Back to Annotations
Q3Question 3 of 3

How do Spring, JPA, and JUnit use annotations at runtime?

Key Concepts
  • Spring: @Component, @Autowired, @Transactional – discovered via classpath scanning, processed via reflection/CGLIB proxies
  • JPA: @Entity, @Table, @Column, @Id – Hibernate reads annotations to map classes to database tables
  • JUnit: @Test, @BeforeEach, @ParameterizedTest – test runner discovers methods via reflection
  • All require @Retention(RUNTIME) – annotations must survive into the running JVM
  • Spring AOP creates proxies around @Transactional methods to manage transaction begin/commit/rollback

Definition

Spring uses reflection to discover @Component beans and create proxies for @Transactional. JPA reads @Entity/@Column to map objects to database tables. JUnit discovers @Test methods via reflection.

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.

JAVA
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.

JAVA
// 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.

JAVA
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.

JAVA
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 @GetMapping which is itself annotated with @RequestMapping(method = GET). This is annotation composition.

Visual Overview

DIAGRAM
  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.

---

Share
Use ← → arrow keys to navigate