Definition
Framework annotation processing:
- Spring: Scans classpath for @Component/@Service/@Repository. Uses reflection to read @Autowired for dependency injection. Creates CGLIB proxies for @Transactional.
- JPA: Hibernate reads @Entity, @Table, @Column, @Id at startup to build the mapping between Java objects and database tables.
- JUnit: The test runner scans for methods annotated with @Test, @BeforeEach, @AfterEach and invokes them via reflection.
Annotations are labels on boxes in a warehouse. Spring's scanner walks through the warehouse reading labels to decide how to wire things together. JPA's mapper reads labels to know which box goes on which shelf (table). JUnit's runner reads "test" labels to know which boxes to open and verify.
Understanding the Concepts
Spring: @Component and @Transactional
Spring uses classpath scanning to discover classes annotated with @Component, @Service, @Repository, or @Controller. At startup, Spring's component scanner walks through the specified base packages, reads each class's annotations via reflection, and creates bean instances for annotated classes. Dependency injection happens through @Autowired, where Spring inspects constructor parameters, field types, or setter methods and wires in the matching beans.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service // Spring discovers this class via component scanning
public class TransferService {
private final AccountRepository repo;
@Autowired // Spring injects the dependency via reflection
public TransferService(AccountRepository repo) {
this.repo = repo;
}
@Transactional // Spring wraps this method in a transaction proxy
public void transfer(String fromId, String toId, BigDecimal amount) {
Account from = repo.findById(fromId).orElseThrow();
Account to = repo.findById(toId).orElseThrow();
from.debit(amount);
to.credit(amount);
// If an exception is thrown, Spring rolls back the transaction
}
}
Spring creates a CGLIB proxy around TransferService. When transfer() is called, the proxy intercepts the call, opens a database transaction, delegates to the real method, and then commits on success or rolls back on a runtime exception. The real TransferService object never knows about the proxy. This is Aspect-Oriented Programming in action.
How does @Transactional actually work under the hood?
Spring's transaction management relies on CGLIB bytecode generation. At bean creation time, Spring generates a subclass of TransferService that overrides each @Transactional method. The override starts a transaction, calls the original method via super, and handles commit or rollback. One critical gotcha: if transfer() calls another @Transactional method on the same object using this.otherMethod(), the proxy is bypassed and the second method runs without its own transaction. This is because this refers to the real object, not the proxy.
// GOTCHA: self-invocation bypasses the proxy
@Transactional
public void batchTransfer(List<TransferRequest> requests) {
for (TransferRequest req : requests) {
this.transfer(req.getFrom(), req.getTo(), req.getAmount());
// This calls the REAL method, not the proxy. No new transaction!
}
}
JPA: @Entity and @Column
Hibernate reads JPA annotations at application startup to build a metadata model that maps Java classes to database tables. Each @Entity becomes a table, each @Column becomes a column, and @Id marks the primary key.
package com.bank.core;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "accounts")
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "account_id", unique = true, nullable = false)
private String accountId;
@Column(precision = 19, scale = 2)
private BigDecimal balance;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private AccountStatus status;
}
Hibernate reads these annotations at startup and generates SQL like SELECT account_id, balance, status FROM accounts WHERE id = ?. The annotations replace the old XML mapping files that Hibernate originally required.
JUnit: @Test and @ParameterizedTest
JUnit 5's test runner uses reflection to discover methods annotated with @Test, @ParameterizedTest, @RepeatedTest, and lifecycle annotations like @BeforeEach and @AfterEach. The runner creates a new test instance per method (by default), invokes lifecycle methods in order, then invokes the test method.
package com.bank.service;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
class TransferServiceTest {
@BeforeEach
void setUp() {
// JUnit calls this before each @Test method via reflection
}
@Test
void shouldTransferBetweenAccounts() {
// JUnit finds this method via reflection because of @Test
assertNotNull(new TransferService(new InMemoryAccountRepo()));
}
@ParameterizedTest
@ValueSource(ints = {100, 500, 1000})
void shouldChargeCorrectFee(int amount) {
// JUnit runs this 3 times with different values
assertTrue(amount > 0);
}
}
Why do they all need RUNTIME retention?
All framework annotations have @Retention(RetentionPolicy.RUNTIME). Without RUNTIME retention, the annotation is stripped before the class file is loaded into the JVM, and reflection calls like method.isAnnotationPresent() return false. This would break classpath scanning, ORM mapping, and test discovery.
What are common mistakes with framework annotations?
- Calling @Transactional methods on
this. The proxy is bypassed and no transaction is managed. - Using @Entity on a class without a no-argument constructor. JPA requires a no-arg constructor (can be protected) for reflection-based instantiation.
- Putting @Test on a private method. JUnit cannot invoke private methods via reflection in the default access mode.
What should a senior engineer know?
- Spring Boot auto-configuration uses @Conditional annotations to decide which beans to create based on classpath contents, properties, and existing beans.
- GraalVM native-image needs reflection metadata for annotations because ahead-of-time compilation cannot discover them dynamically. Spring Native and Spring Boot 3 generate this metadata automatically.
- Annotation inheritance: @Inherited on a type annotation means subclasses inherit it. Spring's @Transactional is @Inherited by default, so a subclass of a @Transactional class is also transactional.
- Composed annotations: Spring allows you to create meta-annotations like
@GetMappingwhich is itself annotated with@RequestMapping(method = GET). This is annotation composition.
Visual Overview
Framework annotation processing:
Spring:
@Component/@Service → classpath scanning → bean creation (reflection)
@Autowired → dependency injection (reflection)
@Transactional → CGLIB proxy → transaction management
JPA:
@Entity/@Table/@Column → metadata read at startup
→ SQL generation: SELECT, INSERT, UPDATE mapped to annotations
JUnit:
@Test/@BeforeEach → test runner discovers via reflection
→ invoke method, report pass/fail
All require: @Retention(RUNTIME)
Key Takeaway
Spring, JPA, and JUnit all use annotations with RUNTIME retention, processed via reflection. Spring scans for @Component to create beans, reads @Autowired for injection, and wraps @Transactional methods in proxy-managed transactions. JPA reads @Entity and @Column to map objects to database tables. JUnit discovers @Test methods to build the test suite.
One sentence to remember: framework annotations need RUNTIME retention so reflection can discover and process them – Spring for DI/AOP, JPA for ORM, JUnit for test discovery.
---