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
Plain-English First
Imagine you are directing a massive theater production. Instead of running around giving orders to every actor personally, you place sticky notes on their script. A note on the door says 'Enter here' (@GetMapping), a note on a chair says 'This is a prop' (@Bean), and a note on an actor's forehead says 'You are the lead' (@Service). Spring Boot annotations are those sticky notes — they tell the Spring Framework exactly how to wire, manage, and run your code without you writing thousands of lines of manual setup logic.
But here is the part most introductions skip: some sticky notes only work if you place them correctly. A note that says 'Handle this transaction' (@Transactional) placed on the wrong door (a private method) is read by no one — the actor walks right past it and nothing happens. Understanding not just what the notes say but where they can actually be read is what separates developers who use Spring from developers who understand it.
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 {\n\n private final ForgeService service;\n\n // Constructor injection: preferred over @Autowired on fields.\n // Dependencies are explicit, final, and testable without Spring context.\n public ForgeController(ForgeService service) {\n this.service = service;\n }
@PostMapping("/process/{id}")
publicResponseEntity<String> executeJob(
@PathVariable("id") Long id,
@RequestBodyJobRequest request) {\n String result = service.process(id, request.getPayload());\n returnResponseEntity.ok(result);\n }
}
@ServiceclassForgeService {
// @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}")
privateString env;
privatefinalForgeRepository repository;
publicForgeService(ForgeRepository repository) {
this.repository = repository;
}
public Stringprocess(Long id, String data) {\n try {\n repository.save(id, data);\n return String.format(\"Env: %s | Job %d processed: %s\", env, id, data);\n } catch (DataAccessException ex) {\n // @Repository's exception translation means we catch DataAccessException\n // here regardless of whether the underlying store is JDBC, JPA, or MongoDB.\n // Without @Repository on ForgeRepository, this catch block gets SQLException instead.\n throw new RuntimeException(\"Failed to persist job \" + id, ex);\n }\n }\n}\n\n// @Repository adds exception translation — SQLExceptions become DataAccessExceptions.\n// Without this annotation, raw persistence exceptions leak into the service layer.\n@Repository\nclass ForgeRepository {\n public void save(Long id, String data) {\n // JDBC or JPA persistence logic here\n }\n}\n\nclass JobRequest {\n private String payload;\n public String getPayload() { return payload; }\n public void setPayload(String payload) { this.payload = payload; }\n}",
"output": "POST /api/v1/forge/process/99 { \"payload\": \"ForgeData\" }\nResponse: \"Env: production | Job99 processed: ForgeData\"\n\n// If ForgeRepository throws a JDBC exception:\n// WITHOUT @Repository: throws SQLException (leaks persistence technology details)\n// WITH @Repository: throws DataAccessException (technology-agnostic, catchable uniformly)"
}
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")
publicclassOrderController {
// @GetMapping is shorthand for @RequestMapping(method = RequestMethod.GET)// Use shortcuts — they communicate intent faster than the verbose form.
@GetMapping("/{orderId}")
publicResponseEntity<OrderDto> getOrder(
// @PathVariable: extracts {orderId} from the URI template
@PathVariableLong orderId,
// @RequestHeader: extracts a specific HTTP header — useful for correlation IDs
@RequestHeader(value = "X-Correlation-Id", required = false) String correlationId) {\n\n // In real code: return orderService.findById(orderId)\n return ResponseEntity.ok(new OrderDto(orderId, \"PENDING\"));\n }\n\n @GetMapping\n public ResponseEntity<Object> searchOrders(\n // @RequestParam: extracts query string parameters\n // /api/v1/orders?status=PENDING&page=0\n @RequestParam(value = \"status\", required = false, defaultValue = \"ALL\") String status,\n @RequestParam(value = \"page\", defaultValue = \"0\") int page) {\n\n return ResponseEntity.ok(Map.of(\"status\", status, \"page\", page));\n }\n\n // @ResponseStatus sets the default HTTP response code for this method.\n // Without it, POST endpoints return 200 OK — semantically wrong for a creation.\n @PostMapping\n @ResponseStatus(HttpStatus.CREATED) // Returns 201 Created on success\n public OrderDto createOrder(\n // @RequestBody: deserializes JSON request body into OrderDto via Jackson\n // @Valid: triggers Bean Validation on the deserialized object\n @Valid @RequestBody OrderDto dto) {\n return dto;\n }\n\n @DeleteMapping(\"/{orderId}\")\n @ResponseStatus(HttpStatus.NO_CONTENT) // 204 No Content — no response body\n public void cancelOrder(@PathVariable Long orderId) {\n // orderService.cancel(orderId)\n }\n}\n\n// Record DTO — clean, immutable, no boilerplate\nrecord OrderDto(Long id, String status) {}\n\n// --- Global Exception Handler ---\n// @RestControllerAdvice = @ControllerAdvice + @ResponseBody\n// Applies to all @Controller and @RestController classes in the application.\n// This is the right place for error handling — not try-catch in every controller.\n@RestControllerAdvice\nclass GlobalExceptionHandler {\n\n // @ExceptionHandler intercepts this exception type thrown anywhere in any controller.\n // Spring matches the most specific handler — IllegalArgumentException before RuntimeException.\n @ExceptionHandler(IllegalArgumentException.class)\n @ResponseStatus(HttpStatus.BAD_REQUEST)\n public Map<String, String> handleBadRequest(IllegalArgumentException ex) {\n return Map.of(\n \"error\", \"Bad Request\",\n \"message\", ex.getMessage()\n );\n }\n\n // Catch-all for unhandled exceptions — always have this to prevent stack traces reaching clients\n @ExceptionHandler(Exception.class)\n @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)\n public Map<String, String> handleGenericError(Exception ex) {\n // Log ex here — do not return stack trace to client\n return Map.of(\"error\", \"Internal Server Error\");\n }\n}",
"output": "GET /api/v1/orders/42\n→ 200OK { \"id\": 42, \"status\": \"PENDING\" }\n\nGET /api/v1/orders?status=PENDING&page=1\n→ 200OK { \"status\": \"PENDING\", \"page\": 1 }\n\nPOST /api/v1/orders { \"id\": null, \"status\": \"NEW\" }\n→ 201Created { \"id\": null, \"status\": \"NEW\" }\n\nDELETE /api/v1/orders/42\n→ 204NoContent\n\n// Throwing IllegalArgumentException in any controller:\n→ 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.
io/thecodeforge/annotations/data/Order.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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\"}))\npublic class Order {\n\n // IDENTITY: database auto-increments; never set this field manually.\n // Use SEQUENCE for PostgreSQL high-throughput scenarios — IDENTITY locks the row on insert.\n @Id\n @GeneratedValue(strategy = GenerationType.IDENTITY)\n private Long id;\n\n // Explicit column mapping: non-null, max length enforced at JPA layer.\n // JPA enforcement catches violations before a database round trip.\n @Column(name = \"reference_number\", nullable = false, length = 50, unique = true)\n private String referenceNumber;\n\n @Column(name = \"customer_id\", nullable = false)\n private Long customerId;\n\n // Precision and scale are critical for monetary values.\n // Without them, JPA defaults vary by database — never leave money columns to defaults.\n @Column(name = \"total_amount\", nullable = false, precision = 19, scale = 4)\n private BigDecimal totalAmount;\n\n @Enumerated(EnumType.STRING) // Store 'PENDING' not '0' — readable in DB, survives enum reordering\n @Column(name = \"status\", nullable = false)\n private OrderStatus status;\n\n @Column(name = \"created_at\", nullable = false, updatable = false)\n private LocalDateTime createdAt;\n\n // @OneToMany with mappedBy: this side does not own the relationship.\n // The OrderItem side owns it via @ManyToOne @JoinColumn.\n // Without mappedBy, JPA creates a separate join table — not what you want.\n // CascadeType.ALL: persist/remove items when order is persisted/removed.\n // orphanRemoval: delete items from DB when removed from this collection.\n @OneToMany(mappedBy = \"order\", cascade = CascadeType.ALL, orphanRemoval = true)\n private List<OrderItem> items;\n\n @PrePersist // Lifecycle callback: runs before first save, not on updates\n protected void onCreate() {\n this.createdAt = LocalDateTime.now();\n }\n\n // Constructors, getters, setters omitted for brevity\n public Long getId() { return id; }\n public String getReferenceNumber() { return referenceNumber; }\n public OrderStatus getStatus() { return status; }\n public void setStatus(OrderStatus status) { this.status = status; }\n}\n\n@Entity\n@Table(name = \"order_items\")\nclass OrderItem {\n\n @Id\n @GeneratedValue(strategy = GenerationType.IDENTITY)\n private Long id;\n\n // @ManyToOne is the owning side — this entity's table has the FK column.\n // @JoinColumn(name = \"order_id\") names the foreign key column explicitly.\n @ManyToOne(fetch = FetchType.LAZY) // LAZY: do not load Order unless accessed\n @JoinColumn(name = \"order_id\", nullable = false)\n private Order order;\n\n @Column(name = \"product_id\", nullable = false)\n private Long productId;\n\n @Column(name = \"quantity\", nullable = false)\n private int quantity;\n\n public Order getOrder() { return order; }\n public void setOrder(Order order) { this.order = order; }\n}\n\nenum OrderStatus { PENDING, CONFIRMED, SHIPPED, CANCELLED }\n\n// --- Spring Data JPA Repository ---\n@Repository\npublic interface OrderRepository extends JpaRepository<Order, Long> {\n\n // Derived query: Spring generates the SQL from the method name.\n // Readable for simple cases — use @Query when the method name becomes a sentence.\n List<Order> findByCustomerIdAndStatus(Long customerId, OrderStatus status);\n\n // @Query with JPQL: use entity names and field names, not table/column names.\n // This is more readable than a 60-character method name.\n @Query(\"SELECT o FROM Order o WHERE o.customerId = :customerId \"\n + \"AND o.totalAmount >= :minAmount \"\n + \"AND o.createdAt >= :since \"\n + \"ORDER BY o.createdAt DESC\")\n List<Order> findHighValueOrdersSince(\n @Param(\"customerId\") Long customerId,\n @Param(\"minAmount\") BigDecimal minAmount,\n @Param(\"since\") LocalDateTime since);\n\n // @Modifying + @Transactional: required for UPDATE and DELETE queries.\n // Without @Modifying, Spring throws an exception.\n // Without @Transactional, the update executes but may not commit.\n // clearAutomatically=true: clears the persistence context after execution\n // to prevent stale cached entities being returned in the same transaction.\n @Modifying(clearAutomatically = true)\n @Transactional\n @Query(\"UPDATE Order o SET o.status = :status WHERE o.id = :id\")\n int updateStatus(@Param(\"id\") Long id, @Param(\"status\") OrderStatus status);\n\n // Native SQL query: use when JPQL cannot express the query (window functions,\n // database-specific functions, complex subqueries).\n // nativeQuery=true: Spring passes the SQL directly to the database.\n @Query(value = \"SELECT * FROM orders WHERE EXTRACT(MONTH FROM created_at) = :month\",\n nativeQuery = true)\n List<Order> findOrdersForMonth(@Param(\"month\") int month);\n\n Optional<Order> findByReferenceNumber(String referenceNumber);\n}",
"output": "// findByCustomerIdAndStatus(42L, OrderStatus.PENDING) generates:\n// SELECT * FROM orders WHERE customer_id = 42 AND status = 'PENDING'\n\n// findHighValueOrdersSince(42L, BigDecimal(\"100.00\"), lastWeek) generates:\n// SELECT * FROM orders WHERE customer_id = 42\n// AND total_amount >= 100.00\n// AND created_at >= '2026-04-11' ORDER BY created_at DESC\n\n// updateStatus(42L, OrderStatus.CONFIRMED) generates:\n// UPDATE orders SET status = 'CONFIRMED' WHERE id = 42\n// Returns: 1 (rows affected)\n\n// @PrePersist fires on first save:\n// INSERT INTO orders (reference_number, customer_id, total_amount, status, created_at)\n// 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.
*/
@ServicepublicclassPaymentService {
privatefinalAuditService auditService;
publicPaymentService(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. Usethis 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 {\n // 1. Debit account\n // 2. Update order status\n // 3. Send to payment gateway\n\n // auditService.logPayment() runs in its OWN transaction (REQUIRES_NEW).\n // If processPayment() rolls back, the audit log is NOT rolled back.\n // This is intentional — you always want an audit trail, even for failures.\n auditService.logPayment(orderId, amount, \"INITIATED\");\n\n // Simulate a failure after audit has been written\n if (amount.compareTo(BigDecimal.ZERO) <= 0) {\n throw new IllegalArgumentException(\"Amount must be positive\");\n // Transaction rolls back. auditService.logPayment() does NOT roll back\n // because it ran in REQUIRES_NEW — its transaction already committed.\n }\n }\n\n /**\n * REQUIRES_NEW: always starts a fresh transaction.\n * Suspends any calling transaction.\n * Use for: audit logging, retry-safe operations, independent operations\n * that should not roll back with the caller.\n *\n * WARNING: REQUIRES_NEW acquires a new connection from the pool.\n * Nested REQUIRES_NEW calls under heavy load can exhaust the connection pool.\n */\n @Transactional(propagation = Propagation.REQUIRES_NEW)\n public void processPaymentIndependently(Long orderId, BigDecimal amount) {\n // Commits independently — caller's rollback does not affect this.\n }\n\n /**\n * READ_COMMITTED isolation: prevents dirty reads (seeing uncommitted data\n * from other transactions). Allows non-repeatable reads — the same query\n * run twice in the same transaction can return different results.\n *\n * Use READ_COMMITTED for most read operations where perfect consistency\n * is not required and throughput matters.\n */\n @Transactional(readOnly = true, isolation = Isolation.READ_COMMITTED)\n public BigDecimal getAccountBalance(Long accountId) {\n // readOnly=true: hint to JPA to skip dirty checking on entities\n // — meaningful performance improvement in read-heavy paths.\n // Does NOT prevent writes at the JDBC level — use for read-only methods.\n return BigDecimal.ZERO; // In real code: accountRepository.findBalance(accountId)\n }\n\n /**\n * NEVER: throws an exception if there IS an active transaction.\n * MANDATORY: throws an exception if there is NO active transaction.\n * SUPPORTS: joins existing transaction if present, runs non-transactionally if not.\n * NOT_SUPPORTED: always runs non-transactionally, suspends existing transaction.\n *\n * These four are rare — document why you are using them when you do.\n */\n @Transactional(propagation = Propagation.MANDATORY)\n public void mustRunInTransaction(Long orderId) {\n // Throws IllegalTransactionStateException if called without an active transaction.\n // Use when a method only makes sense inside a transaction boundary.\n }\n}\n\n@Service\nclass AuditService {\n\n /**\n * REQUIRES_NEW: this method always runs in its own independent transaction.\n * Even if the calling transaction (processPayment) rolls back,\n * this audit log entry is committed.\n */\n @Transactional(propagation = Propagation.REQUIRES_NEW)\n public void logPayment(Long orderId, BigDecimal amount, String status) {\n // Persists audit record in its own committed transaction\n }\n}",
"output": "// Scenario: processPayment() throws exception after logPayment() runs\n//\n// Timeline:\n// T1: processPayment() starts transaction TX1\n// T2: auditService.logPayment() suspends TX1, starts TX2 (REQUIRES_NEW)\n// T3: logPayment() completes, TX2 commits audit record to DB\n// T4: TX1 resumes, exception thrown\n// T5: TX1 rolls back — payment debit, order status update rolled back\n// T6: TX2 already committed — audit log entry STAYS in the database\n//\n// Result: payment failed cleanly, but the audit trail shows what was attempted.\n// This is the correct behavior for financial systems."
}
@Transactional Propagation Types Reference
Understanding the seven propagation types is essential for controlling transaction boundaries correctly. Here is a quick-reference table that explains each propagation type and the most common use case.
Propagation
Behavior
Typical Use Case
REQUIRED
Join existing transaction, or create a new one if none exists. Default.
Standard service methods that should participate in the caller's transaction.
REQUIRES_NEW
Always create a new transaction. Suspend any existing transaction.
Audit logging, notifications, or operations that must commit independently. Risk of connection pool exhaustion.
NESTED
Create a savepoint within the current transaction. Roll back to savepoint on exception.
Sub-operations that should roll back partially without aborting the parent transaction.
SUPPORTS
Join existing transaction if present, run non-transactionally if none.
Read-only methods that can be called with or without a transaction context.
NOT_SUPPORTED
Run non-transactionally, suspending any existing transaction.
Methods that should never run inside a transaction, e.g., sending a message that must not be rolled back.
NEVER
Throw an exception if an existing transaction is detected.
Methods that must never be called within a transaction (security-sensitive operations).
MANDATORY
Throw an exception if no existing transaction is present.
Methods that must always be part of a caller-defined transaction (e.g., data integrity checks).
Choosing the wrong propagation type leads to subtle data inconsistencies. For example, using REQUIRED for an audit log inside a transactional method means the audit log disappears if the main transaction rolls back — which defeats the purpose of audit logging. Always match the propagation to the isolation requirement: independent operations get REQUIRES_NEW, dependent operations stay REQUIRED, and conditional sub-workflows use NESTED when partial rollback is acceptable.
// validateOrder() called without transaction → throws exception
// sendNotification() called inside a transaction → throws exception
NESTED Requires Savepoint Support
NESTED propagation relies on JDBC savepoints, which are not supported by all databases. MySQL with InnoDB supports them. PostgreSQL supports them. H2 supports them. If your database does not support savepoints, NESTED behaves like REQUIRED — the sub-transaction does not get an independent rollback point. Always verify savepoint support in your database before using NESTED in production.
Production Insight
A team used REQUIRES_NEW on a method that processed each item in a batch. With 50 items per batch and a connection pool of 10, every batch consumed 10 connections immediately and waited for the next 40 — effectively serializing the batch. Rewriting the logic to use a single transaction with NESTED for each item eliminated the connection starvation while still allowing per-item rollback granularity. The throughput improved 5x under load.
Key Takeaway
Propagation type defines the transaction boundary when methods call each other. REQUIRED joins the caller; REQUIRES_NEW creates an independent transaction; NESTED allows partial rollback. Always match propagation to the business requirement — independent operations get REQUIRES_NEW, but be aware of connection pool limits.
@Scheduled Cron Syntax Guide
Spring's @Scheduled annotation lets you run methods on a schedule using cron expressions or fixed delays. The cron expression format is a six-field pattern: second minute hour day-of-month month day-of-week. Unlike the standard Unix cron (five fields), Spring adds a seconds field at the beginning.
The fields are
second: 0-59
minute: 0-59
hour: 0-23
day-of-month: 1-31
month: 1-12 or JAN-DEC
day-of-week: 0-7 (0 and 7 = Sunday) or SUN-SAT
Special characters
* matches every value
, lists multiple values (e.g., MON,WED,FRI)
- defines a range (e.g., 10-15)
/ increments (e.g., */5 every 5 units)
? no specific value (for day-of-month or day-of-week when you use the other)
L last day of month or last weekday of month
W nearest weekday to the given day
Common examples
"0 0 " — every hour at minute 0
"0 /5 *" — every 5 minutes
"0 0 8 MON-FRI" — 8 AM every weekday
"0 0 0 1 " — midnight on the first day of every month
"0 0 2 ? * SUN" — 2 AM every Sunday
@Scheduled also supports fixedRate (start a new execution every N milliseconds, regardless of previous completion) and fixedDelay (wait N milliseconds after one execution completes before starting the next). initialDelay lets you skip the first execution for a set time after application startup.
A critical detail: @Scheduled methods must be void and have no parameters. If you need state, inject beans into the scheduled service. Also, @Scheduled does not use the @Async thread pool by default — it runs on a single-threaded TaskScheduler (SimpleAsyncTaskScheduler by default). If you need concurrent scheduling, configure a ThreadPoolTaskScheduler.
package io.thecodeforge.annotations.scheduling;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Value;
@Component
@EnableScheduling// required — without it, all @Scheduled annotations are ignored
public class ScheduledTasks {\n\n // Every 5 minutes: \"0 */5 * * * *\"\n @Scheduled(cron = \"0 */5 * * * *\")\n public void cleanupExpiredSessions() {\n // runs on a single thread by default; use custom TaskScheduler for concurrency\n System.out.println(\"Session cleanup at \" + System.currentTimeMillis());\n }\n\n // 8 AM every weekday: \"0 0 8 * * MON-FRI\"\n @Scheduled(cron = \"0 0 8 * * MON-FRI\")\n public void generateDailyReport() {\n // generate and send report\n }\n\n // Fixed rate: start a new execution every 10 seconds (does not wait for completion)\n @Scheduled(fixedRate = 10000)\n public void checkHealth() {\n // runs every 10 seconds, even if previous invocation is still running\n }\n\n // Fixed delay: wait 5 seconds after previous execution finishes\n @Scheduled(fixedDelay = 5000, initialDelay = 1000)\n public void processQueue() {\n // runs 1 second after startup, then 5 seconds after each completion\n }\n}",
"output": "// Application startup log (with DEBUG level logging for scheduling):\n// Scheduled 'cleanupExpiredSessions' with cron '0 */5 * * * *'\n// Scheduled 'generateDailyReport' with cron '0 0 8 * * MON-FRI'\n// Scheduled 'checkHealth' with fixed rate 10000 ms\n// Scheduled 'processQueue' with fixed delay 5000 ms (initial delay 1000 ms)\n\n// Execution timeline:\n// t=0 : (application starts)\n// t=1s : processQueue() runs (initialDelay)\n// t=10s : checkHealth() runs\n// t=5min : cleanupExpiredSessions() runs\n// t=8am : generateDailyReport() runs (if weekday)"
}
@Profile for Environment-Specific Bean Creation
The best way to internalize Spring annotations is to work through realistic scenarios. Each problem targets a common production pitfall or design pattern. Try to solve them without looking at the solutions first, then check the provided fixes.
Problem 1: Multi-Environment Application Configuration You are building a payment service that must use a mock payment gateway in dev/test and a real gateway in staging/prod. Create a configuration using @Profile that provides a PaymentGateway interface with two implementations: MockPaymentGateway (for dev/test) and RealPaymentGateway (for staging/prod). Also ensure that the database connection is read from application-{profile}.yml and that the database driver is only loaded when the corresponding class is on the classpath.
Problem 2: Fix Broken @Transactional A junior developer has written the following code. The processOrder method calls updateInventory inside a transaction, but the inventory update is never rolled back when an exception occurs. Identify all problems and fix them. ``java @Service public class OrderService { @Transactional private void processOrder(Long orderId) { updateInventory(orderId); throw new RuntimeException("Simulated failure"); } public void updateInventory(Long orderId) { // inventory update logic } } ``
Problem 3: Wire Dual DataSource You need two data sources in the same Spring Boot application: one for reads and one for writes. Create a configuration that defines two DataSource beans, two JdbcTemplate beans, and a @Primary for the write data source. Use @Qualifier on repository classes to inject the correct one.
Problem 4: Scheduled Task with Error Handling Write a scheduled method that emails a report every Monday at 9 AM. If the report generation throws an exception, log it and continue, but also ensure that a failure one week does not affect the next week's run. Add a startup check that verifies the report configuration properties are present.
Problem 5: Self-Invocation Workaround A cache service uses @Cacheable("users") on a public method getUser(Long id). Another method in the same class, getUsers(List<Long> ids), calls this.getUser(id) for each ID. The caching never works. Fix the self-invocation problem without extracting the method to another class.
package io.thecodeforge.annotations.practice;
// Problem 1 Solution// application-dev.yml: spring.profiles.active: dev// application-prod.yml: spring.profiles.active: prod, cloud
@ConfigurationpublicclassPaymentConfig {
@Bean @Profile({"dev","test"})
publicPaymentGatewaymockPayment() { returnnewMockPaymentGateway(); }
@Bean @Profile({"staging","prod"})
publicPaymentGatewayrealPayment() { returnnewRealPaymentGateway(); }
@Bean @ConditionalOnClass(name = "org.postgresql.Driver")
public DataSourceprodDataSource() { // PostgreSQL }
@Bean @ConditionalOnClass(name = "org.h2.Driver")
public DataSourcedevDataSource() { // H2 }
}
// Problem 2 Solution
@ServicepublicclassOrderServiceFixed {
privatefinalInventoryService inventoryService;
publicOrderServiceFixed(InventoryService inventoryService) {
this.inventoryService = inventoryService;
}
@Transactional(rollbackFor = Exception.class)
publicvoidprocessOrder(Long orderId) {
inventoryService.updateInventory(orderId);
thrownewRuntimeException("Simulated failure");
}
}
@ServicepublicclassInventoryService {
@Transactional(propagation = Propagation.REQUIRED)
publicvoidupdateInventory(Long orderId) { /* ... */ }
}
// Problem 3 Solution
@ConfigurationpublicclassDataSourceConfig {
@Bean @Primary @ConfigurationProperties("app.datasource.write")
publicDataSourcewriteDataSource() { returnDataSourceBuilder.create().build(); }
@Bean @ConfigurationProperties("app.datasource.read")
publicDataSourcereadDataSource() { returnDataSourceBuilder.create().build(); }
@BeanpublicJdbcTemplatewriteJdbcTemplate(@Qualifier("writeDataSource") DataSource ds) {
returnnewJdbcTemplate(ds);
}
@BeanpublicJdbcTemplatereadJdbcTemplate(@Qualifier("readDataSource") DataSource ds) {
returnnewJdbcTemplate(ds);
}
}
// Problem 4 Solution
@Component @EnableSchedulingpublicclassReportScheduler {
@Value("${report.email}") privateString email;
@Value("${report.subject}") privateString subject;
@PostConstructpublicvoidcheckProperties() {
Assert.hasText(email, "report.email must be set");
}
@Scheduled(cron = "0 0 9 * * MON")
publicvoidgenerateWeeklyReport() {
try {
// generate report
} catch (Exception e) {
log.error("Report failed", e);
}
}
}
// Problem 5 Solution
@ServicepublicclassUserService {\n @Lazy @AutowiredprivateUserService self;\n @Cacheable(\"users\")\n public User getUser(Long id) { /* slow lookup */ }\n public List<User> getUsers(List<Long> ids) {\n return ids.stream().map(self::getUser).collect(Collectors.toList());\n }\n}",
"output": "// Problem 2 debugging steps:\n// 1. @Transactional on private method → move to public\n// 2. The method calls updateInventory directly (self-invocation) → extract to separate bean\n// 3. Default rollback only on RuntimeException → add rollbackFor = Exception.class\n//\n// Problem 5 key insight:\n// Using @Lazy @Autowired to inject the proxy into itself avoids self-invocation bypass.\n// This works because Spring creates the proxy first, then the lazy injection happens after.\n// Alternative: extract getUser() into a separate service bean."
}
● Production incidentPOST-MORTEMseverity: high
The Silent Transaction Failure — @Transactional on Private Methods
Symptom
Payment 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.
Assumption
The 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 cause
Spring 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.
Fix
Moved @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 ignored
The same AOP proxy limitation applies to @Async, @Cacheable, @Secured, and any other advice-based annotation on private methods
Self-invocation — calling a @Transactional method from within the same class via 'this' — also bypasses the proxy
Always 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.8 entries
Symptom · 01
NoSuchBeanDefinitionException at startup — Spring cannot find a bean
→
Fix
Check 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.
Symptom · 02
Controller returns HTML template name instead of JSON response
→
Fix
You 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.
Symptom · 03
@Transactional not rolling back on exception
→
Fix
Three 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.
Symptom · 04
Two beans of the same type — UnsatisfiedDependencyException or wrong bean injected
→
Fix
Use @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.
Symptom · 05
Bean not created in production but works in dev
→
Fix
The 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.
Symptom · 06
Self-invocation skips AOP advice — caching, security, or transactions have no effect
→
Fix
Calling 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().
Symptom · 07
@Async methods are running synchronously — no thread pool execution observed
→
Fix
@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.
Symptom · 08
@Value field is null at runtime even though the property exists in application.yml
→
Fix
Three 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).