Spring Boot @Transactional - Private Method Costs $12k
$12,000 loss from partial commits: @Transactional on private method silently ignored by Spring AOP.
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
- ✓Basic programming fundamentals
- ✓A computer with internet access
- ✓Willingness to follow along with examples
- 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
- Proxy interception adds ~1-2ms overhead per method call; the real cost is the hidden failure when the proxy is bypassed entirely
- The biggest mistake: treating all stereotypes as interchangeable — @Repository adds exception translation that @Component silently does not
Spring Boot annotations are compile-time or runtime metadata markers that trigger framework behavior, but they are not magic—they rely on Spring's proxy-based AOP (Aspect-Oriented Programming) to intercept method calls and apply cross-cutting concerns like transactions, caching, or security. When you annotate a method with @Transactional, Spring wraps your bean in a proxy that opens a database transaction before the method executes and commits or rolls back after it returns.
The critical gotcha: this proxy interception only works for external calls through the bean reference, not for internal method calls within the same class. A private method annotated with @Transactional will silently ignore the annotation because Spring cannot proxy a private method, and even if it could, calling it from another method in the same class bypasses the proxy entirely.
This is why a seemingly innocent @Transactional on a private method can lead to a $12k production bill—your transaction never actually starts, leaving partial writes, inconsistent data, and hours of debugging. In the ecosystem, alternatives like AspectJ compile-time weaving can intercept private methods, but Spring Boot defaults to proxy-based AOP for simplicity.
Use @Transactional only on public methods called from outside the class, or inject the proxy into itself via AopContext.currentProxy() if you must call transactional logic internally. For propagation types like REQUIRES_NEW or NESTED, understand that each creates a separate transaction context—misusing them with private methods is a recipe for silent data corruption.
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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Annotations replace XML configuration by marking classes and methods with metadata that Spring's IoC container reads at startup. Misplacement of these annotations—especially on private methods—causes silent failures that don't throw errors. This guide covers the four annotation categories and the production failures that result from common mistakes.
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 edge cases—they cause real incidents that have cost teams hours and, in one case, $12,000 in financial data corruption.
Each section explains why the annotation works the way it does, how to use it correctly, and the debugging patterns that turn a mystery into a five-minute fix.
Why Spring Boot Annotations Are Not Magic
Spring Boot annotations are declarative metadata that trigger framework behavior at runtime via bytecode weaving or proxy-based AOP. The core mechanic: annotations like @Transactional, @Cacheable, or @Async cause Spring to wrap your bean in a dynamic proxy that intercepts method calls and applies cross-cutting concerns. This is not compile-time code generation — it's runtime interception.
Key property: proxy-based interception only works for external calls. When a method inside the same class calls another annotated method, the call bypasses the proxy entirely. This is because the proxy wraps the bean, but internal calls use 'this' — the raw object, not the proxy. The result: @Transactional on a private method is silently ignored because private methods can't be proxied by CGLIB or JDK dynamic proxies.
Use annotations when you need consistent, declarative behavior across your service layer — transaction boundaries, caching, retry logic. But never rely on them for internal method calls or private methods. The cost: a $12k production incident where a private @Transactional method silently failed to start a transaction, leading to partial database writes and corrupted data.
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; } }
- 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"); } }
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); }
@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 } }
@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.
package io.thecodeforge.annotations.data; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service public class OrderService { @Transactional public void placeOrder(Order order) { // REQUIRED (default) — joins any calling transaction orderRepository.save(order); inventoryService.deduct(order); // same transaction auditService.logOrder(order); // REQUIRES_NEW — separate transaction } @Transactional(propagation = Propagation.MANDATORY) public void validateOrder(Order order) { // throws IllegalTransactionStateException if no active transaction } @Transactional(propagation = Propagation.NEVER) public void sendNotification(Order order) { // throws IllegalTransactionStateException if called within a transaction } }
@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.
- 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
*matches every value,lists multiple values (e.g.,MON,WED,FRI)-defines a range (e.g.,10-15)/increments (e.g.,*/5every 5 units)?no specific value (for day-of-month or day-of-week when you use the other)Llast day of month or last weekday of monthWnearest weekday to the given day
"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 { // Every 5 minutes: "0 */5 * * * *" @Scheduled(cron = "0 */5 * * * *") public void cleanupExpiredSessions() { // runs on a single thread by default; use custom TaskScheduler for concurrency System.out.println("Session cleanup at " + System.currentTimeMillis()); } // 8 AM every weekday: "0 0 8 * * MON-FRI" @Scheduled(cron = "0 0 8 * * MON-FRI") public void generateDailyReport() { // generate and send report } // Fixed rate: start a new execution every 10 seconds (does not wait for completion) @Scheduled(fixedRate = 10000) public void checkHealth() { // runs every 10 seconds, even if previous invocation is still running } // Fixed delay: wait 5 seconds after previous execution finishes @Scheduled(fixedDelay = 5000, initialDelay = 1000) public void processQueue() { // runs 1 second after startup, then 5 seconds after each completion } }
0 for seconds) causes the method to never execute.? in day-of-week matched every day, which was correct.fixedDelay for non-overlapping executions and fixedRate for concurrent executions with a fixed start interval.@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 @Configuration public class PaymentConfig { @Bean @Profile({"dev","test"}) public PaymentGateway mockPayment() { return new MockPaymentGateway(); } @Bean @Profile({"staging","prod"}) public PaymentGateway realPayment() { return new RealPaymentGateway(); } @Bean @ConditionalOnClass(name = "org.postgresql.Driver") public DataSource prodDataSource() { // PostgreSQL } @Bean @ConditionalOnClass(name = "org.h2.Driver") public DataSource devDataSource() { // H2 } } // Problem 2 Solution @Service public class OrderServiceFixed { private final InventoryService inventoryService; public OrderServiceFixed(InventoryService inventoryService) { this.inventoryService = inventoryService; } @Transactional(rollbackFor = Exception.class) public void processOrder(Long orderId) { inventoryService.updateInventory(orderId); throw new RuntimeException("Simulated failure"); } } @Service public class InventoryService { @Transactional(propagation = Propagation.REQUIRED) public void updateInventory(Long orderId) { /* ... */ } } // Problem 3 Solution @Configuration public class DataSourceConfig { @Bean @Primary @ConfigurationProperties("app.datasource.write") public DataSource writeDataSource() { return DataSourceBuilder.create().build(); } @Bean @ConfigurationProperties("app.datasource.read") public DataSource readDataSource() { return DataSourceBuilder.create().build(); } @Bean public JdbcTemplate writeJdbcTemplate(@Qualifier("writeDataSource") DataSource ds) { return new JdbcTemplate(ds); } @Bean public JdbcTemplate readJdbcTemplate(@Qualifier("readDataSource") DataSource ds) { return new JdbcTemplate(ds); } } // Problem 4 Solution @Component @EnableScheduling public class ReportScheduler { @Value("${report.email}") private String email; @Value("${report.subject}") private String subject; @PostConstruct public void checkProperties() { Assert.hasText(email, "report.email must be set"); } @Scheduled(cron = "0 0 9 * * MON") public void generateWeeklyReport() { try { // generate report } catch (Exception e) { log.error("Report failed", e); } } } // Problem 5 Solution @Service public class UserService { @Lazy @Autowired private UserService self; @Cacheable("users") public User getUser(Long id) { /* slow lookup */ } public List<User> getUsers(List<Long> ids) { return ids.stream().map(self::getUser).collect(Collectors.toList()); } }
@SpringBootApplication: The Illusion of One-Click Magic
That single annotation on your main class is a convenience wrapper, not a toy. It composes three annotations that control the entire startup sequence: @SpringBootConfiguration, @EnableAutoConfiguration, and @ComponentScan. Stack traces from misconfiguration almost always trace back to one of these three, but nobody reads the combination.
@SpringBootConfiguration is just @Configuration with a metadata flag. It registers your class as a configuration source so Spring knows where to look for @Bean definitions. Remove it and your beans vanish. @EnableAutoConfiguration is the dangerous one — it scans your classpath for jars, applies 200+ auto-configuration classes via spring.factories, and decides what beans to wire. ConditionalOnClass checks on every dependency. If you're pulling in spring-boot-starter-data-jpa, you get a DataSource, EntityManager, and transaction manager whether you asked for them or not. That's why unused starters leak into production.
ComponentScan defaults to your package and subpackages. If you place your main class outside the root, scanning silently fails. No logs, no warnings. Your controllers, services, and repositories simply never exist as beans. All of these annotations sit in the same class because Spring Boot assumes you'll stay within one package root. Violate that assumption and you're debugging empty contexts at 2 AM.
// io.thecodeforge — java tutorial // Never trust the default scan path package com.acme.inventory; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.ConfigurableApplicationContext; @SpringBootApplication // equals: @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan("com.acme.inventory") public class MainApp { public static void main(String[] args) { ConfigurableApplicationContext ctx = SpringApplication.run(MainApp.class, args); // Print all beans to verify component scanning String[] beans = ctx.getBeanDefinitionNames(); System.out.println("Beans created: " + beans.length); for (String bean : beans) { if (bean.contains("Inventory")) { System.out.println(" Found: " + bean); } } } }
The @Component Trifecta: When Stereotype Exists Only In Documentation
Newcomers agonise over @Service vs @Repository vs @Component. The framework doesn't care. All three register a class as a Spring bean. The behavioural difference? None at startup. The same scanning mechanics apply. The same proxy wiring. The same default singleton scope.
@Repository gets one hidden bonus: Spring automatically wraps exceptions from your data access code into DataAccessException. That translation only kicks in via the PersistenceExceptionTranslationPostProcessor. @Service does nothing special. @Component does nothing special. They exist for human readability — layer identification, nothing more.
But here's where the fallacy bites you: if you rely on @Repository's exception translation, you must define the post-processor bean or have JPA/Hibernate on the classpath. Otherwise, raw SQLException or HibernateException bubble straight up. No translation. No abstraction. Your service layer catches runtime exceptions that don't exist yet.
The real value of stereotyping is architectural enforcement. A well-structured codebase uses @Service for business logic, @Repository for data access, @Controller for HTTP handlers. Tools like ArchUnit enforce these rules at compile time. Without that, it's just decoration.
Use @Component for generic beans — third-party adapters, utility wrappers, infrastructure. Use @Service and @Repository to signal intent, not to trigger framework magic.
// io.thecodeforge — java tutorial // Spring treats @Service and @Repository identically for bean creation package com.acme.inventory.service; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Repository; import org.springframework.stereotype.Service; @Service public class InventoryService { @Autowired private InventoryRepository repo; public void auditStock() { repo.findAll().forEach(System.out::println); } } @Repository class InventoryRepository { // No actual database here — just a bean for the example public java.util.List<String> findAll() { return java.util.List.of("SKU-001", "SKU-002"); } } // Output confirms both beans exist with the same lifecycle
@ConditionalOnBean and @ConditionalOnMissingBean: Bean-Driven Auto-Configuration
Spring Boot auto-configuration uses conditional annotations to decide which beans to create based on the application context. @ConditionalOnBean creates a bean only if another specified bean already exists. @ConditionalOnMissingBean does the opposite — it creates a bean when no competing bean is present. This pattern powers almost all Spring Boot starters. When you add spring-boot-starter-web, the framework checks if a DispatcherServlet bean exists. If not, it creates one with safe defaults. The real risk is silent overrides. A library might create a bean you didn't expect, causing your @ConditionalOnMissingBean configuration to skip entirely. Always test with a minimal context to verify which beans actually register. The rule: prefer @ConditionalOnMissingBean for your custom beans to allow easy overrides by consumers.
// io.thecodeforge — java tutorial @Configuration public class DatabaseConfig { @Bean @ConditionalOnMissingBean(DataSource.class) public DataSource defaultDataSource() { return new HikariDataSource(); } @Bean @ConditionalOnBean(DataSource.class) public JdbcTemplate jdbcTemplate(DataSource ds) { return new JdbcTemplate(ds); } }
@ConditionalOnProperty: Environment-Driven Bean Selection
The @ConditionalOnProperty annotation gates bean creation based on a Spring Environment property. It checks if a given property exists and optionally matches a specific value. This is how Spring Boot toggles features like metrics, security, or caching without code changes. The annotation has three critical attributes: name (the property key), havingValue (expected value, defaults to true), and matchIfMissing (whether the bean should register when the property is absent). The most common mistake is forgetting matchIfMissing defaults to false — meaning your bean disappears if the property isn't defined anywhere. Another trap: using relaxed binding (e.g., camelCase vs kebab-case) inconsistently between the annotation and application.properties. The annotation uses strict key matching, not the relaxed rules that @Value uses. Specify the exact property key as it appears in your configuration file.
// io.thecodeforge — java tutorial @Configuration public class FeatureToggleConfig { @Bean @ConditionalOnProperty( name = "app.cache.enabled", havingValue = "true", matchIfMissing = false ) public CacheManager redisCacheManager() { return new RedisCacheManager(); } @Bean @ConditionalOnProperty( name = "app.cache.enabled", havingValue = "false", matchIfMissing = true ) public CacheManager noOpCacheManager() { return new NoOpCacheManager(); } }
Overview
Spring Boot’s conditional annotations form a powerful autoconfiguration system. Rather than manually wiring beans based on runtime environments, annotations like @ConditionalOnClass, @ConditionalOnProperty, or @ConditionalOnBean let beans register themselves only when specific conditions are met. This enables clean separation between configuration logic and application code. The framework evaluates each condition during context startup, allowing optional features, mock replacements, or environment-specific tweaks without modifying a single line of application code. The key insight: conditions invert control—Spring decides, at runtime, which beans to instantiate. Avoid burying conditions inside business logic; treat them as infrastructure decisions. For example, a DataSource bean might only appear if an H2 driver is on the classpath, while a caching layer might activate only when Redis properties exist. Conditions promote modularity: each jar can bring its own autoconfiguration, and Spring Boot’s starter pattern relies on this heavily. Understanding conditions means understanding how Spring Boot actually assembles your application’s graph.
// io.thecodeforge — java tutorial @SpringBootApplication public class ExApplication { public static void main(String[] args) { SpringApplication.run(ExApplication.class, args); } } // Spring Boot evaluates all @Conditional annotations // during refresh, before any beans are returned.
4.4 @ConditionalOnResource, 4.5 @ConditionalOnWebApplication & @ConditionalOnNotWebApplication, 4.6 @ConditionalExpression
These lesser-used but surgical annotations fine-tune autoconfiguration. @ConditionalOnResource checks if a specific classpath resource exists—ideal when you need a configuration file (like "db/schema.sql") to be present before wiring a bean. @ConditionalOnWebApplication and @ConditionalOnNotWebApplication match based on the application type (servlet, reactive, or non-web). For example, a custom error handler bean should only appear in a servlet web context. @ConditionalExpression, part of Spring Boot’s deprecated but still functional spel support, evaluates an arbitrary SpEL expression like '"${feature.enabled:false}" == "true"' against the environment. Critical insight: @ConditionalExpression can reference any bean or property, making it the most flexible (and fragile) option. Prefer @ConditionalOnProperty or @ConditionalOnBean when possible. Use these when you need precise control beyond what simple property presence or class checks offer. They save you from writing manual ApplicationContextInitializer logic.
// io.thecodeforge — java tutorial @Configuration public class ConditionalExamples { @Bean @ConditionalOnResource(resources = "classpath:db/schema.sql") DataSource customDataSource() { return DataSourceBuilder.create().build(); } @Bean @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) ErrorAttributes servletErrorAttrs() { return new DefaultErrorAttributes(); } @Bean @ConditionalOnExpression("'${custom.mode}' == 'advanced'") AdvancedService advancedService() { return new AdvancedService(); } }
Conclusion
Spring Boot’s conditional annotations shift configuration responsibility from manual boilerplate to runtime inspection. By understanding @ConditionalOnResource, web-type conditionals, and SpEL-based expressions, you gain fine-grained control over which beans Spring initializes—and when. The real power emerges when these annotations compose: a bean can require both a property and a resource, making wiring deeply contextual. However, restraint is essential. Each condition adds a decision point that can result in mysteriously missing beans. Favor declarative, property-driven conditions over complex SpEL. Log condition evaluation details during development, and always test with the exact environment your production artifact will run in. The final takeaway: autoconfiguration is not magic. It is deliberate, inspectable, and fully under your control when you know these annotations. Start with the simplest condition—a property or a class check—and escalate to advanced forms only when necessary. Your future self (and your team) will thank you.
The Silent Transaction Failure — @Transactional on Private Methods
- 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
this.method()) which bypasses the proxy entirely.Use AopUtils.isAopProxy(bean) in a test to verify proxy is createdEnable trace logging: logging.level.org.springframework.transaction=TRACECheck thread name in logs: if it's the same as the calling thread, async is not workingSet logging.level.org.springframework.aop.interceptor.AsyncExecutionAspectSupport=DEBUGSet logging.level.org.springframework.beans.factory=DEBUG to see bean definitionsUse `context.getBeanDefinitionNames()` in a breakpoint to list all beansInject the bean into itself using @Lazy @AutowiredVerify the proxy is CGLIB (check for EnhancerBySpringCGLIB in class name)Use constructor injection for the property: @Bean public MyBean myBean(@Value("${prop}") String prop)Verify the property key is correct and the property source is loaded| File | Command / Code | Purpose |
|---|---|---|
| io | /** | Stereotype Annotations |
| io | /** | Web and REST Annotations |
| io | /** | Data and JPA Annotations |
| io | /** | @Transactional Deep Dive |
| io | @Service | @Transactional Propagation Types Reference |
| io | @Component | @Scheduled Cron Syntax Guide |
| io | @Configuration | @Profile for Environment-Specific Bean Creation |
| MainApp.java | @SpringBootApplication // equals: @SpringBootConfiguration + @EnableAutoConfigu... | @SpringBootApplication |
| StereotypeShowdown.java | @Service | The @Component Trifecta |
| ConditionalOnBeanExample.java | @Configuration | @ConditionalOnBean and @ConditionalOnMissingBean |
| ConditionalOnPropertyExample.java | @Configuration | @ConditionalOnProperty |
| ExApplication.java | @SpringBootApplication | Overview |
| ConditionalExamples.java | @Configuration | 4.4 @ConditionalOnResource, 4.5 @ConditionalOnWebApplication |
Key takeaways
this.method()) bypasses AOP proxies; use @Lazy self-injection or extract the method to a separate bean.Common mistakes to avoid
5 patternsUsing @Transactional on private methods
Field injection instead of constructor injection
Not adding @EnableAsync or @EnableScheduling
Forgetting rollbackFor = Exception.class
Using EnumType.ORDINAL in @Enumerated
Interview Questions on This Topic
What is the difference between @Component, @Service, and @Repository?
Why does @Transactional not roll back on checked exceptions by default?
rollbackFor = Exception.class to override.How does self-invocation break AOP proxy-based annotations like @Transactional or @Cacheable?
this.method(), the call goes directly to the target object, not through the Spring AOP proxy. Since the proxy is where the annotation advice (transaction start, cache check) is applied, that advice is bypassed. The annotation appears to be silently ignored. Solutions: (1) extract the annotated method into a separate bean that gets injected, or (2) use @Lazy self-injection to call the method on the proxy itself.What is the difference between @Controller and @RestController?
How does Spring resolve property placeholders in @Value?
${...} placeholders from property sources: application.properties, application.yml, environment variables, command-line arguments, etc. The value is injected during bean creation. If the property is not found and no default is specified (e.g., ${prop:default}), Spring throws an IllegalArgumentException at startup. @Value works in constructor/method parameters and fields of Spring-managed beans.Frequently Asked Questions
Spring AOP uses JDK dynamic proxies or CGLIB proxies to intercept method calls. Proxies can only override public methods (and sometimes protected) due to Java's access control. Private methods are not visible to the proxy, so the annotation is parsed at startup but never applied at runtime. The method runs directly on the target object without any transaction boundary. The fix is to make the method public or move the annotation to a public caller method.
Yes, Spring can inject into private fields via reflection, bypassing access modifiers. However, this is considered poor practice because it prevents the field from being final, hides the dependency from the class's constructor signature, and makes unit testing difficult (you need Spring context or reflection to set the field). Constructor injection is preferred.
First, ensure @EnableScheduling is present on a @Configuration class. Check the startup logs for lines like 'Scheduled task' with the cron expression. Set logging level: logging.level.org.springframework.scheduling=TRACE to see scheduling decisions. Verify the bean containing the @Scheduled method is actually created (add a @PostConstruct log). Also ensure the method is public and void with no parameters.
fixedRate schedules the next execution at a fixed interval from the start of the current execution, regardless of how long it takes. If the execution takes longer than the rate, multiple executions may overlap. fixedDelay schedules the next execution after the current execution completes, plus the specified delay. Use fixedRate for tasks that should run at a consistent cadence (e.g., health checks), and fixedDelay for tasks that should never overlap (e.g., queue processing).
Use @Transactional(rollbackFor = Exception.class) or a more specific checked exception class. You can also use rollbackForClassName. Alternatively, wrap the checked exception in a RuntimeException before throwing it, but that is less clean.
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
That's Spring Boot. Mark it forged?
12 min read · try the examples if you haven't