Spring Boot Annotations Cheat Sheet: The Definitive Guide
- 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.
- 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 Incident
Production Debug GuideWhen Spring annotations behave unexpectedly, here is how to go from observable symptom to resolution.
this.method()) which bypasses the proxy entirely.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.
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; } }
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)
- 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
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.
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"); } }
→ 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": "..." }
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.
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); }
// 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')
@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.
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 } }
//
// 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.
@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.
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 } }
// 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
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.
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 this — do 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 {}
// 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'
| Annotation | Category | Purpose and Key Behavior |
|---|---|---|
| @Component | Stereotype | Generic managed bean. Use for utility classes that do not fit a specific layer. Base annotation for all stereotypes. |
| @Service | Stereotype | Business logic layer. Functionally identical to @Component at runtime — exists for semantic clarity and developer communication. |
| @Repository | Stereotype | Data access layer. Adds automatic DataAccessException translation via PersistenceExceptionTranslationPostProcessor proxy wrapper. |
| @Controller | Web | Web request handler. Returns view names by default — use with Thymeleaf or other template engines for server-rendered HTML. |
| @RestController | Web | API entry point. Combines @Controller and @ResponseBody — every method returns data serialized to JSON, never a view name. |
| @RequestMapping | Web | Maps HTTP requests to handler methods. Use the shortcut variants @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, @PatchMapping. |
| @PathVariable | Web | Extracts a value from the URI template: @GetMapping('/{id}') + @PathVariable Long id. |
| @RequestParam | Web | Extracts a query string parameter. Supports required=false and defaultValue for optional parameters. |
| @RequestBody | Web | Deserializes the HTTP request body (JSON) into a Java object via Jackson. Combine with @Valid for Bean Validation. |
| @RequestHeader | Web | Extracts an HTTP header value. Use required=false for optional headers like correlation IDs. |
| @ResponseStatus | Web | Sets the HTTP status code for a successful response. Use @ResponseStatus(HttpStatus.CREATED) on POST endpoints. |
| @ExceptionHandler | Web | Intercepts a specific exception type thrown in a controller and maps it to a structured HTTP response. |
| @RestControllerAdvice | Web | Applies @ExceptionHandler methods globally across all controllers. The right place for consistent error response formatting. |
| @Entity | Data/JPA | Marks a class as a JPA-managed entity mapped to a database table. Requires @Id. |
| @Id | Data/JPA | Marks the primary key field of a JPA entity. |
| @GeneratedValue | Data/JPA | Specifies ID generation strategy. IDENTITY for auto-increment columns; SEQUENCE for PostgreSQL high-throughput scenarios. |
| @Column | Data/JPA | Maps a field to a column with explicit constraints: nullable, length, unique, precision, scale. |
| @OneToMany / @ManyToOne | Data/JPA | Models one-to-many relationships. Use mappedBy on the parent, @JoinColumn on the child. Without mappedBy, JPA creates a join table. |
| @Enumerated(EnumType.STRING) | Data/JPA | Stores enum as its name ('PENDING') not its ordinal (0). Always use STRING — ordinal breaks if enum order changes. |
| @Query | Data/JPA | Defines explicit JPQL or native SQL on a Spring Data repository method. Use when derived method names become unreadable. |
| @Modifying | Data/JPA | Required on @Query methods that perform UPDATE or DELETE. Must be combined with @Transactional on the repository method. |
| @Transactional | AOP/Data | Wraps the method in a database transaction. Only works on public methods called via proxy. Default rollback: RuntimeException only. |
| @Configuration | Config | Marks a class as a bean factory. CGLIB-proxied to preserve singleton semantics across @Bean method calls. |
| @Bean | Config | Defines a single bean in a @Configuration class. The method return value is registered as a Spring bean. |
| @Profile | Config | Activates a bean only when the specified Spring profile is active. Use for environment-level bean switching. |
| @ConditionalOnProperty | Config | Activates a bean based on a specific property value. Use for feature-level toggles independent of environment. |
| @ConditionalOnMissingBean | Config | Creates a bean only if no other bean of that type exists. The default/fallback pattern used by Spring Boot auto-configuration. |
| @Value | Config | Injects a property value or SpEL expression. In @Bean methods, inject as method parameter, not as a field. |
| @Async | AOP | Runs the method on a background thread pool. Requires @EnableAsync. Silently ignored on private methods and self-invocations. |
| @EnableAsync | AOP | Activates @Async processing. Required on a @Configuration class. Without it, @Async is silently ignored. |
| @SpringBootApplication | Bootstrap | Combines @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
Interview Questions on This Topic
- QWhat is the difference between @Component, @Service, and @Repository in Spring? Does the framework treat them differently?Mid-levelReveal
- QExplain how @RestController is different from @Controller. When would you still choose to use @Controller?Mid-levelReveal
- QA bean is annotated with @Scope('prototype'). How does this behave when injected into a singleton-scoped bean?SeniorReveal
- 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
- QHow does @SpringBootApplication combine multiple annotations? Explain its three main components.Mid-levelReveal
- 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
- 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
- QWhy does @Configuration with @Bean preserve singleton semantics while @Component with @Bean does not?SeniorReveal
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.
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.