Skip to content
Home Java Spring Boot Annotations Cheat Sheet: The Definitive Guide

Spring Boot Annotations Cheat Sheet: The Definitive Guide

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Spring Boot → Topic 5 of 15
A comprehensive Spring Boot Annotations cheat sheet covering Stereotypes, Web, Data, and Configuration annotations with production-grade Java examples.
🧑‍💻 Beginner-friendly — no prior Java experience needed
In this tutorial, you'll learn
A comprehensive Spring Boot Annotations cheat sheet covering Stereotypes, Web, Data, and Configuration annotations with production-grade Java examples.
  • Annotations are metadata — they instruct the Spring IoC container without polluting business logic. But metadata placed in the wrong position (private methods, self-invocations) is silently ignored, making correct placement as important as annotation choice.
  • Stereotype annotations (@Service, @Repository) are not interchangeable with @Component. @Repository adds DataAccessException translation at runtime via a proxy wrapper — this is a behavior difference, not a style preference.
  • Constructor injection is the production standard for dependency management. Final fields, explicit dependency declaration, and testability without Spring context are not optional niceties — they are engineering requirements.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • Spring annotations are metadata markers that instruct the IoC container how to wire, manage, and run your code without manual XML configuration
  • The four stereotype layers: @Component (generic), @Service (business logic), @Repository (data access with exception translation), @Controller / @RestController (web endpoints)
  • Constructor injection is the gold standard — field injection (@Autowired on private fields) hides dependencies and breaks unit testing
  • @Transactional on private methods silently fails because Spring AOP proxies cannot intercept them — the transaction never starts, no error is thrown
  • @Async on private methods fails for the same AOP proxy reason — and @Async requires @EnableAsync on a @Configuration class or nothing runs asynchronously
  • @Configuration with @Bean respects singleton semantics via CGLIB proxy; @Component with @Bean does not — calling one @Bean method from another creates a new instance each time
  • @Value does not work in @Bean methods via field injection — inject properties as method parameters instead
  • The biggest mistake: treating all stereotypes as interchangeable — @Repository adds exception translation that @Component silently does not
Production IncidentThe Silent Transaction Failure — @Transactional on Private MethodsA payment service annotated a private helper method with @Transactional. Half the transactions committed without rollback on failure, corrupting financial data for two weeks before detection.
SymptomPayment records were partially committed to the database. When a downstream validation failed, the payment was recorded but inventory was not decremented. Financial reconciliation showed $12,000 in discrepancies over two weeks.
AssumptionThe team assumed the @Transactional annotation on the private processPayment() method was working. They saw no exception in logs because the transaction proxy was never invoked — the method executed directly without any transaction boundary. The annotation compiled cleanly, the application started without warnings, and the code looked correct to every reviewer.
Root causeSpring AOP creates proxies around beans to intercept method calls. The proxy intercepts external calls and applies advice — starting a transaction before the method, committing or rolling back after. Private methods are invisible to the proxy by design: Java's access control means the proxy cannot override them. The @Transactional annotation on a private method is read at startup and silently discarded at runtime. The method executes on the target object directly, with no transaction boundary, no rollback, and no error.
FixMoved @Transactional to the public entry-point method that called the private helper. Added TransactionTemplate for cases where fine-grained programmatic control was needed within the private logic. Added an integration test using @SpringBootTest that deliberately throws an exception mid-operation and asserts the database state was fully rolled back — not just that no exception escaped.
Key Lesson
Never put @Transactional on private methods — Spring AOP proxies cannot intercept them and the annotation is silently ignoredThe same AOP proxy limitation applies to @Async, @Cacheable, @Secured, and any other advice-based annotation on private methodsSelf-invocation — calling a @Transactional method from within the same class via 'this' — also bypasses the proxyAlways write an integration test that verifies rollback behavior by asserting database state after an exception — never assume an annotation works without testing it
Production Debug GuideWhen Spring annotations behave unexpectedly, here is how to go from observable symptom to resolution.
NoSuchBeanDefinitionException at startup — Spring cannot find a beanCheck that the class has a stereotype annotation (@Component, @Service, @Repository, or @Controller). Verify the package is within the component scan base — if your main class is in io.thecodeforge and the service is in com.external, it will never be scanned. If using a custom package, add @ComponentScan("com.external") to your configuration.
Controller returns HTML template name instead of JSON responseYou are using @Controller instead of @RestController. Either switch to @RestController (which applies @ResponseBody to every method in the class), or add @ResponseBody explicitly to each handler method. The error message 'Could not resolve view' confirms Spring is treating the return value as a template name.
@Transactional not rolling back on exceptionThree causes in order of frequency: (1) the annotated method is private — proxies cannot intercept private methods; (2) the exception is a checked exception — by default @Transactional only rolls back on RuntimeException and Error; add rollbackFor = Exception.class if checked exceptions need rollback; (3) the method is called via self-invocation (this.method()) which bypasses the proxy entirely.
Two beans of the same type — UnsatisfiedDependencyException or wrong bean injectedUse @Qualifier("beanName") on the injection point to specify which bean. Add @Primary to one bean to make it the default when no @Qualifier is specified. Never rely on alphabetical bean name ordering — it is an undocumented implementation detail that can change between Spring versions.
Bean not created in production but works in devThe bean likely has @Profile("dev") or a @ConditionalOnProperty that only matches in dev. Check application-prod.yml for the required property values. Verify spring.profiles.active is set correctly in the production environment. Add logging.level.org.springframework.context=DEBUG to see exactly which beans are being created and why.
Self-invocation skips AOP advice — caching, security, or transactions have no effectCalling a @Cacheable or @Transactional method from within the same class bypasses the proxy because you are calling 'this', not the proxy wrapper. Inject the bean into itself via @Lazy self-injection, or extract the annotated method into a separate service class. The @Lazy approach: @Lazy @Autowired private MyService self; then call self.annotatedMethod().
@Async methods are running synchronously — no thread pool execution observed@Async requires @EnableAsync on a @Configuration class. Without it, @Async is silently ignored and the method runs on the calling thread. Also check that the @Async method is not private and not called via self-invocation — both cause the same silent failure as @Transactional.
@Value field is null at runtime even though the property exists in application.ymlThree causes: (1) the class is not a Spring-managed bean — @Value only works in Spring beans; (2) you are using @Value in a @Bean method via field injection — this does not work because the field is not yet injected when @Bean methods run; (3) the property key has a typo. For @Bean methods, inject the value as a method parameter: @Bean public MyService myService(@Value("${my.property}") String prop).

Spring Boot annotations are the backbone of modern Java development, replacing the verbose XML configurations of the past with a declarative, code-first approach. By using these markers, you instruct the Spring IoC container on how to manage the lifecycle of your objects, wire dependencies, handle HTTP traffic, manage transactions, and run methods asynchronously.

Misusing annotations causes production failures that are genuinely hard to diagnose. A @Transactional on a private method silently skips transaction creation. A @Async on a method without @EnableAsync runs synchronously with no error. A @Value injection in a @Bean method returns null. These are not theoretical edge cases — they are the root cause of real production incidents that have cost teams hours of debugging time and, in one case I will describe, two weeks of financial data corruption.

This guide covers the four categories every Spring Boot engineer needs: Stereotypes, Web and REST, Data and JPA, and Configuration. Each section includes the mechanical why behind the annotation's design, the failure scenarios that catch experienced engineers off guard, and the production debugging patterns that turn a mystery into a five-minute fix.

Stereotype Annotations: Defining Your Application Layers

Spring uses stereotype annotations to categorize classes into specific roles within the application context. When Spring starts, it performs component scanning — a walk of your package tree looking for @Component, @Service, @Repository, and @Controller. Every class it finds gets registered as a bean in the IoC container.

The stereotypes are not interchangeable even though three of them are functionally similar at the basic level. @Repository is the one that stands apart: it wraps the bean in a proxy that catches persistence-layer exceptions (JDBC SQLExceptions, JPA PersistenceExceptions, Hibernate exceptions) and converts them into Spring's DataAccessException hierarchy. Without @Repository, a JDBC connection failure throws a raw SQLException — with @Repository, it throws a DataAccessException subclass that your service layer can handle uniformly regardless of the persistence technology underneath.

@Service adds no runtime behavior beyond @Component — it exists for semantic clarity and tooling. Some frameworks and libraries scan specifically for @Service. More importantly, it communicates to every engineer reading your codebase where business logic lives. That communication is worth the annotation.

Constructor injection is the default the Spring team recommends and the one you should use in all new code. Field injection with @Autowired works, but it makes dependencies invisible at compile time, prevents you from marking fields final, and requires Spring context to instantiate the class in unit tests. Constructor injection makes dependencies explicit, allows final fields, and lets you instantiate the class with plain new in tests.

io/thecodeforge/annotations/ForgeController.java · JAVA
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
package io.thecodeforge.annotations;

import org.springframework.web.bind.annotation.*;
import org.springframework.stereotype.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.dao.DataAccessException;

/**
 * io.thecodeforge: Stereotype annotations in a standard three-layer architecture.
 *
 * @RestController → web layer, handles HTTP
 * @Service        → business layer, contains logic
 * @Repository     → data layer, adds exception translation
 */
@RestController
@RequestMapping("/api/v1/forge")
public class ForgeController {

    private final ForgeService service;

    // Constructor injection: preferred over @Autowired on fields.
    // Dependencies are explicit, final, and testable without Spring context.
    public ForgeController(ForgeService service) {
        this.service = service;
    }

    @PostMapping("/process/{id}")
    public ResponseEntity<String> executeJob(
            @PathVariable("id") Long id,
            @RequestBody JobRequest request) {
        String result = service.process(id, request.getPayload());
        return ResponseEntity.ok(result);
    }
}

@Service
class ForgeService {

    // @Value injects property values from application.yml or environment variables.
    // The :production part is the default if the property is not defined.
    @Value("${forge.environment:production}")
    private String env;

    private final ForgeRepository repository;

    public ForgeService(ForgeRepository repository) {
        this.repository = repository;
    }

    public String process(Long id, String data) {
        try {
            repository.save(id, data);
            return String.format("Env: %s | Job %d processed: %s", env, id, data);
        } catch (DataAccessException ex) {
            // @Repository's exception translation means we catch DataAccessException
            // here regardless of whether the underlying store is JDBC, JPA, or MongoDB.
            // Without @Repository on ForgeRepository, this catch block gets SQLException instead.
            throw new RuntimeException("Failed to persist job " + id, ex);
        }
    }
}

// @Repository adds exception translation — SQLExceptions become DataAccessExceptions.
// Without this annotation, raw persistence exceptions leak into the service layer.
@Repository
class ForgeRepository {
    public void save(Long id, String data) {
        // JDBC or JPA persistence logic here
    }
}

class JobRequest {
    private String payload;
    public String getPayload() { return payload; }
    public void setPayload(String payload) { this.payload = payload; }
}
▶ Output
POST /api/v1/forge/process/99 { "payload": "ForgeData" }
Response: "Env: production | Job 99 processed: ForgeData"

// If ForgeRepository throws a JDBC exception:
// WITHOUT @Repository: throws SQLException (leaks persistence technology details)
// WITH @Repository: throws DataAccessException (technology-agnostic, catchable uniformly)
Mental Model
Component Scanning is a Directory Walk
Spring starts up by walking your package tree from the base package downward, looking for annotated classes — think of it as a file system scan for sticky notes. Anything outside that tree is invisible to Spring.
  • Spring scans from the base package (where @SpringBootApplication lives) downward through all sub-packages
  • Every class with @Component, @Service, @Repository, or @Controller gets registered as a bean
  • If a class is in a package outside the scan path, Spring never sees it — no bean, no injection, no compile-time error
  • Expand the scan path with @ComponentScan("io.thecodeforge.external") or move the main class to the root package
  • @Repository adds DataAccessException translation — this is not cosmetic, it is a runtime proxy wrapping your persistence bean
  • @Service and @Component are functionally equivalent at runtime — the difference is semantic clarity and developer communication
📊 Production Insight
A team moved a service class to a new utility package outside the component scan base during a refactor. The application started cleanly — no compile error, no startup warning. At runtime, the first request that needed the bean failed with NoSuchBeanDefinitionException. Three hours of debugging for a missing package inclusion in the scan path. The fix was one line. The lesson: after any package restructuring, verify all beans are still registered by checking the startup log with logging.level.org.springframework=DEBUG.
🎯 Key Takeaway
Stereotype annotations are not interchangeable — @Repository adds DataAccessException translation that @Component silently does not provide. Component scanning is a directory walk from the base package downward — move a class outside the scan path and it disappears without a compile-time warning. Constructor injection is non-negotiable in new code: explicit dependencies, final fields, testable without Spring context.

Web and REST Annotations: Mapping HTTP Traffic

The web annotation layer sits on top of your @RestController beans and handles the translation between HTTP requests and Java method calls. These annotations do more than just route URLs — they control deserialization, response codes, validation triggers, error handling scope, and header extraction.

@RequestMapping is the parent. The shortcut variants @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, and @PatchMapping are composed annotations that combine @RequestMapping with the method attribute already set. Use the shortcut variants in all new code — they are more readable and communicate intent at a glance.

@PathVariable extracts values from the URI template. @RequestParam extracts query string parameters. @RequestHeader extracts HTTP header values. @RequestBody deserializes the request body from JSON into a Java object using Jackson. @ResponseStatus sets the HTTP status code on a successful response — useful for returning 201 Created on POST endpoints instead of the default 200.

The one that confuses engineers most is @ControllerAdvice and @ExceptionHandler. These two together form Spring's global error handling mechanism. A class annotated with @RestControllerAdvice and methods annotated with @ExceptionHandler intercept exceptions thrown anywhere in your controller layer and let you return consistent, structured error responses instead of raw stack traces.

io/thecodeforge/annotations/web/OrderController.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
package io.thecodeforge.annotations.web;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.Map;

/**
 * io.thecodeforge: Web annotation reference — every annotation you use in a REST controller.
 */
@RestController  // = @Controller + @ResponseBody on every method
@RequestMapping("/api/v1/orders")
public class OrderController {

    // @GetMapping is shorthand for @RequestMapping(method = RequestMethod.GET)
    // Use shortcuts — they communicate intent faster than the verbose form.
    @GetMapping("/{orderId}")
    public ResponseEntity<OrderDto> getOrder(
            // @PathVariable: extracts {orderId} from the URI template
            @PathVariable Long orderId,
            // @RequestHeader: extracts a specific HTTP header — useful for correlation IDs
            @RequestHeader(value = "X-Correlation-Id", required = false) String correlationId) {

        // In real code: return orderService.findById(orderId)
        return ResponseEntity.ok(new OrderDto(orderId, "PENDING"));
    }

    @GetMapping
    public ResponseEntity<Object> searchOrders(
            // @RequestParam: extracts query string parameters
            // /api/v1/orders?status=PENDING&page=0
            @RequestParam(value = "status", required = false, defaultValue = "ALL") String status,
            @RequestParam(value = "page", defaultValue = "0") int page) {

        return ResponseEntity.ok(Map.of("status", status, "page", page));
    }

    // @ResponseStatus sets the default HTTP response code for this method.
    // Without it, POST endpoints return 200 OK — semantically wrong for a creation.
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)  // Returns 201 Created on success
    public OrderDto createOrder(
            // @RequestBody: deserializes JSON request body into OrderDto via Jackson
            // @Valid: triggers Bean Validation on the deserialized object
            @Valid @RequestBody OrderDto dto) {
        return dto;
    }

    @DeleteMapping("/{orderId}")
    @ResponseStatus(HttpStatus.NO_CONTENT)  // 204 No Content — no response body
    public void cancelOrder(@PathVariable Long orderId) {
        // orderService.cancel(orderId)
    }
}

// Record DTO — clean, immutable, no boilerplate
record OrderDto(Long id, String status) {}

// --- Global Exception Handler ---
// @RestControllerAdvice = @ControllerAdvice + @ResponseBody
// Applies to all @Controller and @RestController classes in the application.
// This is the right place for error handling — not try-catch in every controller.
@RestControllerAdvice
class GlobalExceptionHandler {

    // @ExceptionHandler intercepts this exception type thrown anywhere in any controller.
    // Spring matches the most specific handler — IllegalArgumentException before RuntimeException.
    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleBadRequest(IllegalArgumentException ex) {
        return Map.of(
            "error", "Bad Request",
            "message", ex.getMessage()
        );
    }

    // Catch-all for unhandled exceptions — always have this to prevent stack traces reaching clients
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Map<String, String> handleGenericError(Exception ex) {
        // Log ex here — do not return stack trace to client
        return Map.of("error", "Internal Server Error");
    }
}
▶ Output
GET /api/v1/orders/42
→ 200 OK { "id": 42, "status": "PENDING" }

GET /api/v1/orders?status=PENDING&page=1
→ 200 OK { "status": "PENDING", "page": 1 }

POST /api/v1/orders { "id": null, "status": "NEW" }
→ 201 Created { "id": null, "status": "NEW" }

DELETE /api/v1/orders/42
→ 204 No Content

// Throwing IllegalArgumentException in any controller:
→ 400 Bad Request { "error": "Bad Request", "message": "..." }
⚠ @ControllerAdvice vs @RestControllerAdvice
@ControllerAdvice applies to all controllers but does not automatically serialize the return value to JSON — you need @ResponseBody on each @ExceptionHandler method. @RestControllerAdvice combines @ControllerAdvice and @ResponseBody, which is what you almost always want for REST APIs. Use @RestControllerAdvice in all new code unless you are building a mixed web/API application where some handlers return view names.
📊 Production Insight
A team had per-controller try-catch blocks returning error responses in slightly different shapes across 12 controllers. Frontend engineers had to handle four different error JSON structures depending on which endpoint was called. Moving all exception handling to a single @RestControllerAdvice class standardized the error shape across the entire API in one afternoon. Every controller got simpler, tests got easier, and the frontend team stopped filing tickets about inconsistent error formats.
🎯 Key Takeaway
Use @GetMapping, @PostMapping, and the other HTTP method shortcuts — they are more readable than @RequestMapping with a method attribute. Use @ResponseStatus(HttpStatus.CREATED) on POST endpoints — returning 200 for resource creation is semantically incorrect. Put all exception handling in a single @RestControllerAdvice class — per-controller try-catch blocks create inconsistent error responses and duplicate code.

Data and JPA Annotations: Persistence Without Boilerplate

Spring Data JPA annotations bridge the gap between your Java objects and your relational database. Understanding what each annotation does at the SQL level — not just the Java level — is what separates engineers who use JPA from engineers who understand it.

@Entity marks a class as a JPA-managed entity. Every @Entity class needs a corresponding database table. @Table(name = "orders") maps the entity to a specific table name when the class name and table name differ. @Id marks the primary key field. @GeneratedValue(strategy = GenerationType.IDENTITY) tells JPA to let the database generate the ID — this is the correct strategy for most relational databases with auto-increment columns.

@Column(name, nullable, length, unique) maps a field to a column with explicit constraints. Omitting it is fine when the field name and column name match and you have no special constraints — JPA applies default conventions. Use it explicitly when you need to enforce not-null constraints at the JPA layer, not just the database layer.

@OneToMany and @ManyToOne model relationships. The single most common JPA mistake is putting @OneToMany on a collection without understanding the SQL it generates. A naive @OneToMany without join column specification generates a separate join table. Adding @OneToMany(mappedBy = "order") on the parent side and @ManyToOne @JoinColumn(name = "order_id") on the child side generates the correct foreign key relationship.

Spring Data's @Query lets you write JPQL or native SQL when the derived method name conventions become unreadable. A method named findByCustomerEmailAndStatusAndCreatedAtAfterOrderByCreatedAtDesc is technically valid but practically unreadable. @Query makes the intent explicit.

io/thecodeforge/annotations/data/Order.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
package io.thecodeforge.annotations.data;

import jakarta.persistence.*;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

/**
 * io.thecodeforge: JPA entity with relationship modeling.
 * Every annotation here maps to a specific SQL behavior — comments explain what.
 */
@Entity
@Table(name = "orders",
       indexes = @Index(name = "idx_orders_customer_id", columnList = "customer_id"),
       uniqueConstraints = @UniqueConstraint(columnNames = {"reference_number"}))
public class Order {

    // IDENTITY: database auto-increments; never set this field manually.
    // Use SEQUENCE for PostgreSQL high-throughput scenarios — IDENTITY locks the row on insert.
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Explicit column mapping: non-null, max length enforced at JPA layer.
    // JPA enforcement catches violations before a database round trip.
    @Column(name = "reference_number", nullable = false, length = 50, unique = true)
    private String referenceNumber;

    @Column(name = "customer_id", nullable = false)
    private Long customerId;

    // Precision and scale are critical for monetary values.
    // Without them, JPA defaults vary by database — never leave money columns to defaults.
    @Column(name = "total_amount", nullable = false, precision = 19, scale = 4)
    private BigDecimal totalAmount;

    @Enumerated(EnumType.STRING)  // Store 'PENDING' not '0' — readable in DB, survives enum reordering
    @Column(name = "status", nullable = false)
    private OrderStatus status;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    // @OneToMany with mappedBy: this side does not own the relationship.
    // The OrderItem side owns it via @ManyToOne @JoinColumn.
    // Without mappedBy, JPA creates a separate join table — not what you want.
    // CascadeType.ALL: persist/remove items when order is persisted/removed.
    // orphanRemoval: delete items from DB when removed from this collection.
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items;

    @PrePersist  // Lifecycle callback: runs before first save, not on updates
    protected void onCreate() {
        this.createdAt = LocalDateTime.now();
    }

    // Constructors, getters, setters omitted for brevity
    public Long getId() { return id; }
    public String getReferenceNumber() { return referenceNumber; }
    public OrderStatus getStatus() { return status; }
    public void setStatus(OrderStatus status) { this.status = status; }
}

@Entity
@Table(name = "order_items")
class OrderItem {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // @ManyToOne is the owning side — this entity's table has the FK column.
    // @JoinColumn(name = "order_id") names the foreign key column explicitly.
    @ManyToOne(fetch = FetchType.LAZY)  // LAZY: do not load Order unless accessed
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;

    @Column(name = "product_id", nullable = false)
    private Long productId;

    @Column(name = "quantity", nullable = false)
    private int quantity;

    public Order getOrder() { return order; }
    public void setOrder(Order order) { this.order = order; }
}

enum OrderStatus { PENDING, CONFIRMED, SHIPPED, CANCELLED }

// --- Spring Data JPA Repository ---
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Derived query: Spring generates the SQL from the method name.
    // Readable for simple cases — use @Query when the method name becomes a sentence.
    List<Order> findByCustomerIdAndStatus(Long customerId, OrderStatus status);

    // @Query with JPQL: use entity names and field names, not table/column names.
    // This is more readable than a 60-character method name.
    @Query("SELECT o FROM Order o WHERE o.customerId = :customerId "
         + "AND o.totalAmount >= :minAmount "
         + "AND o.createdAt >= :since "
         + "ORDER BY o.createdAt DESC")
    List<Order> findHighValueOrdersSince(
            @Param("customerId") Long customerId,
            @Param("minAmount") BigDecimal minAmount,
            @Param("since") LocalDateTime since);

    // @Modifying + @Transactional: required for UPDATE and DELETE queries.
    // Without @Modifying, Spring throws an exception.
    // Without @Transactional, the update executes but may not commit.
    // clearAutomatically=true: clears the persistence context after execution
    // to prevent stale cached entities being returned in the same transaction.
    @Modifying(clearAutomatically = true)
    @Transactional
    @Query("UPDATE Order o SET o.status = :status WHERE o.id = :id")
    int updateStatus(@Param("id") Long id, @Param("status") OrderStatus status);

    // Native SQL query: use when JPQL cannot express the query (window functions,
    // database-specific functions, complex subqueries).
    // nativeQuery=true: Spring passes the SQL directly to the database.
    @Query(value = "SELECT * FROM orders WHERE EXTRACT(MONTH FROM created_at) = :month",
           nativeQuery = true)
    List<Order> findOrdersForMonth(@Param("month") int month);

    Optional<Order> findByReferenceNumber(String referenceNumber);
}
▶ Output
// findByCustomerIdAndStatus(42L, OrderStatus.PENDING) generates:
// SELECT * FROM orders WHERE customer_id = 42 AND status = 'PENDING'

// findHighValueOrdersSince(42L, BigDecimal("100.00"), lastWeek) generates:
// SELECT * FROM orders WHERE customer_id = 42
// AND total_amount >= 100.00
// AND created_at >= '2026-04-11' ORDER BY created_at DESC

// updateStatus(42L, OrderStatus.CONFIRMED) generates:
// UPDATE orders SET status = 'CONFIRMED' WHERE id = 42
// Returns: 1 (rows affected)

// @PrePersist fires on first save:
// INSERT INTO orders (reference_number, customer_id, total_amount, status, created_at)
// VALUES ('REF-001', 42, 99.9900, 'PENDING', '2026-04-18T10:00:00')
⚠ Never Use @Enumerated(EnumType.ORDINAL) in Production
EnumType.ORDINAL stores the enum's integer position (0, 1, 2, ...) in the database. If you ever reorder your enum values — even to add a new value in the middle — every existing record in the database now maps to the wrong enum constant. EnumType.STRING stores the name ('PENDING', 'CONFIRMED') which is readable in the database, survives reordering, and makes database queries understandable without enum source code. Use EnumType.STRING unconditionally. The storage overhead of a short string over an integer is negligible.
📊 Production Insight
A team used EnumType.ORDINAL on a PaymentStatus enum. Six months later, a product requirement added a new PROCESSING status between PENDING and CONFIRMED in the source code. The ordinal values shifted. Every 'CONFIRMED' record in the database was now interpreted as 'PROCESSING'. The payment dashboard showed all historical confirmed payments as processing. Rolling back the enum change and running a data migration to fix ordinal values took a weekend. The fix going forward was one annotation attribute change: EnumType.STRING.
🎯 Key Takeaway
JPA annotations map Java objects to SQL — always think about what SQL they generate, not just what they do at the Java layer. Use EnumType.STRING unconditionally. Use mappedBy on the non-owning side of relationships to avoid accidental join table creation. @Modifying requires @Transactional on the repository method, and clearAutomatically=true prevents stale entity caching after bulk updates.

@Transactional Deep Dive: Propagation, Isolation, and What Actually Happens

@Transactional is the annotation with the most misunderstood behavior in the Spring ecosystem. Most engineers know it starts a transaction — fewer know the propagation and isolation attributes that control what happens when a @Transactional method calls another @Transactional method.

Propagation controls the transaction boundary when one transactional method calls another. REQUIRED (the default) means 'join the existing transaction if one exists, start a new one if not.' REQUIRES_NEW means 'always start a new transaction, suspend the current one.' NESTED means 'create a savepoint within the current transaction — roll back to the savepoint on exception without rolling back the outer transaction.'

The production scenario where this matters: an audit logging method should always commit even when the main operation rolls back. If the audit method uses REQUIRED, a rollback on the main transaction rolls back the audit entry too. The fix: REQUIRES_NEW on the audit method gives it its own independent transaction.

Isolation controls what the transaction can see from concurrent transactions. READ_COMMITTED (the PostgreSQL and SQL Server default) prevents dirty reads but allows non-repeatable reads and phantom reads. REPEATABLE_READ prevents non-repeatable reads. SERIALIZABLE prevents phantom reads but degrades throughput.

The rollback rule is the silent killer: by default, @Transactional only rolls back on RuntimeException and Error. Checked exceptions — IOException, SQLException — do not trigger rollback. This means a method that throws a checked exception mid-operation commits the work done before the exception.

io/thecodeforge/annotations/data/PaymentService.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
package io.thecodeforge.annotations.data;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;

/**
 * io.thecodeforge: @Transactional propagation and isolation reference.
 * This single class demonstrates every propagation scenario that matters in production.
 */
@Service
public class PaymentService {

    private final AuditService auditService;

    public PaymentService(AuditService auditService) {
        this.auditService = auditService;
    }

    /**
     * REQUIRED (default): joins existing transaction or starts a new one.
     * This is correct for most service methods.
     *
     * rollbackFor = Exception.class: rolls back on ALL exceptions,
     * not just RuntimeException. Use this when your method can throw
     * checked exceptions that should also trigger rollback.
     */
    @Transactional(rollbackFor = Exception.class)
    public void processPayment(Long orderId, BigDecimal amount) throws Exception {
        // 1. Debit account
        // 2. Update order status
        // 3. Send to payment gateway

        // auditService.logPayment() runs in its OWN transaction (REQUIRES_NEW).
        // If processPayment() rolls back, the audit log is NOT rolled back.
        // This is intentional — you always want an audit trail, even for failures.
        auditService.logPayment(orderId, amount, "INITIATED");

        // Simulate a failure after audit has been written
        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Amount must be positive");
            // Transaction rolls back. auditService.logPayment() does NOT roll back
            // because it ran in REQUIRES_NEW — its transaction already committed.
        }
    }

    /**
     * REQUIRES_NEW: always starts a fresh transaction.
     * Suspends any calling transaction.
     * Use for: audit logging, retry-safe operations, independent operations
     * that should not roll back with the caller.
     *
     * WARNING: REQUIRES_NEW acquires a new connection from the pool.
     * Nested REQUIRES_NEW calls under heavy load can exhaust the connection pool.
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processPaymentIndependently(Long orderId, BigDecimal amount) {
        // Commits independently — caller's rollback does not affect this.
    }

    /**
     * READ_COMMITTED isolation: prevents dirty reads (seeing uncommitted data
     * from other transactions). Allows non-repeatable reads — the same query
     * run twice in the same transaction can return different results.
     *
     * Use READ_COMMITTED for most read operations where perfect consistency
     * is not required and throughput matters.
     */
    @Transactional(readOnly = true, isolation = Isolation.READ_COMMITTED)
    public BigDecimal getAccountBalance(Long accountId) {
        // readOnly=true: hint to JPA to skip dirty checking on entities
        // — meaningful performance improvement in read-heavy paths.
        // Does NOT prevent writes at the JDBC level — use for read-only methods.
        return BigDecimal.ZERO; // In real code: accountRepository.findBalance(accountId)
    }

    /**
     * NEVER: throws an exception if there IS an active transaction.
     * MANDATORY: throws an exception if there is NO active transaction.
     * SUPPORTS: joins existing transaction if present, runs non-transactionally if not.
     * NOT_SUPPORTED: always runs non-transactionally, suspends existing transaction.
     *
     * These four are rare — document why you are using them when you do.
     */
    @Transactional(propagation = Propagation.MANDATORY)
    public void mustRunInTransaction(Long orderId) {
        // Throws IllegalTransactionStateException if called without an active transaction.
        // Use when a method only makes sense inside a transaction boundary.
    }
}

@Service
class AuditService {

    /**
     * REQUIRES_NEW: this method always runs in its own independent transaction.
     * Even if the calling transaction (processPayment) rolls back,
     * this audit log entry is committed.
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logPayment(Long orderId, BigDecimal amount, String status) {
        // Persists audit record in its own committed transaction
    }
}
▶ Output
// Scenario: processPayment() throws exception after logPayment() runs
//
// Timeline:
// T1: processPayment() starts transaction TX1
// T2: auditService.logPayment() suspends TX1, starts TX2 (REQUIRES_NEW)
// T3: logPayment() completes, TX2 commits audit record to DB
// T4: TX1 resumes, exception thrown
// T5: TX1 rolls back — payment debit, order status update rolled back
// T6: TX2 already committed — audit log entry STAYS in the database
//
// Result: payment failed cleanly, but the audit trail shows what was attempted.
// This is the correct behavior for financial systems.
⚠ REQUIRES_NEW Under Load Exhausts the Connection Pool
Every REQUIRES_NEW call acquires a new database connection from the pool while holding the calling transaction's connection. If processPayment() (connection 1) calls auditService.logPayment() with REQUIRES_NEW (connection 2), and your pool has 10 connections, 5 concurrent payment operations fully exhaust the pool. The 6th request waits indefinitely. This is the most common source of connection pool starvation in systems that use REQUIRES_NEW for audit logging. Size your connection pool to account for the maximum nesting depth, or use asynchronous audit logging with @TransactionalEventListener(phase = AFTER_COMMIT) to decouple the audit write from the main transaction.
📊 Production Insight
The default rollback rule — only RuntimeException and Error — is the most quietly destructive @Transactional behavior in production. A team had a payment method that could throw a checked IOException when the payment gateway was unreachable. The IOException propagated up, the transaction committed the partial work (account debited, no payment record), and the exception reached the controller. Financial records showed money leaving accounts with no corresponding payment. The fix was one attribute: rollbackFor = Exception.class. Every service method that can throw checked exceptions needs this unless you explicitly want checked exceptions to not trigger rollback.
🎯 Key Takeaway
REQUIRED (default) joins the existing transaction. REQUIRES_NEW starts its own — use for audit logging and independent operations, but watch connection pool exhaustion. rollbackFor = Exception.class is often what you actually want — the default (RuntimeException only) silently commits on checked exceptions. readOnly = true is a meaningful JPA performance hint for read paths — it skips dirty checking across all entities in the session.

@Async: Running Methods in a Background Thread

@Async is Spring's mechanism for running a method in a separate thread pool thread rather than the calling thread. The use cases are real and common: sending emails after registration, publishing events to a message queue, generating reports, processing webhooks — anything where the caller should not wait for completion.

The mechanics: @Async works via the same AOP proxy mechanism as @Transactional. Spring wraps the bean in a proxy. When an external caller invokes an @Async method, the proxy submits the method call to a thread pool (the default is SimpleAsyncTaskExecutor, which creates a new thread per call — you almost never want this in production) and immediately returns to the caller.

Three things break @Async silently. First: missing @EnableAsync on a @Configuration class. Without it, @Async is completely ignored — the method runs synchronously on the calling thread with no error and no warning. Second: calling the @Async method from within the same class (self-invocation bypasses the proxy). Third: the method is private.

The return type contract: @Async methods must return void or Future/CompletableFuture. A method returning a plain String annotated with @Async will still be called asynchronously, but the String value returned will always be null at the call site — the actual String was computed on a different thread and the proxy cannot return it synchronously. Use CompletableFuture<String> if you need the result.

io/thecodeforge/annotations/async/NotificationService.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
package io.thecodeforge.annotations.async;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;

/**
 * io.thecodeforge: @Async configuration and usage reference.
 *
 * @EnableAsync is required — without it, @Async is silently ignored.
 * Never forget this annotation. Never.
 */
@Configuration
@EnableAsync
public class AsyncConfig {

    /**
     * Custom thread pool executor.
     * The default SimpleAsyncTaskExecutor creates a new thread per task — not acceptable
     * in production. It will exhaust OS thread limits under any meaningful load.
     *
     * This executor is bounded: max 20 threads, queue depth 500.
     * Tune these values based on your expected concurrent async task volume.
     *
     * Name the bean 'taskExecutor' to make it the default for all @Async calls,
     * or name it something descriptive and reference it in @Async("notificationExecutor").
     */
    @Bean(name = "notificationExecutor")
    public Executor notificationExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("notification-");
        // RejectedExecutionHandler: what happens when queue is full and pool is at max
        // CallerRunsPolicy: run on the calling thread (back pressure)
        // AbortPolicy: throw RejectedExecutionException (default)
        executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

@Service
public class NotificationService {

    /**
     * void return: fire-and-forget. Caller does not wait.
     * Exceptions thrown in this method are lost unless you configure
     * AsyncUncaughtExceptionHandler — configure one, always.
     *
     * "notificationExecutor" specifies which executor to use.
     * Without the name, Spring uses the default executor (SimpleAsyncTaskExecutor).
     */
    @Async("notificationExecutor")
    public void sendWelcomeEmail(String email) {
        // Runs on a thread from notificationExecutor pool
        // Caller returns immediately after the proxy submits this to the pool
        System.out.println("Sending welcome email to " + email
            + " on thread: " + Thread.currentThread().getName());
    }

    /**
     * CompletableFuture return: async with result.
     * Caller can chain .thenApply(), .thenAccept(), or .join() to get the result.
     * CompletableFuture.completedFuture() wraps the result for the proxy to return.
     */
    @Async("notificationExecutor")
    public CompletableFuture<String> generateReport(Long userId) {
        // Long-running report generation on background thread
        String report = "Report for user " + userId;
        return CompletableFuture.completedFuture(report);
    }

    // WRONG: calling sendWelcomeEmail from within the same class.
    // The call goes to 'this', not the proxy — @Async is bypassed.
    // sendWelcomeEmail() runs synchronously on the calling thread.
    public void registerUser(String email) {
        this.sendWelcomeEmail(email); // @Async bypassed — runs synchronously
    }
}
▶ Output
// Correct usage: called from a different bean
// notificationService.sendWelcomeEmail("user@example.com");
// → Returns immediately to caller
// → Email sent on thread: notification-1 (background thread)

// CompletableFuture usage:
// CompletableFuture<String> future = notificationService.generateReport(42L);
// future.thenAccept(report -> System.out.println(report)); // non-blocking
// String result = future.join(); // blocking — waits for completion

// Self-invocation trap:
// notificationService.registerUser("user@example.com");
// → sendWelcomeEmail runs on the CALLING thread — not async
// → No error, no warning — just silent synchronous execution
⚠ Exceptions in @Async void Methods Are Silently Swallowed
When an @Async void method throws an exception, it is thrown on the background thread after the caller has already returned. By default, Spring logs the exception at ERROR level but otherwise swallows it. The caller never sees it. Configure an AsyncUncaughtExceptionHandler to handle these exceptions: implement AsyncUncaughtExceptionHandler and register it in a class that implements AsyncConfigurer. This is not optional in production — silent exception swallowing in background threads is how data inconsistencies accumulate undetected.
📊 Production Insight
A team added @Async to their email notification method and deployed without configuring a custom executor. SimpleAsyncTaskExecutor creates an unbounded new thread per invocation. Under a promotional campaign load spike, the service received 50,000 registration events in 10 minutes — 50,000 threads were created simultaneously. The JVM crashed with OutOfMemoryError: unable to create native thread. The entire service went down, not just email notifications. A bounded ThreadPoolTaskExecutor with CallerRunsPolicy would have applied back pressure without crashing the JVM.
🎯 Key Takeaway
@EnableAsync is required — without it @Async is silently ignored with no error. Never use the default SimpleAsyncTaskExecutor in production — always configure a bounded ThreadPoolTaskExecutor. @Async on private methods and self-invocation both silently bypass the proxy and run synchronously. Exceptions in @Async void methods are lost by default — configure AsyncUncaughtExceptionHandler.

Advanced Configuration and Profile Management

As your application grows, you need to manage different behaviors for different environments and feature states. This is where @Configuration, @Bean, @Profile, and the @Conditional family of annotations come into play.

Instead of hardcoding environment checks in logic, you define bean recipes. Spring evaluates these recipes at startup and creates exactly the beans appropriate for the current environment and configuration state. A dev environment gets a local mock storage service. A prod environment gets the S3 implementation. A feature-flagged caching layer only exists when forge.cache.enabled=true is set.

The important mechanical detail that most explanations skip: @Configuration classes are CGLIB-proxied by Spring. When one @Bean method calls another @Bean method within the same @Configuration class, the call goes through the CGLIB proxy — Spring intercepts it and returns the existing singleton instance rather than executing the method body again. This preserves singleton semantics. With a @Component class containing @Bean methods (lite mode), there is no CGLIB proxy — calling one @Bean method from another creates a new instance, breaking the singleton contract. Always use @Configuration for classes with @Bean methods.

io/thecodeforge/annotations/config/CloudConfig.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
package io.thecodeforge.annotations.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.*;
import org.springframework.context.annotation.*;

/**
 * io.thecodeforge: Configuration annotation reference.
 *
 * CGLIB proxy note: @Configuration classes are proxied by Spring.
 * Calling one @Bean method from another returns the existing singleton.
 * Using @Component instead of @Configuration breaks thisdo not do it.
 */
@Configuration
public class CloudConfig {

    /**
     * @Profile: only creates this bean when spring.profiles.active=prod.
     * Use for environment-level switching of entire subsystem implementations.
     */
    @Bean
    @Profile("prod")
    public StorageService s3Storage() {
        return new S3StorageService();
    }

    /**
     * @Profile with multiple values: creates bean in dev OR test environments.
     * Useful for test doubles that should be available in both.
     */
    @Bean
    @Profile({"dev", "test"})
    public StorageService localStorageForDevAndTest() {
        return new LocalStorageService();
    }

    /**
     * @ConditionalOnProperty: creates bean when forge.mock.enabled=true.
     * matchIfMissing=false: if the property is absent, the bean is NOT created.
     * matchIfMissing=true: if the property is absent, the bean IS created (opt-out pattern).
     */
    @Bean
    @ConditionalOnProperty(
        name = "forge.cache.enabled",
        havingValue = "true",
        matchIfMissing = false
    )
    public CacheService redisCache() {
        return new RedisCacheService();
    }

    /**
     * @ConditionalOnMissingBean: creates this bean ONLY if no other CacheService
     * bean has been defined. This is the default/fallback pattern.
     * Spring Boot's auto-configuration uses this extensively.
     */
    @Bean
    @ConditionalOnMissingBean(CacheService.class)
    public CacheService noOpCache() {
        return new NoOpCacheService();
    }

    /**
     * @ConditionalOnClass: creates this bean only when a specific class is
     * on the classpath. Use for optional integrations.
     * If the class is present, the dependency is available — configure accordingly.
     */
    @Bean
    @ConditionalOnClass(name = "io.lettuce.core.RedisClient")
    public StorageService lettuceBackedStorage() {
        return new LocalStorageService(); // Would be Lettuce-based in real code
    }

    /**
     * @Value in @Bean methods: inject as a parameter, not a field.
     * Field @Value injection happens after @Bean methods run — the field is null
     * when the @Bean method executes. This is the correct pattern.
     */
    @Bean
    public StorageService configuredStorage(
            @Value("${storage.bucket:default-bucket}") String bucket,
            @Value("${storage.region:us-east-1}") String region) {
        // bucket and region are correctly injected as method parameters
        return new S3StorageService(bucket, region);
    }
}

interface StorageService {}
interface CacheService {}

class S3StorageService implements StorageService {
    private final String bucket;
    private final String region;
    S3StorageService() { this.bucket = "default"; this.region = "us-east-1"; }
    S3StorageService(String bucket, String region) { this.bucket = bucket; this.region = region; }
}
class LocalStorageService implements StorageService {}
class RedisCacheService implements CacheService {}
class NoOpCacheService implements CacheService {}
▶ Output
// spring.profiles.active=prod:
// Bean 's3Storage' created
// Bean 'localStorageForDevAndTest' NOT created
// Bean 'configuredStorage' created (always — no condition)

// spring.profiles.active=dev:
// Bean 'localStorageForDevAndTest' created
// Bean 's3Storage' NOT created

// forge.cache.enabled=true:
// Bean 'redisCache' created
// Bean 'noOpCache' NOT created (ConditionalOnMissingBean — redisCache exists)

// forge.cache.enabled=false (or absent):
// Bean 'redisCache' NOT created
// Bean 'noOpCache' created (no CacheService bean exists)

// @Value in @Bean method (correct — injected as parameter):
// bucket = value from application.yml or 'default-bucket'
// region = value from application.yml or 'us-east-1'
⚠ @Value Does Not Work via Field Injection in @Bean Methods
@Value fields are populated by Spring after the application context is initialized. @Bean methods run during context initialization. If you read a @Value-annotated field inside a @Bean method body, the field is null because the application context has not finished initializing yet. The correct pattern: inject the property value as a @Bean method parameter. Spring correctly resolves @Value annotations on method parameters during @Bean execution. This is a subtle timing issue that produces NullPointerException at startup — often misdiagnosed as a missing property.
📊 Production Insight
A team put all 200 bean definitions in a single AppConfig.java. One @Bean method threw a NullPointerException at startup because of a misconfigured dependency. Spring failed the entire @Configuration class — none of the 200 beans were created. The application would not start at all. Debugging meant finding the one failing method among 200. Splitting into SecurityConfig, DatabaseConfig, CacheConfig, and StorageConfig isolated the failure to a four-method class. The bad bean was identified in two minutes instead of 40.
🎯 Key Takeaway
@Configuration is not cosmetic — it activates CGLIB proxying that preserves singleton semantics across @Bean method calls. @Component with @Bean methods does not do this. Inject @Value as @Bean method parameters, not as fields read inside @Bean method bodies. Split configurations by domain — a single @Configuration class with 50+ @Bean methods is a single point of startup failure.
Choosing Between @Profile and @ConditionalOnProperty
IfEntire environment differs — different DB, different auth, different storage
UseUse @Profile to activate different bean implementations per environment
IfIndividual feature toggle, independent of which environment is active
UseUse @ConditionalOnProperty for granular on/off control per feature
IfBean depends on a library being on the classpath — optional integration
UseUse @ConditionalOnClass — creates the bean only when the required class is present
IfBean should exist only if no other bean of that type is already defined
UseUse @ConditionalOnMissingBean — the default/fallback pattern used by Spring Boot auto-configuration
IfTwo beans, same type, need both available but inject different ones at different sites
UseUse @Primary on the default + @Qualifier on specific injection points
🗂 Spring Boot Annotations Reference
Every annotation category at a glance — stereotype, web, data, configuration, and AOP-based behavior.
AnnotationCategoryPurpose and Key Behavior
@ComponentStereotypeGeneric managed bean. Use for utility classes that do not fit a specific layer. Base annotation for all stereotypes.
@ServiceStereotypeBusiness logic layer. Functionally identical to @Component at runtime — exists for semantic clarity and developer communication.
@RepositoryStereotypeData access layer. Adds automatic DataAccessException translation via PersistenceExceptionTranslationPostProcessor proxy wrapper.
@ControllerWebWeb request handler. Returns view names by default — use with Thymeleaf or other template engines for server-rendered HTML.
@RestControllerWebAPI entry point. Combines @Controller and @ResponseBody — every method returns data serialized to JSON, never a view name.
@RequestMappingWebMaps HTTP requests to handler methods. Use the shortcut variants @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, @PatchMapping.
@PathVariableWebExtracts a value from the URI template: @GetMapping('/{id}') + @PathVariable Long id.
@RequestParamWebExtracts a query string parameter. Supports required=false and defaultValue for optional parameters.
@RequestBodyWebDeserializes the HTTP request body (JSON) into a Java object via Jackson. Combine with @Valid for Bean Validation.
@RequestHeaderWebExtracts an HTTP header value. Use required=false for optional headers like correlation IDs.
@ResponseStatusWebSets the HTTP status code for a successful response. Use @ResponseStatus(HttpStatus.CREATED) on POST endpoints.
@ExceptionHandlerWebIntercepts a specific exception type thrown in a controller and maps it to a structured HTTP response.
@RestControllerAdviceWebApplies @ExceptionHandler methods globally across all controllers. The right place for consistent error response formatting.
@EntityData/JPAMarks a class as a JPA-managed entity mapped to a database table. Requires @Id.
@IdData/JPAMarks the primary key field of a JPA entity.
@GeneratedValueData/JPASpecifies ID generation strategy. IDENTITY for auto-increment columns; SEQUENCE for PostgreSQL high-throughput scenarios.
@ColumnData/JPAMaps a field to a column with explicit constraints: nullable, length, unique, precision, scale.
@OneToMany / @ManyToOneData/JPAModels one-to-many relationships. Use mappedBy on the parent, @JoinColumn on the child. Without mappedBy, JPA creates a join table.
@Enumerated(EnumType.STRING)Data/JPAStores enum as its name ('PENDING') not its ordinal (0). Always use STRING — ordinal breaks if enum order changes.
@QueryData/JPADefines explicit JPQL or native SQL on a Spring Data repository method. Use when derived method names become unreadable.
@ModifyingData/JPARequired on @Query methods that perform UPDATE or DELETE. Must be combined with @Transactional on the repository method.
@TransactionalAOP/DataWraps the method in a database transaction. Only works on public methods called via proxy. Default rollback: RuntimeException only.
@ConfigurationConfigMarks a class as a bean factory. CGLIB-proxied to preserve singleton semantics across @Bean method calls.
@BeanConfigDefines a single bean in a @Configuration class. The method return value is registered as a Spring bean.
@ProfileConfigActivates a bean only when the specified Spring profile is active. Use for environment-level bean switching.
@ConditionalOnPropertyConfigActivates a bean based on a specific property value. Use for feature-level toggles independent of environment.
@ConditionalOnMissingBeanConfigCreates a bean only if no other bean of that type exists. The default/fallback pattern used by Spring Boot auto-configuration.
@ValueConfigInjects a property value or SpEL expression. In @Bean methods, inject as method parameter, not as a field.
@AsyncAOPRuns the method on a background thread pool. Requires @EnableAsync. Silently ignored on private methods and self-invocations.
@EnableAsyncAOPActivates @Async processing. Required on a @Configuration class. Without it, @Async is silently ignored.
@SpringBootApplicationBootstrapCombines @Configuration, @EnableAutoConfiguration, and @ComponentScan. Place in the root package for full scan coverage.

🎯 Key Takeaways

  • Annotations are metadata — they instruct the Spring IoC container without polluting business logic. But metadata placed in the wrong position (private methods, self-invocations) is silently ignored, making correct placement as important as annotation choice.
  • Stereotype annotations (@Service, @Repository) are not interchangeable with @Component. @Repository adds DataAccessException translation at runtime via a proxy wrapper — this is a behavior difference, not a style preference.
  • Constructor injection is the production standard for dependency management. Final fields, explicit dependency declaration, and testability without Spring context are not optional niceties — they are engineering requirements.
  • @Transactional only rolls back on RuntimeException and Error by default. Add rollbackFor = Exception.class when your method throws checked exceptions that should also trigger rollback. Private methods and self-invocations bypass the proxy entirely — the transaction never starts.
  • @Async requires @EnableAsync and a bounded ThreadPoolTaskExecutor. Without @EnableAsync it is silently ignored. Without a custom executor, SimpleAsyncTaskExecutor creates unbounded threads and crashes the JVM under load. Exceptions in @Async void methods are swallowed by default — configure AsyncUncaughtExceptionHandler.
  • @Configuration is not cosmetic — it activates CGLIB proxying that preserves singleton semantics across @Bean method calls. @Component with @Bean methods does not do this. Always use @Configuration for bean factory classes.
  • Never use EnumType.ORDINAL in @Enumerated. Adding a new enum value in the middle shifts all subsequent ordinals, silently corrupting every existing database record that stored those ordinals.
  • @ControllerAdvice with @ExceptionHandler is the correct place for error handling — not try-catch blocks in every controller. One @RestControllerAdvice class gives you consistent error response shapes across every endpoint in the application.

⚠ Common Mistakes to Avoid

    Using @Autowired on fields instead of constructors
    Symptom

    Dependencies are invisible at compile time. Unit tests require a Spring context to instantiate the class — you cannot pass mocks directly via constructor. Fields cannot be final. The class compiles fine but fails silently when a dependency is missing at runtime with NoSuchBeanDefinitionException.

    Fix

    Switch to constructor injection. Spring auto-injects constructor parameters without @Autowired if there is only one constructor. This makes dependencies explicit, allows final fields for immutability, and lets you instantiate the class with plain new in unit tests passing mocks directly.

    Forgetting @ResponseBody or using @Controller instead of @RestController for APIs
    Symptom

    Controller methods return HTTP 404 with 'Could not resolve view' error. Spring looks for an HTML template matching the return value string instead of serializing the object to JSON. The endpoint appears to work during development when a template resolver is loose about missing views.

    Fix

    Use @RestController instead of @Controller for all REST APIs. @RestController combines @Controller and @ResponseBody, ensuring every method serializes the return value to JSON. If you must use @Controller, add @ResponseBody to each handler method individually.

    Putting @Transactional on private methods
    Symptom

    Data corruption — partial commits occur when exceptions are thrown mid-operation. No error is thrown, no warning is logged. The annotation is present in source code and passes code review, but the transaction boundary never exists at runtime.

    Fix

    Move @Transactional to the public entry-point method. If fine-grained control is needed inside private helpers, use TransactionTemplate for programmatic transaction management. Write an integration test that deliberately throws an exception and asserts the full database rollback.

    Self-invocation bypassing AOP proxies
    Symptom

    @Cacheable, @Transactional, or @Secured annotations on methods called via this.method() from within the same class have no effect. Cache misses on every call. Transactions never start. Security checks are bypassed. No error, no warning.

    Fix

    Extract the annotated method into a separate service bean and inject that bean. Alternatively, use @Lazy self-injection: @Lazy @Autowired private MyService self; and call self.annotatedMethod() instead of this.annotatedMethod().

    Using @Component when @Service or @Repository is semantically and functionally more appropriate
    Symptom

    Data access exceptions are not translated — raw SQLExceptions or JPA PersistenceExceptions propagate to the service layer, bypassing Spring's DataAccessException hierarchy. Code reviewers cannot quickly identify which layer a class belongs to.

    Fix

    Use @Service for business logic, @Repository for data access, and @Component only for generic utilities. @Repository's exception translation is a runtime behavior difference, not just a style difference.

    Missing @EnableAsync when using @Async
    Symptom

    @Async annotated methods run synchronously on the calling thread. No error, no warning, no indication that the annotation is being ignored. The performance problem that @Async was meant to solve is still present, just invisible.

    Fix

    Add @EnableAsync to a @Configuration class. This activates Spring's async method execution support. Without it, @Async is read at startup and silently discarded.

    Using the default SimpleAsyncTaskExecutor for @Async in production
    Symptom

    Under load, thread count grows unboundedly. The JVM crashes with OutOfMemoryError: unable to create native thread. Every @Async invocation creates a new OS thread. The application works fine in development and under moderate load, then fails catastrophically under a traffic spike.

    Fix

    Always define a custom ThreadPoolTaskExecutor bean for @Async methods. Configure corePoolSize, maxPoolSize, queueCapacity, and a RejectedExecutionHandler. CallerRunsPolicy applies back pressure by running the task on the calling thread when the pool is full — this prevents JVM crash at the cost of slowing the caller temporarily.

    Using EnumType.ORDINAL for @Enumerated
    Symptom

    Adding a new enum value in the middle of the enum definition shifts the ordinals of all subsequent values. Existing database records that stored the integer ordinal now map to the wrong enum constant. Data corruption is silent until a business process compares database values against expected states.

    Fix

    Use @Enumerated(EnumType.STRING) unconditionally. String storage is readable in the database, immune to enum reordering, and the storage overhead of a short string versus an integer is negligible.

    Reading a @Value field inside a @Bean method body
    Symptom

    NullPointerException at application startup inside a @Bean method. The @Value-annotated field is null because Spring injects @Value fields after context initialization, which happens after @Bean methods run.

    Fix

    Inject the property value as a @Bean method parameter annotated with @Value: @Bean public MyService myService(@Value("${my.property}") String prop). Spring correctly resolves @Value on method parameters during @Bean execution.

Interview Questions on This Topic

  • QWhat is the difference between @Component, @Service, and @Repository in Spring? Does the framework treat them differently?Mid-levelReveal
    All three are stereotype annotations that register a class as a Spring bean during component scanning. At the basic level, they are functionally identical — any class annotated with any of these gets picked up and managed by the IoC container. However, @Repository adds a meaningful runtime difference: automatic exception translation. PersistenceExceptionTranslationPostProcessor wraps @Repository beans in a proxy that catches persistence-layer exceptions — JDBC SQLExceptions, JPA PersistenceExceptions, Hibernate exceptions — and converts them into Spring's DataAccessException hierarchy. This means your service layer catches DataAccessException regardless of whether the underlying store is JDBC, JPA, or MongoDB. @Service adds no extra runtime behavior beyond @Component. It exists for semantic clarity — it communicates that the class contains business logic. Some frameworks and annotation processors specifically scan for @Service. The distinction also matters for code reviews and architecture enforcement. The practical rule: always use the most semantically specific annotation. @Repository for data access, @Service for business logic, @Component for generic utilities that do not fit either layer.
  • QExplain how @RestController is different from @Controller. When would you still choose to use @Controller?Mid-levelReveal
    @RestController is a composed annotation that combines @Controller and @ResponseBody. With @Controller alone, handler methods return a view name by default — Spring looks for a matching template (Thymeleaf, JSP, FreeMarker) to render HTML. Adding @ResponseBody to a method tells Spring to serialize the return value directly to the HTTP response body as JSON or XML using the configured HttpMessageConverters. @RestController applies @ResponseBody to every method in the class automatically, which is what you want for any REST API. You would still use @Controller when building server-rendered web applications that return HTML views — a Spring MVC application with Thymeleaf templates where controller methods return template names like 'orders/list'. You would also use @Controller in mixed applications that have some endpoints returning views and others returning JSON, adding @ResponseBody selectively to the JSON endpoints. Modern API-first applications should use @RestController exclusively for API endpoints.
  • QA bean is annotated with @Scope('prototype'). How does this behave when injected into a singleton-scoped bean?SeniorReveal
    When a prototype-scoped bean is injected into a singleton-scoped bean, the injection happens once — at the time the singleton is created. The singleton holds a single reference to one prototype instance for its entire lifetime. The prototype bean effectively behaves like a singleton, which completely defeats the purpose of prototype scope. To get a fresh prototype instance on each use, three approaches work: First: inject ObjectFactory<PrototypeBean> and call getObject() each time you need an instance. ObjectFactory is a Spring interface that acts as a lazy factory. Second: inject Provider<PrototypeBean> from javax.inject and call get(). This is the JSR-330 standard equivalent. Third: use @Lookup on an abstract method in the singleton. Spring overrides the method at runtime using CGLIB to return a fresh prototype instance on each call. This is the cleanest approach — the singleton declares an abstract method like 'protected abstract PrototypeBean createInstance()' and calls it wherever it needs a fresh instance.
  • QWhat happens when you have two beans of the same type and try to @Autowire them? How do @Primary and @Qualifier solve this?Mid-levelReveal
    Spring throws NoUniqueBeanDefinitionException at startup because it cannot determine which bean to inject when multiple candidates exist for the same type. @Primary marks one bean as the preferred default. When Spring needs to inject a bean of that type and no @Qualifier is specified, it chooses the @Primary bean. This handles the common case where one implementation is used 95% of the time. @Qualifier("beanName") on the injection point explicitly selects a specific bean by name, overriding @Primary. You can also define a custom qualifier annotation by creating an annotation annotated with @Qualifier — this is cleaner than string-based bean names and caught by the compiler. Best practice: use @Primary for the most common implementation and @Qualifier for edge cases that need a different implementation. Never rely on alphabetical bean name ordering as a selection mechanism — it is an implementation detail that is not guaranteed to be stable across Spring versions.
  • QHow does @SpringBootApplication combine multiple annotations? Explain its three main components.Mid-levelReveal
    @SpringBootApplication is a composed annotation that combines three annotations: @Configuration marks the class as a source of @Bean definitions, enabling Java-based Spring configuration on the main application class itself. @EnableAutoConfiguration triggers Spring Boot's auto-configuration mechanism. It scans the classpath for JARs and reads META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports files (previously spring.factories in older versions) to determine which auto-configuration classes to apply. For example, if spring-boot-starter-data-jpa is on the classpath, Spring Boot auto-configures DataSource, EntityManagerFactory, and TransactionManager without any explicit configuration. @ComponentScan tells Spring to scan the package where the annotated class lives and all sub-packages for @Component, @Service, @Repository, and @Controller annotations. This is why the main application class should live in the root package of your application — placing it in a sub-package means sibling packages are never scanned and their beans are never created.
  • QExplain @Transactional propagation. What is the difference between REQUIRED and REQUIRES_NEW? When would you use each, and what is the production risk of REQUIRES_NEW?SeniorReveal
    Propagation controls what happens to the transaction boundary when one @Transactional method calls another. REQUIRED (the default): if a transaction already exists, join it. If no transaction exists, start a new one. This is correct for most service methods that participate in a caller-defined transaction boundary. REQUIRES_NEW: always start a new transaction, suspending the caller's transaction if one exists. The new transaction runs independently — it commits or rolls back regardless of what happens to the caller's transaction. Use REQUIRES_NEW for: audit logging that must persist even when the main operation rolls back, notification sends that should commit independently, and retry-safe operations that should not be undone by the caller's failure. The production risk: REQUIRES_NEW acquires a new database connection from the pool while holding the caller's connection. If your connection pool size is 10 and each payment operation (connection 1) calls an audit method with REQUIRES_NEW (connection 2), 5 concurrent payments exhaust all 10 connections. The 6th request waits indefinitely for a connection, causing request timeouts and service degradation. Size your connection pool for maximum nesting depth, or decouple audit logging using @TransactionalEventListener(phase = AFTER_COMMIT) which publishes an event after the main transaction commits and handles the audit in a separate transaction without holding the original connection.
  • QWhat are the three conditions that silently prevent @Async from working, and what does the default executor do that makes it unacceptable for production?SeniorReveal
    Three conditions silently prevent @Async from working: First: @EnableAsync is missing from any @Configuration class. Without it, Spring does not activate async method processing. @Async annotations are present but the proxy infrastructure is never created. Methods run synchronously on the calling thread with no error and no log warning. Second: the @Async method is private. Same AOP proxy limitation as @Transactional — private methods are called directly on the target object, bypassing the proxy that would submit the task to a thread pool. Third: self-invocation. Calling an @Async method via this.method() from within the same class goes to the target object, not the proxy. The method runs synchronously. The default executor is SimpleAsyncTaskExecutor. It creates a new OS thread for every @Async invocation — unbounded thread creation. Under a traffic spike, this means thousands of threads being created simultaneously. OS thread creation has a hard limit. When that limit is hit, the JVM throws OutOfMemoryError: unable to create native thread and crashes. The fix: always define a ThreadPoolTaskExecutor with explicit corePoolSize, maxPoolSize, and queueCapacity. Choose a RejectedExecutionHandler that matches your back-pressure requirements — CallerRunsPolicy slows the caller without crashing, AbortPolicy throws an exception that the caller can handle.
  • QWhy does @Configuration with @Bean preserve singleton semantics while @Component with @Bean does not?SeniorReveal
    @Configuration classes are enhanced at startup using CGLIB — Spring generates a subclass of your @Configuration class that overrides all @Bean methods. When one @Bean method calls another @Bean method within a @Configuration class, the call goes through the CGLIB-generated override, which checks the application context first. If a bean with that name already exists, the existing instance is returned. If not, the method body executes and creates a new instance. This is how singleton semantics are preserved even when one @Bean method directly calls another. With @Component (called 'lite mode' bean definitions), no CGLIB subclass is generated. @Bean methods are plain Java methods. When one @Bean method calls another, it is a direct Java method call — a new instance is created every time the method body executes. The second call does not check the application context. The practical consequence: if a DataSourceConfig @Configuration class has a dataSource() @Bean method and a transactionManager() @Bean method that calls dataSource() internally, CGLIB ensures both get the same DataSource instance. Without @Configuration, they get two different DataSource instances — two separate connection pools, two separate transaction managers pointing at different connections. Always use @Configuration for classes with @Bean methods.

Frequently Asked Questions

What is the difference between @Component, @Service, @Repository, and @Controller?

All four are stereotype annotations that register a class as a Spring bean during component scanning. The differences: @Component is the generic stereotype — use it for utility classes that do not fit a specific layer. @Service is functionally identical to @Component at runtime but signals business logic — it exists for semantic clarity and developer communication. @Repository adds automatic exception translation — persistence exceptions are converted to Spring's DataAccessException hierarchy via a proxy wrapper. This is a runtime behavior difference, not just naming convention. @Controller marks a class as a web request handler. Spring MVC discovers @RequestMapping methods on @Controller classes. @RestController combines @Controller and @ResponseBody, serializing return values to JSON rather than resolving view names.

Why does @Transactional not work on private methods?

Spring implements @Transactional using AOP proxies. When you call a method on a Spring bean from outside the bean, the call goes through a proxy that intercepts it, starts a transaction, delegates to the real method, and commits or rolls back. Private methods are called directly on the target object — Java's access control prevents the proxy from overriding them. The @Transactional annotation is read at proxy creation time, but the proxy never intercepts private method calls, so the transaction logic is never executed. The annotation is silently ignored — no error, no warning, just a missing transaction boundary at runtime. The same limitation applies to @Async, @Cacheable, and any other advice-based annotation.

When should I use @Configuration vs @Component for bean definitions?

Always use @Configuration for classes with @Bean methods. @Configuration triggers CGLIB proxying — when one @Bean method calls another within the same class, Spring intercepts the call and returns the existing singleton bean rather than executing the method body again. This preserves singleton semantics. With @Component, @Bean methods are plain Java methods — calling one from another creates a new instance every time, breaking the singleton contract and potentially creating duplicate connection pools, transaction managers, or other resources that should exist only once.

What is the difference between @Profile and @ConditionalOnProperty?

@Profile activates beans based on the active Spring profile (spring.profiles.active). It is designed for environment-level switching — entire subsystems change between dev, staging, and prod. @ConditionalOnProperty activates beans based on a specific property value. It is designed for feature-level toggling — individual features can be enabled or disabled independently of the active environment. Use @Profile when the implementation itself changes between environments. Use @ConditionalOnProperty when a feature needs an independent on/off switch regardless of which environment is running.

How does self-invocation bypass Spring AOP proxies?

When you call a method on a Spring bean from another bean, the call goes through the proxy object, which intercepts it and applies advice — transactions, caching, security, async submission. When you call a method via this from within the same class, you are calling the target object directly. The proxy is not involved. This means @Transactional, @Cacheable, @Secured, @Async, and any other AOP-based annotation on the called method are completely ignored. The fix: extract the annotated method into a separate bean and inject that bean, or use @Lazy self-injection to get a reference to the proxy instead of the raw object.

What happens if I use EnumType.ORDINAL instead of EnumType.STRING?

EnumType.ORDINAL stores the enum's integer position in the database — PENDING becomes 0, CONFIRMED becomes 1, SHIPPED becomes 2. If you ever add a new enum value between two existing values, the ordinals of all subsequent values shift. Every database record storing the old ordinal now maps to the wrong enum constant. The corruption is silent until a business process reads the data and gets unexpected enum values. EnumType.STRING stores the enum name as a string — 'PENDING', 'CONFIRMED'. Reordering the enum, adding values anywhere, or renaming the enum constant (with a database migration) are all safe. Always use EnumType.STRING.

Why is @Async silently ignored sometimes?

@Async is silently ignored in three scenarios: First, @EnableAsync is missing from any @Configuration class — without it, Spring never activates async processing and all @Async annotations are discarded. Second, the annotated method is private — the AOP proxy cannot intercept private methods. Third, the method is called via self-invocation (this.method() from within the same class) — the call goes to the target object, bypassing the proxy. All three produce the same symptom: the method runs synchronously on the calling thread with no error and no warning. Always verify @Async is actually working by checking Thread.currentThread().getName() in the method body to confirm it is running on a thread pool thread.

What is the difference between @ControllerAdvice and @RestControllerAdvice?

@ControllerAdvice applies to all controllers but does not automatically serialize the return value to JSON. Each @ExceptionHandler method in a @ControllerAdvice class needs its own @ResponseBody annotation to return JSON, or can return a ModelAndView for HTML. @RestControllerAdvice combines @ControllerAdvice and @ResponseBody — every @ExceptionHandler method automatically serializes its return value to JSON. Use @RestControllerAdvice for REST APIs. Use @ControllerAdvice in mixed applications that serve both HTML and JSON from the same application context.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousBuilding a REST API with Spring BootNext →Spring Boot with MySQL and JPA
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged