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
✦ Definition~90s read
What is Spring Boot Annotations?
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.
★
Imagine you are directing a massive theater production.
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.
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.
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.
Private + @Transactional = No Transaction
A private method with @Transactional will never start a transaction — Spring's proxy can't intercept it. The annotation is silently ignored.
Production Insight
A team used @Transactional on a private helper method called from a public service method. A batch update wrote 50% of records before a constraint violation — no rollback occurred, corrupting the database.
Symptom: partial writes with no error logged, data integrity violated silently.
Rule: never put @Transactional on private methods; extract into a separate @Service bean if you need transaction demarcation.
Key Takeaway
Annotations are proxy-based, not compile-time — internal calls bypass them.
Private methods with @Transactional are silently ignored — no transaction, no warning.
Extract cross-cutting concerns into separate beans to ensure proxy interception works.
thecodeforge.io
Spring Boot @Transactional - Private Method Costs $12k
Spring Boot Annotations
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")
publicclassForgeController {
privatefinalForgeService service;
// Constructor injection: preferred over @Autowired on fields.// Dependencies are explicit, final, and testable without Spring context.publicForgeController(ForgeService service) {
this.service = service;
}
@PostMapping("/process/{id}")
publicResponseEntity<String> executeJob(
@PathVariable("id") Long id,
@RequestBodyJobRequest request) {
String result = service.process(id, request.getPayload());
returnResponseEntity.ok(result);
}
}
@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;
}
publicStringprocess(Long id, String data) {
try {
repository.save(id, data);
returnString.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.thrownewRuntimeException("Failed to persist job " + id, ex);
}
}
}
// @Repository adds exception translation — SQLExceptions become DataAccessExceptions.// Without this annotation, raw persistence exceptions leak into the service layer.
@RepositoryclassForgeRepository {
publicvoidsave(Long id, String data) {
// JDBC or JPA persistence logic here
}
}
classJobRequest {
privateString payload;
publicStringgetPayload() { return payload; }
publicvoidsetPayload(String payload) { this.payload = payload; }
}
Output
POST /api/v1/forge/process/99 { "payload": "ForgeData" }
Response: "Env: production | Job 99 processed: ForgeData"
// If ForgeRepository throws a JDBC exception:
// WITHOUT @Repository: throws SQLException (leaks persistence technology details)
// WITH @Repository: throws DataAccessException (technology-agnostic, catchable uniformly)
Component Scanning is a Directory Walk
Spring scans from the base package (where @SpringBootApplication lives) downward through all sub-packages
Every class with @Component, @Service, @Repository, or @Controller gets registered as a bean
If a class is in a package outside the scan path, Spring never sees it — no bean, no injection, no compile-time error
Expand the scan path with @ComponentScan("io.thecodeforge.external") or move the main class to the root package
@Repository adds DataAccessException translation — this is not cosmetic, it is a runtime proxy wrapping your persistence bean
@Service and @Component are functionally equivalent at runtime — the difference is semantic clarity and developer communication
Production Insight
A team moved a service class to a new utility package outside the component scan base during a refactor.
The application started cleanly — no compile error, no startup warning.
At runtime, the first request that needed the bean failed with NoSuchBeanDefinitionException.
Three hours of debugging for a missing package inclusion in the scan path.
The fix was one line: @ComponentScan("io.thecodeforge.utility").
Lesson: after any package restructuring, verify all beans are still registered by checking the startup log with logging.level.org.springframework=DEBUG.
Key Takeaway
Stereotype annotations are not interchangeable — @Repository adds DataAccessException translation that @Component silently does not provide.
Component scanning is a directory walk from the base package downward — move a class outside the scan path and it disappears without a compile-time warning.
Constructor injection is non-negotiable in new code: explicit dependencies, final fields, testable without Spring context.
Web and REST Annotations: Mapping HTTP Traffic
The web annotation layer sits on top of your @RestController beans and handles the translation between HTTP requests and Java method calls. These annotations do more than just route URLs — they control deserialization, response codes, validation triggers, error handling scope, and header extraction.
@RequestMapping is the parent. The shortcut variants @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, and @PatchMapping are composed annotations that combine @RequestMapping with the method attribute already set. Use the shortcut variants in all new code — they are more readable and communicate intent at a glance.
@PathVariable extracts values from the URI template. @RequestParam extracts query string parameters. @RequestHeader extracts HTTP header values. @RequestBody deserializes the request body from JSON into a Java object using Jackson. @ResponseStatus sets the HTTP status code on a successful response — useful for returning 201 Created on POST endpoints instead of the default 200.
The one that confuses engineers most is @ControllerAdvice and @ExceptionHandler. These two together form Spring's global error handling mechanism. A class annotated with @RestControllerAdvice and methods annotated with @ExceptionHandler intercept exceptions thrown anywhere in your controller layer and let you return consistent, structured error responses instead of raw stack traces.
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) {
// In real code: return orderService.findById(orderId)returnResponseEntity.ok(newOrderDto(orderId, "PENDING"));
}
@GetMappingpublicResponseEntity<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) {
returnResponseEntity.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 successpublicOrderDtocreateOrder(
// @RequestBody: deserializes JSON request body into OrderDto via Jackson// @Valid: triggers Bean Validation on the deserialized object
@Valid @RequestBodyOrderDto dto) {
return dto;
}
@DeleteMapping("/{orderId}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 204 No Content — no response bodypublicvoidcancelOrder(@PathVariableLong 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.
@RestControllerAdviceclassGlobalExceptionHandler {
// @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)
publicMap<String, String> handleBadRequest(IllegalArgumentException ex) {
returnMap.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)
publicMap<String, String> handleGenericError(Exception ex) {
// Log ex here — do not return stack trace to clientreturnMap.of("error", "Internal Server Error");
}
}
Output
GET /api/v1/orders/42
→ 200 OK { "id": 42, "status": "PENDING" }
GET /api/v1/orders?status=PENDING&page=1
→ 200 OK { "status": "PENDING", "page": 1 }
POST /api/v1/orders { "id": null, "status": "NEW" }
→ 201 Created { "id": null, "status": "NEW" }
DELETE /api/v1/orders/42
→ 204 No Content
// Throwing IllegalArgumentException in any controller:
@ControllerAdvice applies to all controllers but does not automatically serialize the return value to JSON — you need @ResponseBody on each @ExceptionHandler method. @RestControllerAdvice combines @ControllerAdvice and @ResponseBody, which is what you almost always want for REST APIs. Use @RestControllerAdvice in all new code unless you are building a mixed web/API application where some handlers return view names.
Production Insight
A team had per-controller try-catch blocks returning error responses in different shapes across 12 controllers.
Frontend engineers had to handle four different error JSON structures depending on which endpoint was called.
Moving all exception handling to a single @RestControllerAdvice class standardized the error shape across the entire API in one afternoon.
Every controller got simpler, tests got easier, and the frontend team stopped filing tickets about inconsistent error formats.
Lesson: centralize error handling to maintain consistent API contracts.
Key Takeaway
Use @GetMapping, @PostMapping, and the other HTTP method shortcuts — they are more readable than @RequestMapping with a method attribute.
Use @ResponseStatus(HttpStatus.CREATED) on POST endpoints — returning 200 for resource creation is semantically incorrect.
Put all exception handling in a single @RestControllerAdvice class — per-controller try-catch blocks create inconsistent error responses and duplicate code.
Data and JPA Annotations: Persistence Without Boilerplate
Spring Data JPA annotations bridge the gap between your Java objects and your relational database. Understanding what each annotation does at the SQL level — not just the Java level — is what separates engineers who use JPA from engineers who understand it.
@Entity marks a class as a JPA-managed entity. Every @Entity class needs a corresponding database table. @Table(name = "orders") maps the entity to a specific table name when the class name and table name differ. @Id marks the primary key field. @GeneratedValue(strategy = GenerationType.IDENTITY) tells JPA to let the database generate the ID — this is the correct strategy for most relational databases with auto-increment columns.
@Column(name, nullable, length, unique) maps a field to a column with explicit constraints. Omitting it is fine when the field name and column name match and you have no special constraints — JPA applies default conventions. Use it explicitly when you need to enforce not-null constraints at the JPA layer, not just the database layer.
@OneToMany and @ManyToOne model relationships. The single most common JPA mistake is putting @OneToMany on a collection without understanding the SQL it generates. A naive @OneToMany without join column specification generates a separate join table. Adding @OneToMany(mappedBy = "order") on the parent side and @ManyToOne @JoinColumn(name = "order_id") on the child side generates the correct foreign key relationship.
Spring Data's @Query lets you write JPQL or native SQL when the derived method name conventions become unreadable. A method named findByCustomerEmailAndStatusAndCreatedAtAfterOrderByCreatedAtDesc is technically valid but practically unreadable. @Query makes the intent explicit.
io/thecodeforge/annotations/data/Order.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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
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"}))
publicclassOrder {
// 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)
privateLong 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)
privateString referenceNumber;
@Column(name = "customer_id", nullable = false)
privateLong 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)
privateBigDecimal totalAmount;
@Enumerated(EnumType.STRING) // Store 'PENDING' not '0' — readable in DB, survives enum reordering
@Column(name = "status", nullable = false)
privateOrderStatus status;
@Column(name = "created_at", nullable = false, updatable = false)
privateLocalDateTime 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)
privateList<OrderItem> items;
@PrePersist// Lifecycle callback: runs before first save, not on updatesprotectedvoidonCreate() {
this.createdAt = LocalDateTime.now();
}
// Constructors, getters, setters omitted for brevitypublicLonggetId() { return id; }
publicStringgetReferenceNumber() { return referenceNumber; }
publicOrderStatusgetStatus() { return status; }
publicvoidsetStatus(OrderStatus status) { this.status = status; }
}
@Entity
@Table(name = "order_items")
classOrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
privateLong 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)
privateOrder order;
@Column(name = "product_id", nullable = false)
privateLong productId;
@Column(name = "quantity", nullable = false)
privateint quantity;
publicOrdergetOrder() { return order; }
publicvoidsetOrder(Order order) { this.order = order; }
}
enumOrderStatus { PENDING, CONFIRMED, SHIPPED, CANCELLED }
// --- Spring Data JPA Repository ---
@RepositorypublicinterfaceOrderRepositoryextendsJpaRepository<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")
intupdateStatus(@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);
}
Never Use @Enumerated(EnumType.ORDINAL) in Production
EnumType.ORDINAL stores the enum's integer position (0, 1, 2, ...) in the database. If you ever reorder your enum values — even to add a new value in the middle — every existing record in the database now maps to the wrong enum constant. EnumType.STRING stores the name ('PENDING', 'CONFIRMED') which is readable in the database, survives reordering, and makes database queries understandable without enum source code. Use EnumType.STRING unconditionally. The storage overhead of a short string over an integer is negligible.
Production Insight
A team used EnumType.ORDINAL on a PaymentStatus enum.
Six months later, a product requirement added a new PROCESSING status between PENDING and CONFIRMED in the source code.
The ordinal values shifted, making every 'CONFIRMED' record in the database interpreted as 'PROCESSING'.
The payment dashboard showed all historical confirmed payments as processing.
Rolling back the enum change and running a data migration took a weekend.
Lesson: use EnumType.STRING unconditionally to avoid fragile ordinal mappings.
Key Takeaway
JPA annotations map Java objects to SQL — always think about what SQL they generate, not just what they do at the Java layer.
Use EnumType.STRING unconditionally.
Use mappedBy on the non-owning side of relationships to avoid accidental join table creation.
@Modifying requires @Transactional on the repository method, and clearAutomatically=true prevents stale entity caching after bulk updates.
@Transactional Deep Dive: Propagation, Isolation, and What Actually Happens
@Transactional is the annotation with the most misunderstood behavior in the Spring ecosystem. Most engineers know it starts a transaction — fewer know the propagation and isolation attributes that control what happens when a @Transactional method calls another @Transactional method.
Propagation controls the transaction boundary when one transactional method calls another. REQUIRED (the default) means 'join the existing transaction if one exists, start a new one if not.' REQUIRES_NEW means 'always start a new transaction, suspend the current one.' NESTED means 'create a savepoint within the current transaction — roll back to the savepoint on exception without rolling back the outer transaction.'
The production scenario where this matters: an audit logging method should always commit even when the main operation rolls back. If the audit method uses REQUIRED, a rollback on the main transaction rolls back the audit entry too. The fix: REQUIRES_NEW on the audit method gives it its own independent transaction.
Isolation controls what the transaction can see from concurrent transactions. READ_COMMITTED (the PostgreSQL and SQL Server default) prevents dirty reads but allows non-repeatable reads and phantom reads. REPEATABLE_READ prevents non-repeatable reads. SERIALIZABLE prevents phantom reads but degrades throughput.
The rollback rule is the silent killer: by default, @Transactional only rolls back on RuntimeException and Error. Checked exceptions — IOException, SQLException — do not trigger rollback. This means a method that throws a checked exception mid-operation commits the work done before the exception.
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)
publicvoidprocessPayment(Long orderId, BigDecimal amount) throwsException {
// 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 writtenif (amount.compareTo(BigDecimal.ZERO) <= 0) {
thrownewIllegalArgumentException("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.
* Usefor: 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)
publicvoidprocessPaymentIndependently(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)
publicBigDecimalgetAccountBalance(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)
publicvoidmustRunInTransaction(Long orderId) {
// Throws IllegalTransactionStateException if called without an active transaction.// Use when a method only makes sense inside a transaction boundary.
}
}
@ServiceclassAuditService {
/**
* REQUIRES_NEW: this method always runs in its own independent transaction.
* Evenif the calling transaction (processPayment) rolls back,
* this audit log entry is committed.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
publicvoidlogPayment(Long orderId, BigDecimal amount, String status) {
// Persists audit record in its own committed transaction
}
}
Output
// Scenario: processPayment() throws exception after logPayment() runs
// T3: logPayment() completes, TX2 commits audit record to DB
// T4: TX1 resumes, exception thrown
// T5: TX1 rolls back — payment debit, order status update rolled back
// T6: TX2 already committed — audit log entry STAYS in the database
//
// Result: payment failed cleanly, but the audit trail shows what was attempted.
// This is the correct behavior for financial systems.
REQUIRES_NEW Under Load Exhausts the Connection Pool
Every REQUIRES_NEW call acquires a new database connection from the pool while holding the calling transaction's connection. If processPayment() (connection 1) calls auditService.logPayment() with REQUIRES_NEW (connection 2), and your pool has 10 connections, 5 concurrent payment operations fully exhaust the pool. The 6th request waits indefinitely. This is the most common source of connection pool starvation in systems that use REQUIRES_NEW for audit logging. Size your connection pool to account for the maximum nesting depth, or use asynchronous audit logging with @TransactionalEventListener(phase = AFTER_COMMIT) to decouple the audit write from the main transaction.
Production Insight
The default rollback rule — only RuntimeException and Error — is the most quietly destructive @Transactional behavior in production.
A team had a payment method that could throw a checked IOException when the payment gateway was unreachable.
The IOException propagated up, the transaction committed the partial work (account debited, no payment record), and the exception reached the controller.
Financial records showed money leaving accounts with no corresponding payment.
The fix was one attribute: rollbackFor = Exception.class.
Lesson: every service method that can throw checked exceptions needs this unless you explicitly want checked exceptions to not trigger rollback.
Key Takeaway
REQUIRED (default) joins the existing transaction. REQUIRES_NEW starts its own — use for audit logging and independent operations, but watch connection pool exhaustion.
rollbackFor = Exception.class is often what you actually want — the default (RuntimeException only) silently commits on checked exceptions.
readOnly = true is a meaningful JPA performance hint for read paths — it skips dirty checking across all entities in the session.
@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.
Throughput improved 5x under load.
Lesson: be aware of connection pool limits when using REQUIRES_NEW in loops or batch operations.
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.
Cron Expressions Are Evaluated at Startup — Changes Require Restart
Spring's @Scheduled cron expressions are parsed once at application startup. Changing the cron text in a property file does not take effect until the application is restarted. For dynamic scheduling, use a TaskScheduler directly with ScheduledFuture, or use a library like Quartz. Also, always test cron expressions with a validator — a single typo (e.g., missing 0 for seconds) causes the method to never execute.
Production Insight
A team used @Scheduled(cron = "0 0 2 ?") for a nightly database maintenance task.
The ? in day-of-week matched every day, which was correct.
But they forgot to add @EnableScheduling to any @Configuration class.
The method never ran. The database accumulated dead rows for three months.
By the time they discovered the issue, the maintenance window required a full weekend outage.
The fix: one annotation, @EnableScheduling.
Lesson: always verify scheduled tasks are running in production by checking logs or a health endpoint that tracks last execution time.
Key Takeaway
@Scheduled requires @EnableScheduling on a @Configuration class.
Cron expressions use a six-field pattern (seconds included).
Use fixedDelay for non-overlapping executions and fixedRate for concurrent executions with a fixed start interval.
@Scheduled methods must be void and no-arg; they run on a single-threaded scheduler by default.
@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.
// 1. @Transactional on private method → move to public
// 2. The method calls updateInventory directly (self-invocation) → extract to separate bean
// 3. Default rollback only on RuntimeException → add rollbackFor = Exception.class
//
// Problem 5 key insight:
// Using @Lazy @Autowired to inject the proxy into itself avoids self-invocation bypass.
// This works because Spring creates the proxy first, then the lazy injection happens after.
// Alternative: extract getUser() into a separate service bean.
Practice Makes Production-Ready
These problems mirror real incidents. Running through them with actual Spring Boot applications cements the proxy mechanics and configuration patterns. I recommend setting up a blank Spring Boot project and implementing each solution, then writing integration tests that verify the expected behavior (e.g., asserting rollback after an exception, verifying scheduled execution with Awaitility, etc.).
Production Insight
Each of these problems has caused real production incidents.
The @Transactional private method pattern alone has led to multiple financial data corruptions.
Self-invocation bypassing caches is a common source of performance degradation that looks like a database issue.
Working through these solutions in an actual project ingrains the proxy mechanics better than any reading.
Lesson: theoretical knowledge of annotations is useless without the practice of debugging them.
Key Takeaway
Practice problems mirror real production failures.
Always test transactional rollback with integration tests.
Self-invocation bypasses proxies; use @Lazy self-injection or extract methods to separate beans.
@Profile and @ConditionalOnX annotations control which beans are created in which environment.
@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.
Every time someone moves your main class to a different package, the implicit @ComponentScan breaks. Explicitly define basePackages or basePackageClasses to make the contract visible. Don't let IntelliJ's refactoring tool silently kill your production endpoints.
Key Takeaway
@SpringBootApplication is a composite annotation — break its implicit package assumptions and you break your entire application context.
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.
StereotypeShowdown.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
26
27
28
29
// io.thecodeforge — java tutorial// Spring treats @Service and @Repository identically for bean creationpackage com.acme.inventory.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;
import org.springframework.stereotype.Service;
@ServicepublicclassInventoryService {
@AutowiredprivateInventoryRepository repo;
publicvoidauditStock() {
repo.findAll().forEach(System.out::println);
}
}
@RepositoryclassInventoryRepository {
// No actual database here — just a bean for the examplepublic java.util.List<String> findAll() {
return java.util.List.of("SKU-001", "SKU-002");
}
}
// Output confirms both beans exist with the same lifecycle
Output
InventoryRepository bean initialized
InventoryService bean injected with InventoryRepository
Audit complete: SKU-001, SKU-002
Senior Shortcut: ArchUnit Validation
Add ArchUnit to your test suite. Write rules: 'classes annotated with @Service must reside in package ..service'. When a junior tags a utility class with @Service because it compiles, the test fails at build time, not in production.
Key Takeaway
@Service, @Repository, and @Component are semantically identical to Spring's IoC container. The value is in the convention they enforce — not the magic they provide.
@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.
jdbcTemplate bean only created when DataSource exists. DataSource with @ConditionalOnMissingBean only created if no other DataSource is defined.
Production Trap:
Two libraries both using @ConditionalOnMissingBean for the same type can cause silent startup failures. Neither bean registers, and you get a NoSuchBeanDefinitionException hours later in production.
Key Takeaway
Always use @ConditionalOnMissingBean for extensible defaults; @ConditionalOnBean for dependent infrastructure.
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.
Setting app.cache.enabled=true activates Redis cache. Setting it false or omitting it with matchIfMissing=true activates no-op cache.
Production Trap:
Using @ConditionalOnProperty with matchIfMissing=true hides configuration mistakes. A typo in the property key silently defaults the bean to the missing-value behavior, not your intended setup.
Key Takeaway
Set matchIfMissing explicitly to false for features that must be deliberately enabled; use it only for safe defaults.
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.
ExApplication.javaJAVA
1
2
3
4
5
6
7
8
9
// io.thecodeforge — java tutorial
@SpringBootApplicationpublicclassExApplication {
publicstaticvoidmain(String[] args) {
SpringApplication.run(ExApplication.class, args);
}
}
// Spring Boot evaluates all @Conditional annotations// during refresh, before any beans are returned.
Output
// No direct output; context loads conditionally.
Production Trap:
Never assume a bean will exist just because you imported a library. Conditions can silently skip registration. Always test with your target profile.
Key Takeaway
Conditions let Spring decide bean presence at runtime, not at compile time.
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.
// Beans appear only when the resource exists, web type matches, or SpEL evaluates to true.
Production Trap:
Overusing @ConditionalExpression leads to invisible bean wiring. Log condition outcomes with debug=true in application.properties to trace surprises.
Key Takeaway
Conditional resource, web type, and SpEL annotations let you match on file presence, environment, or arbitrary expressions.
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.
Production Trap:
Run with --debug to see which conditions matched and which failed. Unmatched conditions often mean a missing dependency or incorrect property.
Key Takeaway
Master conditions for predictable, auditable autoconfiguration with minimal surprises.
● 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).
★ Spring Annotation Debugging Cheat SheetQuick diagnostic commands and actions for common annotation misconfigurations.
@Transactional not rolling back on exception−
Immediate action
Check if method is private or called via self-invocation
Commands
Use AopUtils.isAopProxy(bean) in a test to verify proxy is created
Move @Transactional to public method; add rollbackFor = Exception.class for checked exceptions
@Async methods run synchronously+
Immediate action
Verify @EnableAsync is present on a @Configuration class
Commands
Check thread name in logs: if it's the same as the calling thread, async is not working
Set logging.level.org.springframework.aop.interceptor.AsyncExecutionAspectSupport=DEBUG
Fix now
Add @EnableAsync to a @Configuration class; ensure method is public and not self-invoked
Bean not found (NoSuchBeanDefinitionException)+
Immediate action
Verify the class has a stereotype annotation and is within component scan
Commands
Set logging.level.org.springframework.beans.factory=DEBUG to see bean definitions
Use `context.getBeanDefinitionNames()` in a breakpoint to list all beans
Fix now
Add @ComponentScan or move the class to the base package
Self-invocation caching not working+
Immediate action
Check if method is called via `this.` inside the same class
Commands
Inject the bean into itself using @Lazy @Autowired
Verify the proxy is CGLIB (check for EnhancerBySpringCGLIB in class name)
Fix now
Use self-injection or extract the method to a separate @Service
@Value returns null in @Bean method+
Immediate action
Check if @Value is on a field of the @Bean method class, not on a parameter
Commands
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
Fix now
Inject @Value as a method parameter instead of field injection
Key takeaways
1
Never put @Transactional, @Async, @Cacheable, or @Secured on private methods
AOP proxies cannot intercept them.
2
Use constructor injection over field injection; dependencies become explicit, final, and testable without Spring context.
3
@Repository adds exception translation that @Component does not
use it for all data access beans.
4
Always write integration tests that assert rollback behavior after an exception
don't assume annotations work.
5
Self-invocation (this.method()) bypasses AOP proxies; use @Lazy self-injection or extract the method to a separate bean.
6
@EnableAsync and @EnableScheduling must be explicitly added; without them, the annotations are silently ignored.
7
Use EnumType.STRING in @Enumerated to survive enum reordering.
8
rollbackFor = Exception.class is often needed for @Transactional to roll back on checked exceptions.
Common mistakes to avoid
5 patterns
×
Using @Transactional on private methods
Symptom
Transactions do not start or roll back; partial commits occur silently.
Fix
Move @Transactional to a public method. The same applies to @Async, @Cacheable, and any AOP-based annotation.
×
Field injection instead of constructor injection
Symptom
Unit tests require Spring context; fields cannot be final; dependencies are hidden.
Fix
Use constructor injection. Dependencies become explicit, final, and testable with plain new.
×
Not adding @EnableAsync or @EnableScheduling
Symptom
@Async runs synchronously; @Scheduled methods never execute.
Fix
Add @EnableAsync and @EnableScheduling to a @Configuration class.
×
Forgetting rollbackFor = Exception.class
Symptom
Checked exceptions do not trigger rollback; partial commits persist.
Fix
Add rollbackFor = Exception.class to @Transactional unless you explicitly want checked exceptions to commit.
×
Using EnumType.ORDINAL in @Enumerated
Symptom
Reordering enum values corrupts database records.
Fix
Use EnumType.STRING unconditionally.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01JUNIOR
What is the difference between @Component, @Service, and @Repository?
Q02SENIOR
Why does @Transactional not roll back on checked exceptions by default?
Q03SENIOR
How does self-invocation break AOP proxy-based annotations like @Transac...
Q04JUNIOR
What is the difference between @Controller and @RestController?
Q05SENIOR
How does Spring resolve property placeholders in @Value?
Q01 of 05JUNIOR
What is the difference between @Component, @Service, and @Repository?
ANSWER
All three are stereotype annotations that register a class as a Spring bean. @Service is a specialization of @Component used for business logic; it adds no runtime behavior beyond @Component. @Repository is a specialization that adds exception translation: persistence exceptions (e.g., SQLException) are automatically converted to Spring's DataAccessException hierarchy. This lets the service layer catch a consistent exception type regardless of the underlying data access technology.
Q02 of 05SENIOR
Why does @Transactional not roll back on checked exceptions by default?
ANSWER
This is a design choice in Spring's transaction management. By default, @Transactional only rolls back on RuntimeException and Error. The rationale is that checked exceptions represent expected, recoverable conditions (e.g., a file not found), so the transaction should commit the work done before the exception. For many business operations, this default is incorrect — you typically want rollback on any exception. Use rollbackFor = Exception.class to override.
Q03 of 05SENIOR
How does self-invocation break AOP proxy-based annotations like @Transactional or @Cacheable?
ANSWER
When a bean method calls another method within the same class using 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.
Q04 of 05JUNIOR
What is the difference between @Controller and @RestController?
ANSWER
@Controller is a stereotype for web controllers; it returns view names (e.g., Thymeleaf templates) by default. @RestController is a composed annotation that combines @Controller and @ResponseBody, meaning every handler method automatically serializes its return value to the HTTP response body (usually JSON). Use @RestController for REST APIs to avoid adding @ResponseBody to every method.
Q05 of 05SENIOR
How does Spring resolve property placeholders in @Value?
ANSWER
Spring uses a PropertySourcesPlaceholderConfigurer (automatically registered in Spring Boot) to resolve ${...} 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.
01
What is the difference between @Component, @Service, and @Repository?
JUNIOR
02
Why does @Transactional not roll back on checked exceptions by default?
SENIOR
03
How does self-invocation break AOP proxy-based annotations like @Transactional or @Cacheable?
SENIOR
04
What is the difference between @Controller and @RestController?
JUNIOR
05
How does Spring resolve property placeholders in @Value?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
Why does @Transactional not work on private methods?
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.
Was this helpful?
02
Can I use @Autowired on a private field?
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.
Was this helpful?
03
How do I debug why a @Scheduled method is not running?
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.
Was this helpful?
04
What is the difference between fixedRate and fixedDelay in @Scheduled?
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).
Was this helpful?
05
How do I force rollback for checked exceptions in @Transactional?
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.