17. The Spring Framework
Spring is the framework most Java applications are built on, and it exists to solve one problem: how objects find the other objects they depend on without being welded to them. This chapter starts with that problem, builds up dependency injection and the container that performs it, then covers the three things Spring layers on top of it, aspect-oriented programming, data access, and the web layer, finishing with the annotations that drive all of it.
<a id="171-inversion-of-control-and-dependency-injection"></a>
17.1 Inversion of Control and Dependency Injection
This subchapter builds the core idea from the ground up: why constructing your own collaborators causes trouble, how inverting that responsibility fixes it, and how Spring's container performs the inversion in practice.
Tight Coupling in Plain Java
Every object needs other objects to do its work. An order service needs a repository, the repository needs a data source, the data source needs a configuration. The question that determines an application's flexibility is a narrow one: who creates those collaborators?
The obvious answer is that each class creates its own, with new.
public class OrderService {
private final OrderRepository repository;
private final EmailSender emailSender;
public OrderService() {
// This class decides exactly which implementations it uses,
// and how they are configured.
this.repository = new JdbcOrderRepository(
new HikariDataSource(loadConfig()));
this.emailSender = new SmtpEmailSender("smtp.example.com", 587);
}
public void place(Order order) {
repository.save(order);
emailSender.sendConfirmation(order);
}
}
This compiles, runs, and is a serious design problem. The class has taken on three responsibilities that have nothing to do with orders.
| Responsibility | Belongs to |
|---|---|
| Placing an order | This class, correctly |
| Choosing implementations | Whoever assembles the application |
| Configuring them | Configuration, loaded once at startup |
Four concrete consequences follow, and each one costs real time.
It cannot be tested. Constructing an OrderService opens a connection pool
and requires a mail server. A test for "an order below the minimum is rejected"
now needs a database and SMTP running, which turns a one-millisecond unit test
into a fragile integration test.
@Test
void rejectsOrderBelowMinimum() {
OrderService service = new OrderService(); // opens a real connection pool
... // and needs a real mail server
}
Implementations cannot be swapped. Sending mail through a different provider
means editing OrderService, a class that has nothing to do with mail. The same
applies to every other class that hard-codes SmtpEmailSender.
Configuration is scattered. The SMTP host lives inside a service class, so finding every configuration value in the application means reading every class.
Objects are duplicated. Each service that constructs its own
HikariDataSource gets a separate connection pool. Ten services mean ten pools
and ten times the connections the database expects.
The dependency direction is what makes this rigid.
Tightly coupled Loosely coupled
--------------- ---------------
OrderService OrderService
| new | depends on
v v
JdbcOrderRepository (concrete) OrderRepository (interface)
^
| implements
JdbcOrderRepository
The service is bound to a class. The service is bound to a contract.
Programming against an interface is half the fix. The service should declare what it needs, not which implementation, and something else should supply it.
public class OrderService {
private final OrderRepository repository; // an interface
private final EmailSender emailSender; // an interface
// The caller supplies the collaborators. This class no longer
// knows or cares which implementations they are.
public OrderService(OrderRepository repository, EmailSender emailSender) {
this.repository = repository;
this.emailSender = emailSender;
}
}
The class is now testable with fakes, works with any implementation, and holds no configuration. What has changed is that somebody else must do the assembling, and that somebody is the subject of the rest of this subchapter.
Key Takeaways
- Every object needs collaborators; the design question is who creates them.
- Using
newinside a class binds it to specific implementations and their configuration.- Hard-coded dependencies make unit tests require real infrastructure.
- Swapping an implementation then means editing unrelated classes.
- Self-constructed resources such as connection pools get duplicated per class.
- Depending on interfaces and accepting collaborators as parameters removes all four problems.
- The assembly work does not disappear; it moves somewhere that owns it.
🧪 Practice
- Write a
ReportServicethat constructs its ownJdbcTemplateand a file writer, then list everything a unit test of it would require.- Refactor that class to accept both collaborators as constructor parameters, and write a test using in-memory fakes.
- Count how many places in a small project call
newon a class that represents infrastructure rather than data.- Interview: A class is described as "hard to test". What is usually true about its constructor? (Hint: what does the test have to provide before the first assertion can run?)
Inversion of Control as a Design Principle
The previous topic ended by moving object creation out of the class that uses the objects. That move has a name: Inversion of Control, usually shortened to IoC. Something that the class used to control, namely obtaining its collaborators, is now controlled by something else.
The name is broader than dependency management. Control is inverted whenever a framework calls your code rather than your code calling a library.
Library style Framework style
------------- ---------------
your code -> calls -> library framework -> calls -> your code
You own the flow. The framework owns the flow.
e.g. calling Collections.sort e.g. Spring calling your controller
This is sometimes called the Hollywood Principle: "don't call us, we'll call you". A servlet container calls your servlet; a test framework calls your test method; Spring calls your controller. In all three cases you write the pieces and something else decides when they run.
Dependency Injection (DI) is the specific form of IoC that this subchapter is about: the inverted control is specifically the control over supplying dependencies. The two terms are often used interchangeably, but the relationship is that DI is one kind of IoC.
Inversion of Control (broad principle)
|
+-- Dependency Injection supplying collaborators from outside
+-- Template Method the base class controls the algorithm
+-- Callbacks and listeners the framework decides when to invoke
+-- Service Locator the object asks a registry for what it needs
The last entry is worth contrasting, because it is the alternative that looks
similar and behaves worse. With a service locator, the object still fetches
its own dependencies, just from a registry instead of with new.
// SERVICE LOCATOR: the dependency is still hidden inside the class.
public class OrderService {
private final OrderRepository repository =
ServiceRegistry.lookup(OrderRepository.class); // hidden dependency
}
// DEPENDENCY INJECTION: the dependency is part of the public contract.
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) { // visible dependency
this.repository = repository;
}
}
The difference is honesty. With injection, the constructor signature tells you everything the class needs, and the compiler enforces it. With a locator, the only way to discover the dependencies is to read the whole class body, and a missing registration fails at runtime.
The benefit that matters most in practice is testability, and it is worth seeing concretely.
@Test
void sendsConfirmationAfterSaving() {
var repository = new InMemoryOrderRepository(); // fast fake
var emails = new RecordingEmailSender(); // records instead of sending
var service = new OrderService(repository, emails);
service.place(anOrder());
assertEquals(1, emails.sent().size()); // no database, no SMTP
}
There is a cost, and it should be stated plainly. Indirection makes the flow harder to follow: you can no longer see which implementation runs by reading the calling class, and a wiring mistake surfaces at startup rather than at compile time. For a small program this cost outweighs the benefit. For anything with a test suite and more than a handful of services, it does not.
A framework is not required. You can practise dependency injection with
nothing but constructors, assembling everything in main. This is called pure
or poor man's DI, and it works well until the graph gets large.
public static void main(String[] args) {
DataSource ds = new HikariDataSource(config()); // one pool, shared
OrderRepository repo = new JdbcOrderRepository(ds);
EmailSender mail = new SmtpEmailSender(config());
OrderService orders = new OrderService(repo, mail); // assembled here
new HttpServer(new OrderController(orders)).start();
}
That method is the composition root: the single place that knows every concrete type. Spring's container is a mechanism for building this graph automatically, from declarations, when writing it by hand stops scaling.
Key Takeaways
- Inversion of Control means something else owns a responsibility your code used to own.
- In framework style, the framework calls your code rather than the reverse.
- Dependency injection is the specific inversion of who supplies collaborators.
- A service locator hides dependencies inside the class; injection puts them in the signature.
- The main practical payoff is that tests can supply fakes with no infrastructure.
- The cost is indirection and wiring errors that appear at startup, not at compile time.
- DI needs no framework; the composition root can be plain code in
main.
🧪 Practice
- Assemble a three-class object graph by hand in
mainwith no framework, and identify the composition root.- Rewrite one class to use a static registry lookup instead, and list what you lost.
- Name three inversions of control in tooling you already use, outside of dependency injection.
- Interview: What can you tell about a class's dependencies by reading only its constructor, and why does that matter? (Hint: compare it with having to read every line of the class body.)
The Spring Container and ApplicationContext
Assembling an object graph by hand works until the graph has fifty nodes, at
which point main becomes a long, order-sensitive list of constructor calls
that must be edited every time a class gains a dependency. Spring's
container automates this. You declare what the objects are; it works out
the order and builds them.
An object managed by the container is called a bean. The container reads bean definitions, resolves the dependencies between them, instantiates them in a valid order, and holds them for the life of the application.
Configuration Container Your objects
------------- --------- ------------
@Configuration classes --> 1. read definitions
@Component classes 2. resolve dependencies OrderController
property files 3. instantiate in order -> OrderService
4. inject collaborators -> OrderRepository
5. run lifecycle hooks -> DataSource
6. hold until shutdown
The container is exposed through two interfaces. BeanFactory is the minimal
one and is rarely used directly. ApplicationContext extends it with everything
real applications need, and is what "the Spring container" normally means.
| Capability | BeanFactory | ApplicationContext |
|---|---|---|
| Bean instantiation and DI | Yes | Yes |
| Eager singleton creation | No, lazy | Yes, at startup |
| Annotation processing | No | Yes |
| Property resolution | No | Yes |
| Internationalised messages | No | Yes |
| Event publishing | No | Yes |
Automatic BeanPostProcessor registration | No | Yes |
Starting a container takes one line, given a configuration class.
@Configuration
@ComponentScan("com.shop") // find annotated classes under this package
public class AppConfig { }
public class Application {
public static void main(String[] args) {
// Creating the context builds every singleton immediately.
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Ask for the top of the graph. Everything below it is already wired.
OrderService orders = context.getBean(OrderService.class);
orders.place(anOrder());
} // close() runs destruction callbacks on every singleton
}
}
Eager instantiation at startup is a deliberate design choice. Every singleton is created when the context starts, so a missing or ambiguous dependency fails immediately with a clear error rather than at three in the morning when a rarely used code path first runs. This is why a Spring application that starts successfully is meaningfully more likely to be wired correctly.
getBean should appear roughly once per application. Calling it from inside
a bean is a service locator, with all the drawbacks described in the previous
topic. The correct pattern is to retrieve the entry point once and let injection
supply everything else. In Spring Boot, even that call disappears.
The context is also a resource for the beans inside it, offering events, property resolution, and a shutdown hook.
@Component
public class OrderService {
private final ApplicationEventPublisher events; // container facility
public OrderService(ApplicationEventPublisher events) {
this.events = events;
}
public void place(Order order) {
// Decouples the sender from the listeners entirely.
events.publishEvent(new OrderPlacedEvent(order.id()));
}
}
@Component
public class InventoryListener {
@EventListener // invoked by the container
public void on(OrderPlacedEvent event) { ... }
}
There are several context implementations, chosen by how configuration is supplied, and Spring Boot picks one for you.
| Implementation | Configuration source |
|---|---|
AnnotationConfigApplicationContext | Java @Configuration |
ClassPathXmlApplicationContext | XML on the classpath |
AnnotationConfigServletWebServerApplicationContext | Java, for web apps |
Key Takeaways
- The container builds and holds the object graph from declarations you write.
- A container-managed object is a bean.
ApplicationContextisBeanFactoryplus events, properties, and annotation support.- Singletons are created eagerly at startup, so wiring errors fail fast.
- Call
getBeanonce at the entry point; calling it inside a bean is a service locator.- Closing the context runs destruction callbacks, so use it as a resource.
- Beans can depend on container facilities such as the event publisher.
🧪 Practice
- Start an
AnnotationConfigApplicationContextwith two components, retrieve one, and confirm its collaborator was injected.context.getBeanDefinitionNames()and identify which beans you declared and which Spring added.- Remove a required bean and read the startup failure message, noting exactly which part names the missing type.
- Publish a custom event from one bean and consume it in two listeners.
- Interview: Why does Spring create singletons eagerly rather than on first use? (Hint: compare the cost of a slower startup with the cost of a wiring error discovered in production.)
Constructor, Setter, and Field Injection
Spring can supply a dependency in three places: through the constructor, through a setter, or directly into a field by reflection. They are not equivalent, and the choice has real consequences for correctness.
Constructor injection passes dependencies as constructor arguments. This is the recommended form for everything that is required.
@Service
public class OrderService {
private final OrderRepository repository; // final: cannot be reassigned
private final EmailSender emailSender;
// Since Spring 4.3, @Autowired is unnecessary when there is exactly
// one constructor. The container uses it automatically.
public OrderService(OrderRepository repository, EmailSender emailSender) {
this.repository = repository;
this.emailSender = emailSender;
}
}
Four properties make this the default choice. The object is fully
initialised the moment it exists, so there is no window in which it is
half-built. The fields can be final, which makes them immutable and
thread-safe. The dependencies are explicit in the signature, so the class
is honest about what it needs. And the class is usable without Spring,
which means tests construct it with new.
Setter injection supplies the dependency after construction.
@Service
public class ReportService {
private ExportFormat format = ExportFormat.CSV; // a working default
@Autowired(required = false) // optional: leave the default if absent
public void setFormat(ExportFormat format) {
this.format = format;
}
}
This is the right tool for genuinely optional dependencies with a sensible default, and for dependencies that can be reconfigured at runtime. It is the wrong tool for required ones, because the object exists in an incomplete state between construction and the setter call, and the field cannot be final.
Field injection writes straight into the field by reflection.
@Service
public class InvoiceService {
@Autowired
private InvoiceRepository repository; // concise, and a bad idea
}
It is the shortest to write and the one to avoid. The problems are not stylistic.
| Problem | Consequence |
|---|---|
Cannot be final | The field is mutable for the object's whole life |
| Invisible dependencies | The public API says nothing about what is required |
Not constructible with new | Tests need reflection or a full container |
| Hides design smells | Adding a tenth dependency costs one line, so nobody notices |
That last row matters more than it appears. A constructor with ten parameters is visibly wrong and prompts a refactor. Ten annotated fields look tidy, so a class quietly accumulates responsibilities.
// The test tells the story: this is what field injection costs.
@Test
void withFieldInjection() {
InvoiceService service = new InvoiceService();
// repository is null. The only ways forward are reflection or
// starting a Spring context for a test that needs neither.
}
@Test
void withConstructorInjection() {
var service = new OrderService(new InMemoryOrderRepository(),
new RecordingEmailSender()); // done
}
| Aspect | Constructor | Setter | Field |
|---|---|---|---|
| Required dependencies | Yes, enforced | No | No |
| Fields can be final | Yes | No | No |
| Visible in the API | Yes | Yes | No |
Testable with new | Yes | Yes | No |
| Circular dependencies | Fails at startup | Tolerated | Tolerated |
| Recommended for | Everything required | Optional with defaults | Nothing |
The last row of that table has a twist worth understanding. Constructor injection cannot resolve a circular dependency, because neither object can be built first, and Spring reports an error at startup. Setter and field injection tolerate the cycle by building both objects and wiring them afterwards. That sounds like an advantage and is not: a cycle between two services is a design problem, and constructor injection surfaces it immediately instead of letting it survive.
Spring also supports injecting collections of a type, which is how plugin-style designs are built.
@Service
public class ValidationService {
private final List<Validator> validators; // every Validator bean, injected
public ValidationService(List<Validator> validators) {
this.validators = validators; // add a new @Component to extend
}
}
Key Takeaways
- Constructor injection is the default for every required dependency.
- It guarantees a fully initialised object, allows final fields, and documents requirements in the signature.
@Autowiredis unnecessary on a class with a single constructor.- Setter injection suits optional dependencies that have working defaults.
- Field injection hides dependencies, prevents final fields, and blocks construction with
new.- A constructor growing too long is useful feedback that field injection suppresses.
- Constructor injection rejects circular dependencies at startup, which is a feature.
- Injecting a
List<T>supplies every bean of that type, enabling plugin designs.
🧪 Practice
- Write the same service three times, once per injection style, and try to unit test each with plain
new.- Create two services that depend on each other, first with constructor injection and then with setter injection, and compare the startup behaviour.
- Define three implementations of a
Validatorinterface and inject them all as a list, then add a fourth without editing the consumer.- Take a class with eight field-injected dependencies and convert it to constructor injection. Decide whether it should be split.
- Interview: Why does the Spring team recommend constructor injection when field injection works? (Hint: name one guarantee about object state and one thing a test can do without a container.)
Bean Definitions and Component Scanning
The container needs to know which objects to manage. There are two ways to tell it, and real applications use both because they suit different situations.
Component scanning is declaration by annotation. You mark a class, and Spring finds it by searching packages at startup.
@Service // "manage this class as a bean"
public class OrderService {
public OrderService(OrderRepository repository) { ... }
}
@Configuration
@ComponentScan("com.shop") // search this package and everything below it
public class AppConfig { }
Scanning looks for classes carrying @Component or any annotation that is
itself annotated with @Component. That indirection is what makes the
stereotype annotations work: they are all specialisations of one idea.
@Component generic container-managed object
|
+-- @Service business logic
+-- @Repository data access (adds exception translation)
+-- @Controller web request handling
| +-- @RestController @Controller + @ResponseBody
+-- @Configuration declares other beans
Functionally, @Service and @Component are almost identical. They differ in
intent, which matters for readability, and @Repository and @Controller add
real behaviour covered in 17.3 and 17.4.
Explicit @Bean methods are declaration by code. A method inside a
@Configuration class returns an object, and the returned object becomes a
bean named after the method.
@Configuration
public class DataConfig {
@Bean
public DataSource dataSource(
@Value("${db.url}") String url, // injected from properties
@Value("${db.user}") String user,
@Value("${DB_PASSWORD}") String password) {
var config = new HikariConfig();
config.setJdbcUrl(url);
config.setUsername(user);
config.setPassword(password);
config.setMaximumPoolSize(10);
return new HikariDataSource(config); // this object becomes a bean
}
@Bean
public OrderRepository orderRepository(DataSource dataSource) {
// Parameters are resolved from the container, so this receives
// the bean defined above.
return new JdbcOrderRepository(dataSource);
}
}
The decisive question when choosing between them: do you own the class?
| Situation | Use |
|---|---|
| Your own class, no construction logic | @Component family |
A third-party class such as HikariDataSource | @Bean method |
| Construction requires conditionals or configuration | @Bean method |
| Several beans of the same type with different settings | @Bean methods |
You cannot annotate a class from a library you did not write, which is why every
application has at least a few @Bean methods.
Scanning can be narrowed with filters, which matters for startup time in large applications and for excluding classes that should not be beans.
@ComponentScan(
basePackages = "com.shop",
excludeFilters = @ComponentScan.Filter(
type = FilterType.REGEX,
pattern = "com\\.shop\\.legacy\\..*")) // skip the old package
public class AppConfig { }
Bean names default to the decapitalised class name, or to the method name
for a @Bean. Two beans cannot share a name; the later definition silently
overrides the earlier one in some configurations, which is a genuinely confusing
failure. Name them explicitly when there is any doubt.
@Service("legacyOrderService") // explicit name
public class OrderServiceV1 implements OrderService { }
A practical convention worth adopting: put the configuration class in the root
package. Spring Boot's @SpringBootApplication scans the package it lives in
and everything beneath, so a root-package placement means scanning just works.
com.shop <-- Application.class lives here
com.shop.order scanned
com.shop.order.web scanned
com.shop.billing scanned
com.other NOT scanned
Key Takeaways
- Beans are declared either by annotation and scanning or by explicit
@Beanmethods.- Scanning finds classes annotated with
@Componentor any annotation built from it.- Stereotypes name intent;
@Repositoryand@Controlleralso add behaviour.@Beanmethods are required for third-party classes and for construction logic.@Beanmethod parameters are resolved from the container like any other injection point.- Default bean names come from the class or method name, and collisions cause confusing overrides.
- Place the configuration class in the root package so scanning covers the application.
🧪 Practice
- Declare one bean by annotation and one by a
@Beanmethod, then list both names from the context.- Move a component into a package outside the scan root and read the failure it produces.
- Define two
DataSourcebeans with different settings using@Beanmethods, then observe the error when something injectsDataSource.- Add an exclude filter that skips a package, and confirm its classes are no longer registered.
- Interview: You need a bean of a class from a third-party library. Which declaration style is available, and why is the other one not? (Hint: what would you have to modify to use annotations?)
Bean Scopes and Lifecycle Callbacks
A bean definition is a recipe. Scope answers a separate question: how many objects does the container build from that recipe, and how long does each one live?
The default is singleton, meaning one instance per container, shared by every injection point. This is not the singleton design pattern, which is one instance per classloader enforced by a private constructor; Spring's is one instance per container, with an ordinary constructor.
@Service // singleton by default
public class OrderService { }
Singleton scope Prototype scope
--------------- ---------------
ControllerA ----+ ControllerA ---> OrderService #1
| (a new one, per injection)
+--> OrderService
| (one shared) ControllerB ---> OrderService #2
ControllerB ----+
| Scope | One instance per | Available in |
|---|---|---|
singleton | Container (the default) | Everywhere |
prototype | Injection or getBean call | Everywhere |
request | HTTP request | Web contexts |
session | HTTP session | Web contexts |
application | ServletContext | Web contexts |
websocket | WebSocket session | Web contexts |
Singletons must be stateless, and this is the single most important rule in this topic. One instance serves every thread in the application, so a mutable field is shared mutable state without synchronisation.
@Service
public class BrokenOrderService {
private Order current; // SHARED across all threads
public void place(Order order) {
this.current = order; // thread A writes
validate(); // thread B may have overwritten it
repository.save(this.current); // the wrong order gets saved
}
}
@Service
public class CorrectOrderService {
// Injected collaborators are fine: they are set once and never reassigned.
private final OrderRepository repository;
public void place(Order order) {
validate(order); // state lives in parameters and locals,
repository.save(order); // which are per-thread by nature
}
}
Prototype scope gives a fresh instance every time, which suits objects that do carry state.
@Component
@Scope("prototype")
public class ImportJob {
private int processed; // safe: each user of this bean gets its own
}
There is a trap: a prototype injected into a singleton is resolved once, at singleton construction, so the singleton keeps the same prototype instance forever. The fix is to inject a provider and ask for a new one each time.
@Service
public class ImportService {
private final ObjectProvider<ImportJob> jobs; // a factory, not an instance
public ImportService(ObjectProvider<ImportJob> jobs) {
this.jobs = jobs;
}
public void run(File file) {
ImportJob job = jobs.getObject(); // a genuinely new instance
job.process(file);
}
}
Lifecycle callbacks let a bean run code after its dependencies are injected and before it is destroyed.
instantiate -> inject dependencies -> @PostConstruct -> ready to use
|
destroy <- @PreDestroy <- context.close() <--------------------+
@Component
public class ConnectionWarmer {
private final DataSource dataSource;
public ConnectionWarmer(DataSource dataSource) {
this.dataSource = dataSource;
}
@PostConstruct // after injection, before first use
public void warmUp() throws SQLException {
try (var conn = dataSource.getConnection()) {
conn.isValid(2); // pay the connection cost at startup
}
}
@PreDestroy // on context shutdown
public void reportShutdown() {
log.info("shutting down");
}
}
@PostConstruct exists because a constructor runs before injection completes
for setter and field injection, so initialisation depending on collaborators
cannot go there. With constructor injection, most initialisation can simply go
in the constructor, and @PostConstruct is reserved for work that should not
happen during construction, such as touching the network.
One important asymmetry: @PreDestroy is never called on prototype beans.
The container hands them out and forgets them, so anything holding a resource
must be closed by the code that requested it.
Key Takeaways
- Scope determines how many instances the container creates from one definition.
- The default, singleton, is one shared instance per container and is not the singleton pattern.
- Singleton beans must be stateless, because one instance serves all threads.
- Injected final collaborators are safe state; mutable request data is not.
- A prototype injected into a singleton is resolved once; use
ObjectProviderfor a fresh instance.@PostConstructruns after injection, which a constructor cannot guarantee for setter or field injection.@PreDestroyruns at context shutdown, and never for prototype beans.
🧪 Practice
- Inject the same singleton into two components and confirm the references are identical, then repeat with prototype scope.
- Add a mutable counter field to a singleton service, call it from twenty threads, and observe the result.
- Inject a prototype into a singleton, call it repeatedly, and show the same instance is returned. Fix it with
ObjectProvider.- Add
@PostConstructand@PreDestroylogging to a bean and run the context inside a try-with-resources block.- Interview: A developer stores the current user in a field on an
@Servicebean. What goes wrong and when? (Hint: how many instances exist, and how many threads use them at once?)
Qualifiers, Primary Beans, and Ambiguity
Spring resolves an injection point by type. When exactly one bean matches, the process is invisible. The interesting cases are when zero match or when more than one does.
Zero matches produces NoSuchBeanDefinitionException at startup, and the fix is
to define the bean or widen the component scan. Multiple matches produces
NoUniqueBeanDefinitionException, and there are four ways to resolve it.
public interface NotificationSender { void send(Message message); }
@Component
public class EmailSender implements NotificationSender { }
@Component
public class SmsSender implements NotificationSender { }
@Service
public class AlertService {
// AMBIGUOUS: two beans implement NotificationSender.
public AlertService(NotificationSender sender) { ... }
}
@Primary marks a default. It is the right choice when one implementation
is what almost everything should get.
@Component
@Primary // chosen when nothing more specific asks
public class EmailSender implements NotificationSender { }
@Qualifier names a specific bean. It is the right choice at an injection
point that genuinely needs a particular implementation.
@Service
public class AlertService {
public AlertService(@Qualifier("smsSender") NotificationSender sender) {
// "smsSender" is the default bean name of the SmsSender class
this.sender = sender;
}
}
The two combine well: mark the common case @Primary, and use @Qualifier at
the few places that need the exception.
A custom qualifier annotation is better than a string when the distinction is meaningful, because it survives renames and is checked by the compiler.
@Qualifier // makes this a qualifier annotation
@Retention(RetentionPolicy.RUNTIME)
public @interface Urgent { }
@Component
@Urgent
public class SmsSender implements NotificationSender { }
@Service
public class AlertService {
public AlertService(@Urgent NotificationSender sender) { ... }
}
Parameter names act as an implicit qualifier. If no other rule applies, Spring falls back to matching the parameter name against the bean name, which is why this works without any annotation.
// The parameter name "smsSender" matches the SmsSender bean's name.
public AlertService(NotificationSender smsSender) { ... }
This is convenient and fragile, since it breaks if the parameter is renamed or if the build strips parameter names. Prefer an explicit qualifier.
Resolution proceeds in a fixed order, and knowing it explains most surprises.
1. Match by type -> exactly one? done
2. Several matches?
a. narrow by @Qualifier or a custom qualifier annotation
b. otherwise take the @Primary bean
c. otherwise match the bean name against the parameter name
d. otherwise fail with NoUniqueBeanDefinitionException
| Mechanism | Declared at | Best for |
|---|---|---|
@Primary | The bean | The sensible default for most callers |
@Qualifier("name") | The injection point | A specific exception to the default |
| Custom qualifier | Both | A meaningful distinction, type-safe |
| Parameter name | Implicit | Nothing; too fragile to rely on |
Injecting all of them is often the real answer. When several implementations should all run, take a collection rather than picking one.
@Service
public class AlertService {
private final List<NotificationSender> senders; // every implementation
public AlertService(List<NotificationSender> senders) {
this.senders = senders;
}
public void alert(Message message) {
senders.forEach(s -> s.send(message)); // notify through all channels
}
}
Order within that list is undefined unless you control it with @Order or by
implementing Ordered, which matters when the beans form a pipeline.
Key Takeaways
- Injection resolves by type first; ambiguity is an error, not a coin toss.
@Primarydeclares the default implementation on the bean itself.@Qualifierselects a specific bean at the injection point.- Custom qualifier annotations are type-safe and survive renaming.
- Parameter-name matching is a fallback that should not be relied upon.
- Combining a
@Primarydefault with targeted qualifiers is the usual design.- Injecting a
ListorMapof a type is correct when all implementations should participate.- Use
@Orderwhen the collection's order matters.
🧪 Practice
- Create two implementations of one interface, inject the interface, and read the resulting startup error.
- Resolve it with
@Primary, then override that choice at one injection point with@Qualifier.- Write a custom qualifier annotation and use it in place of the string form.
- Inject all implementations as a
Listand control their order with@Order.- Interview: Two teams each add an implementation of the same interface and the application stops starting. What are the two fixes, and which belongs to which team? (Hint: one is declared on the bean, one at the injection point.)
Java Configuration versus XML Configuration
Spring's configuration has been written three ways over its lifetime, and you will meet all three. Knowing why the style changed explains a lot of code you will read in older systems.
XML came first. Beans and their wiring were declared in files outside the Java source.
<beans xmlns="http://www.springframework.org/schema/beans" ...>
<bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource">
<property name="jdbcUrl" value="${db.url}"/>
<property name="username" value="${db.user}"/>
</bean>
<!-- The wiring is expressed by referring to another bean's id. -->
<bean id="orderRepository" class="com.shop.JdbcOrderRepository">
<constructor-arg ref="dataSource"/>
</bean>
<bean id="orderService" class="com.shop.OrderService">
<constructor-arg ref="orderRepository"/>
</bean>
</beans>
The appeal was that configuration lived entirely outside the code, so it could be changed without recompiling. The cost was that the compiler cannot check any of it: a typo in a class name, a renamed property, or a missing constructor argument all fail at startup, and refactoring tools cannot follow a class name inside a string.
Annotations came next, moving declarations onto the classes themselves.
@Service
public class OrderService {
public OrderService(OrderRepository repository) { ... }
}
This is concise and refactor-safe, but it only works for classes you own, and it puts configuration inside the class it configures.
Java configuration is the current default and combines the strengths of both: declarations are code, so the compiler checks them, but they live in a dedicated configuration class rather than in the domain classes.
@Configuration
public class AppConfig {
@Bean
public DataSource dataSource(@Value("${db.url}") String url) {
var config = new HikariConfig();
config.setJdbcUrl(url);
return new HikariDataSource(config);
}
@Bean
public OrderRepository orderRepository(DataSource dataSource) {
return new JdbcOrderRepository(dataSource); // checked by the compiler
}
@Bean
public OrderService orderService(OrderRepository repository) {
return new OrderService(repository);
}
}
| Aspect | XML | Annotations | Java config |
|---|---|---|---|
| Compile-time checking | None | Yes | Yes |
| Refactoring support | Poor, names in strings | Full | Full |
| Works for library classes | Yes | No | Yes |
| Configuration separated from code | Yes | No | Yes |
| Conditional logic | Very limited | Limited | Full Java |
| Verbosity | High | Lowest | Moderate |
One subtlety of @Configuration is worth understanding, because it explains
behaviour that otherwise looks impossible. Spring subclasses configuration
classes with a proxy, so a direct call from one @Bean method to another
returns the existing singleton rather than a new object.
@Configuration
public class AppConfig {
@Bean
public DataSource dataSource() { return new HikariDataSource(...); }
@Bean
public OrderRepository orderRepository() {
// Looks like a second pool. Is not: the proxy returns the singleton.
return new JdbcOrderRepository(dataSource());
}
@Bean
public AuditRepository auditRepository() {
return new JdbcAuditRepository(dataSource()); // the same instance again
}
}
This is called full mode, and it depends on the class being annotated
@Configuration. Using @Component instead gives lite mode, where the
proxy is absent and each call really does create a new object, which is a subtle
and expensive bug when the object is a connection pool. Taking the dependency as
a method parameter avoids the question entirely and is the clearer style.
Mixing is supported, which matters when modernising an old application. A Java configuration class can import legacy XML.
@Configuration
@ImportResource("classpath:legacy-beans.xml") // pull in existing XML
@ComponentScan("com.shop.modern")
public class AppConfig { }
The practical guidance: write Java configuration for third-party and infrastructure beans, annotate your own classes, and treat XML as something you read rather than write.
Key Takeaways
- XML, annotations, and Java configuration are three styles of the same declarations.
- XML has no compile-time checking and resists refactoring because names are strings.
- Annotations are concise but only apply to classes you own.
- Java configuration is compiler-checked, works for library classes, and supports real logic.
@Configurationclasses are proxied, so inter-bean method calls return singletons.@Componentgives lite mode with no proxy, where such calls create new objects.- Taking dependencies as
@Beanmethod parameters sidesteps the full versus lite distinction.- XML can be imported into Java configuration, which is how legacy systems are migrated.
🧪 Practice
- Express the same three-bean graph in XML and in Java configuration, and compare the failure you get from a misspelled class name in each.
- Call one
@Beanmethod from another inside a@Configurationclass and print the identity hash to confirm one instance.- Change
@Configurationto@Componenton that class and repeat the previous exercise.- Import an XML file into a Java configuration class and inject a bean declared in it.
- Interview: Why can a
@Beanmethod call another@Beanmethod and still get a singleton? (Hint: what does Spring do to the configuration class before instantiating it?)
<a id="172-aspect-oriented-programming"></a>
17.2 Aspect-Oriented Programming
This subchapter covers the second pillar of Spring: separating concerns that cut across many classes, such as transactions, logging, and security, from the business logic they surround, and understanding the proxy mechanism that makes it work and the limits that mechanism imposes.
Cross-Cutting Concerns
Object-oriented decomposition organises code by what it is about. Orders go in an order class, invoices in an invoice class. This works beautifully for domain logic and fails for a specific category of requirement: the ones that apply to everything.
Consider a service method that has been made production-ready.
@Service
public class OrderService {
public Order place(Order order) {
long start = System.nanoTime(); // timing
log.info("place() called with {}", order.id()); // logging
if (!securityContext.hasRole("CUSTOMER")) { // security
throw new AccessDeniedException("not permitted");
}
Connection conn = dataSource.getConnection(); // transaction
conn.setAutoCommit(false);
try {
validate(order); // the actual work
repository.save(conn, order); // the actual work
conn.commit();
log.info("place() took {} ms", // timing
(System.nanoTime() - start) / 1_000_000);
return order;
} catch (Exception e) {
conn.rollback(); // transaction
log.error("place() failed", e); // logging
throw e;
} finally {
conn.close();
}
}
}
Two lines do what the method is named for. Twenty do something else. And those twenty lines will appear, near-identically, in every service method in the application.
OrderService InvoiceService ShippingService
------------ -------------- ---------------
logging X X X
security X X X
transactions X X X
timing X X X
business logic A B C
^
the concerns cut ACROSS the class structure
A requirement that spans many otherwise-unrelated modules is a cross-cutting concern. The common examples are transaction management, security checks, logging and auditing, caching, performance monitoring, retry logic, and input validation.
The cost of scattering them is concrete.
| Problem | What it looks like in practice |
|---|---|
| Duplication | The same twelve lines in two hundred methods |
| Inconsistency | Some methods log the failure, some do not |
| Tangling | Business logic is unreadable under the surrounding code |
| Change amplification | Switching logging format means editing every method |
| Forgetting | A new method silently ships with no security check |
Ordinary object-oriented tools help only partially. Inheritance forces every service to extend one base class, which conflicts with modelling the domain. A template method fixes the shape of every method. A decorator works but must be hand-written and registered for each interface.
Aspect-Oriented Programming (AOP) addresses this directly. The idea is to write the cross-cutting code once, in one place, and declare where it applies by a rule rather than by editing each site.
// The business method, with the concerns removed.
@Service
public class OrderService {
@Transactional // declared, not implemented here
public Order place(Order order) {
validate(order);
return repository.save(order);
}
}
// The concern, written once, applied by a rule.
@Aspect
@Component
public class TimingAspect {
@Around("execution(* com.shop..*Service.*(..))") // where it applies
public Object time(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
try {
return pjp.proceed(); // run the real method
} finally {
log.info("{} took {} ms", pjp.getSignature(),
(System.nanoTime() - start) / 1_000_000);
}
}
}
The service is back to two lines of business logic. The timing code exists once and covers every service method, including ones written next year.
The trade-off is honest and should be stated. Behaviour now happens that is
not visible in the method you are reading. A developer debugging place() sees
no transaction code, so understanding what actually runs requires knowing the
aspects exist. AOP therefore pays off for a small number of genuinely universal
concerns and becomes a maintenance problem when used for business rules.
Key Takeaways
- Cross-cutting concerns apply across many classes and resist object-oriented decomposition.
- Typical examples are transactions, security, logging, caching, and monitoring.
- Scattering them causes duplication, inconsistency, and unreadable business logic.
- Inheritance and decorators address the problem only partially.
- AOP moves the concern into one module and declares where it applies by a rule.
- The cost is invisible behaviour, so AOP suits infrastructure rather than business rules.
🧪 Practice
- Take a service method and count the lines belonging to the domain versus the lines belonging to infrastructure.
- List five cross-cutting concerns in a project you know and note how each is currently implemented.
- Implement timing for two services with a hand-written decorator, then describe what adding a third service costs.
- Interview: Why is inheritance a poor tool for adding logging to every service class? (Hint: how many base classes can a class have, and what else might it need to extend?)
Join Points, Pointcuts, and Advice
AOP has its own vocabulary, and the terms are worth learning precisely because they map onto the annotations you will write. There are five.
JOIN POINT a point in execution where advice could apply
(in Spring: always a method call on a bean)
|
POINTCUT an expression selecting WHICH join points
"every public method in com.shop.service"
|
ADVICE the code to run, and WHEN relative to the join point
"log before", "measure around", "rollback after throwing"
|
ASPECT a class combining pointcuts and advice
|
WEAVING the act of connecting them to the target
(in Spring: creating a proxy at startup)
An analogy: the join points are every intersection on a road network, the pointcut is the rule "all intersections downtown", the advice is "install a traffic camera", and the aspect is the department that owns the policy.
Advice types determine when the code runs relative to the method.
| Annotation | Runs | Can it stop the method? |
|---|---|---|
@Before | Before the method | Only by throwing |
@AfterReturning | After a normal return | No |
@AfterThrowing | After an exception escapes | No |
@After | After either outcome, like finally | No |
@Around | Surrounds the call | Yes, fully |
+-------------------------------------------+
@Around | before proceed() |
| +-----------------------------------+ |
@Before | | | |
| | the target method runs | |
@AfterReturning -> on normal return | |
@AfterThrowing -> on exception | |
@After -> either way | |
| +-----------------------------------+ |
| after proceed() |
+-------------------------------------------+
@Aspect
@Component
public class AuditAspect {
@Before("execution(* com.shop.service.*.*(..))")
public void logEntry(JoinPoint jp) {
// JoinPoint exposes the signature and arguments, read-only.
log.info("entering {} with {}", jp.getSignature().getName(),
Arrays.toString(jp.getArgs()));
}
@AfterThrowing(pointcut = "execution(* com.shop.service.*.*(..))",
throwing = "ex") // binds the thrown exception
public void logFailure(JoinPoint jp, Exception ex) {
log.error("{} failed: {}", jp.getSignature().getName(), ex.getMessage());
}
@Around("execution(* com.shop.service.*.*(..))")
public Object retry(ProceedingJoinPoint pjp) throws Throwable {
for (int attempt = 1; ; attempt++) {
try {
return pjp.proceed(); // MUST be called, or the method
} catch (TransientException e) { // never runs at all
if (attempt == 3) throw e;
}
}
}
}
@Around is the most powerful because it receives a ProceedingJoinPoint and
decides whether, when, and how often to call proceed(). Forgetting to call it
silently skips the target method, which is the classic AOP bug.
Pointcut expressions use the AspectJ language. The execution designator
covers most needs, and its shape is worth memorising.
execution( modifiers? return-type declaring-type.method(params) throws? )
execution( public * com.shop.service.*.*(..) )
^ ^ ^ ^ ^ ^
optional any package | | any arguments
return | any method name
type any class
| Expression | Matches |
|---|---|
execution(* com.shop.service.*.*(..)) | Any method in that package |
execution(public * *(..)) | Any public method |
execution(* save*(..)) | Methods whose name starts with save |
execution(* *(String, ..)) | First parameter is a String |
within(com.shop.service..*) | Any join point in that package tree |
@annotation(com.shop.Audited) | Methods carrying that annotation |
bean(*Repository) | Beans whose name ends in Repository |
this(com.shop.OrderService) | The proxy is of that type |
target(com.shop.OrderService) | The target object is of that type |
Named pointcuts avoid repeating the expression, which matters because a typo in a pointcut fails silently: the advice simply never runs.
@Aspect
@Component
public class AuditAspect {
@Pointcut("execution(* com.shop.service.*.*(..))")
public void serviceMethods() { } // an empty method: a name only
@Pointcut("@annotation(com.shop.Audited)")
public void audited() { }
// Pointcuts compose with &&, || and !
@Before("serviceMethods() && audited()")
public void logAudited(JoinPoint jp) { ... }
}
Annotation-driven pointcuts are usually the better design, because they make the behaviour visible at the point it applies instead of hiding it in a package naming convention.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Audited { }
@Service
public class OrderService {
@Audited // the reader can see this applies
public Order place(Order order) { ... }
}
This also survives refactoring: moving a class to a different package does not silently switch the advice off.
Key Takeaways
- A join point is a candidate location; in Spring it is always a method call on a bean.
- A pointcut is an expression selecting join points; advice is the code that runs.
- An aspect packages pointcuts with advice, and weaving connects them to the target.
- The five advice types differ in timing and in whether they can prevent the call.
@Aroundmust callproceed(), or the target method never executes.executionis the workhorse designator;@annotationandwithincover most of the rest.- Named
@Pointcutmethods let expressions be reused and composed.- A mistyped pointcut fails silently by matching nothing.
🧪 Practice
- Write a
@Beforeaspect that logs the name and arguments of every method in one package.- Add an
@Aroundaspect that measures execution time, then deliberately omitproceed()and observe what happens.- Define an
@Auditedannotation and an aspect triggered by@annotation, and apply it to two methods.- Write one pointcut matching any public method whose name starts with
findand which takes aLongfirst parameter.- Interview: An aspect appears correct but never runs. What are the first two things you would check? (Hint: one concerns the expression, one concerns whether the object is managed by the container.)
Proxy-Based Aspects in Spring
AOP needs a mechanism to intercept calls. AspectJ does it by rewriting bytecode at compile time or class load time, which is powerful and requires extra build tooling. Spring takes a simpler route: it creates a proxy object at runtime, and understanding this explains both what works and what silently does not.
When a bean matches a pointcut, the container does not hand out the bean. It hands out a wrapper that implements the same interface or extends the same class, runs the advice, and delegates to the real object.
Without AOP With AOP
----------- --------
Controller Controller
| |
v v
OrderService (the real object) OrderService$$Proxy
| advice runs here
v
OrderService (the real object)
@Service
public class OrderService {
public Order place(Order order) { ... }
}
@Component
public class OrderController {
private final OrderService service; // this is the PROXY, not the real bean
public OrderController(OrderService service) {
this.service = service;
// Proof: the class name is not what you wrote.
System.out.println(service.getClass());
// e.g. class com.shop.OrderService$$SpringCGLIB$$0
}
}
Spring chooses between two proxying strategies.
| Strategy | Requires | Proxies | Limitation |
|---|---|---|---|
| JDK dynamic | The target implements an interface | Interface methods only | Cannot inject the concrete class |
| CGLIB | A non-final class with a usable constructor | All public methods | Cannot proxy final classes or final methods |
Spring Boot defaults to CGLIB for everything, which avoids the common failure where a class implements an interface, gets a JDK proxy, and then cannot be injected by its concrete type.
// With a JDK proxy, this fails: the proxy implements OrderService (the
// interface) but is NOT an instance of JdbcOrderService (the class).
public OrderController(JdbcOrderService service) { ... } // no such bean
Enabling AOP is one annotation, and Spring Boot adds it automatically when
spring-boot-starter-aop is on the classpath.
@Configuration
@EnableAspectJAutoProxy // create proxies for beans matching any @Aspect
public class AopConfig { }
Advice ordering matters when several aspects match the same method. Without
direction the order is undefined; @Order fixes it, with lower values running
outermost.
@Aspect
@Component
@Order(1) // outermost: runs first, returns last
public class SecurityAspect { }
@Aspect
@Component
@Order(2) // inside security, outside the method
public class TransactionAspect { }
call --> SecurityAspect (1) --> TransactionAspect (2) --> OrderService
<-- <-- <--
That particular order is deliberate in real systems: an unauthorised call should be rejected before a transaction is opened.
Three consequences of the proxy approach deserve naming now, with the last covered in detail two topics further on.
Only external calls are intercepted. A call from one method of a bean to another method of the same bean does not pass through the proxy, so no advice runs.
Only Spring-managed beans are proxied. An object created with new has no
proxy and no advice, no matter what annotations its class carries.
Only public methods are advised under CGLIB, because private and final methods cannot be overridden.
@Service
public class OrderService {
@Transactional
private void save(Order order) { ... } // NEVER transactional: not overridable
}
Key Takeaways
- Spring implements AOP with runtime proxies rather than bytecode weaving.
- Injected references to advised beans are proxies, not the objects you wrote.
- JDK proxies require an interface; CGLIB subclasses the class and is Boot's default.
- Final classes, final methods, and private methods cannot be advised.
@EnableAspectJAutoProxyactivates proxying, and Spring Boot adds it automatically.@Ordercontrols the nesting of multiple aspects, lowest value outermost.- Advice runs only for external calls to container-managed beans.
🧪 Practice
getClass()on an injected advised bean and on a non-advised one, and compare the names.- Make a service class
finaland observe the startup failure when an aspect targets it.- Apply two aspects to one method, then set
@Orderon each and confirm the nesting changes.- Create a service instance with
new, call an advised method, and show no advice runs.- Interview: Why does Spring Boot proxy with CGLIB by default rather than JDK proxies? (Hint: what type does the application usually inject, and what does a JDK proxy actually implement?)
Declarative Transactions with @Transactional
16.3 established where transaction boundaries belong and showed the code that implements them: get a connection, disable auto-commit, commit on success, roll back on failure, restore state. That block is identical in every service method, which makes it the textbook cross-cutting concern.
@Transactional replaces it with one annotation, and the aspect supplies the
rest.
@Service
public class TransferService {
private final AccountRepository accounts;
@Transactional // the aspect opens, commits, rolls back
public void transfer(long from, long to, BigDecimal amount) {
accounts.debit(from, amount);
accounts.credit(to, amount); // if this throws, the debit is undone
}
}
What the proxy actually does around that method:
proxy.transfer(...)
|
+-- get a connection, setAutoCommit(false) [begin]
|
+-- target.transfer(...) ------------------------ your code
| |
| +-- normal return --> commit()
| |
| +-- RuntimeException --> rollback()
| |
| +-- checked Exception --> COMMIT (the default, and a trap)
|
+-- release the connection
The rollback rule is the single most surprising default in Spring. By
default, @Transactional rolls back for RuntimeException and Error, and
commits for checked exceptions. The reasoning is historical, following EJB
convention, and it catches nearly everyone once.
// BUG: this commits. The checked exception does not trigger a rollback.
@Transactional
public void importFile(File f) throws IOException {
repository.save(parse(f));
throw new IOException("disk full"); // partial work is COMMITTED
}
// FIX: state which exceptions should roll back.
@Transactional(rollbackFor = Exception.class)
public void importFile(File f) throws IOException { ... }
Propagation answers what happens when a transactional method calls another
one. The default, REQUIRED, joins the caller's transaction if there is one.
| Propagation | With an existing transaction | Without one |
|---|---|---|
REQUIRED | Join it (the default) | Start a new one |
REQUIRES_NEW | Suspend it, start a new one | Start a new one |
SUPPORTS | Join it | Run without one |
NOT_SUPPORTED | Suspend it, run without | Run without one |
MANDATORY | Join it | Throw an exception |
NEVER | Throw an exception | Run without one |
NESTED | Use a savepoint | Start a new one |
REQUIRES_NEW is the one with a genuine use case: writing an audit record that
must survive even when the business transaction rolls back.
@Service
public class AuditService {
// A separate transaction, committed independently of the caller's.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String event) {
auditRepository.save(new AuditEntry(event));
}
}
Be aware that this occupies a second connection while the first is suspended, so a pool of ten can deadlock if every request does it.
The other settings mirror what 16.3 covered at the JDBC level.
@Transactional(
isolation = Isolation.REPEATABLE_READ, // as in 16.3
timeout = 10, // seconds before forced rollback
readOnly = true, // hint: skips dirty checking in JPA
rollbackFor = Exception.class,
noRollbackFor = NotFoundException.class)
public Report generate(long id) { ... }
readOnly = true is worth applying to every query method. With JPA it tells
Hibernate to skip dirty-checking snapshots, which is a measurable saving on
large result sets.
Where to put the annotation. On the service layer, on public methods, matching the business operation. Putting it on a repository method creates one transaction per database call, which 16.3 identified as the anti-pattern that makes multi-step operations non-atomic.
Two failure modes to recognise, both of which follow from the proxy mechanism and are covered in the next topic: the annotation does nothing on a private method, and it does nothing when the method is called from within the same class.
Key Takeaways
@Transactionalreplaces the manual begin, commit, and rollback block with an aspect.- It rolls back on unchecked exceptions and commits on checked ones by default.
- Use
rollbackFor = Exception.classwhenever checked exceptions can escape.REQUIREDjoins an existing transaction and is the correct default.REQUIRES_NEWsuspends the caller's transaction and needs a second connection.readOnly = trueon query methods lets JPA skip dirty checking.- Annotate public service methods, not repository methods.
- The annotation is silently ignored on private methods and on self-invoked calls.
🧪 Practice
- Write a transfer method that throws after the first update, and confirm nothing is persisted.
- Change the thrown exception to a checked one and observe that the work commits. Fix it with
rollbackFor.- Implement audit logging with
REQUIRES_NEWand prove the audit row survives a rolled-back caller.- Add
@Transactionalto a private method and demonstrate it has no effect.- Interview: A service method annotated
@Transactionalthrows a checked exception and the data is still saved. Is this a Spring bug? (Hint: what is the documented default rollback rule, and why was it chosen?)
Logging, Security, and Caching Aspects
Transactions are the aspect Spring provides. The same machinery handles three other concerns that appear in nearly every application, two of which Spring also provides ready-made.
Logging and auditing is the concern most often written by hand, because the requirements are application-specific.
@Aspect
@Component
public class AuditAspect {
private final AuditRepository audits;
private final SecurityContext security;
@AfterReturning(pointcut = "@annotation(audited)", returning = "result")
public void record(JoinPoint jp, Audited audited, Object result) {
// The annotation itself is bound as a parameter, so its
// attributes configure the advice.
audits.save(new AuditEntry(
audited.action(), // from the annotation
security.currentUser(),
jp.getSignature().getName(),
Instant.now()));
}
}
@Service
public class AccountService {
@Audited(action = "CLOSE_ACCOUNT") // visible, configurable, one line
public void close(long accountId) { ... }
}
Binding the annotation as an advice parameter is the pattern that makes custom aspects genuinely useful: one aspect, configured per site.
Security is provided by Spring Security, whose method-level checks are aspects.
@Service
public class DocumentService {
@PreAuthorize("hasRole('ADMIN')") // evaluated before the call
public void delete(long id) { ... }
// SpEL can reference the arguments and the authenticated principal.
@PreAuthorize("#ownerId == authentication.principal.id")
public List<Document> listFor(long ownerId) { ... }
@PostAuthorize("returnObject.owner == authentication.name") // after the call
public Document find(long id) { ... }
}
@PreAuthorize throws before the method runs, which is what you want for a
delete. @PostAuthorize runs the method first and then discards the result,
which is only appropriate when the decision depends on what was returned.
Caching is provided by Spring's cache abstraction, again as an aspect.
@Service
public class ProductService {
@Cacheable("products") // skip the method on a cache hit
public Product findById(long id) {
return repository.findById(id); // runs only on a miss
}
@CacheEvict(value = "products", key = "#product.id") // invalidate on write
public Product update(Product product) {
return repository.save(product);
}
@CacheEvict(value = "products", allEntries = true)
public void reindex() { ... }
}
caller --> proxy --> key in cache? --yes--> return cached value
| (the method never runs)
no
|
v
target method --> store result --> return
| Annotation | Effect |
|---|---|
@Cacheable | Return the cached value if present; otherwise run and store |
@CachePut | Always run the method, and update the cache with the result |
@CacheEvict | Remove one entry or all entries |
@Caching | Combine several of the above on one method |
Two cautions on caching. Cache keys default to the method arguments, so a method whose result also depends on the current user or tenant will serve one user's data to another unless the key includes it.
@Cacheable(value = "documents", key = "#id + '-' + authentication.name")
public Document find(long id) { ... }
And cache invalidation must cover every write path. A row updated by a different service, a scheduled job, or a database migration leaves the cache stale, which is why time-based expiry is usually configured as a backstop.
The common thread across all four concerns is worth stating: each is configured declaratively at the point it applies and implemented once centrally, which is exactly what the first topic of this subchapter argued for.
Key Takeaways
- Transactions, security, caching, and auditing are all implemented as aspects over the same proxy mechanism.
- Binding an annotation as an advice parameter lets one aspect be configured per method.
@PreAuthorizeblocks the call;@PostAuthorizediscards the result afterwards.@Cacheableskips the method entirely on a hit, which is the whole point and the whole risk.- Default cache keys are the arguments, so user-specific data needs an explicit key.
- Every write path must evict, and time-based expiry is a necessary backstop.
🧪 Practice
- Write an
@Auditedannotation and an aspect that records the method name and the current user.- Add
@Cacheableto a slow repository lookup and measure the second call.- Demonstrate a stale cache by updating a row through a method that does not evict, then fix it.
- Protect one method with
@PreAuthorizeusing a SpEL expression that references a method argument.- Interview: A caching bug leaks one customer's data to another. What is the most likely cause? (Hint: what does the cache key consist of by default, and what is missing from it?)
Proxy Limitations and Self-Invocation
Every limitation in Spring AOP follows from one fact established two topics ago: advice lives on the proxy, not on your object. If a call does not go through the proxy, no advice runs. This topic collects the consequences, because each one produces a bug that looks like the framework is broken.
Self-invocation is the big one. When a method calls another method on the
same object, the call uses this, which is the target, not the proxy.
@Service
public class OrderService {
public void processAll(List<Order> orders) {
for (Order order : orders) {
process(order); // 'this.process(...)' -- bypasses the proxy
} // @Transactional below does NOTHING here
}
@Transactional
public void process(Order order) {
repository.save(order);
}
}
External call Self-invocation
------------- ---------------
caller --> PROXY --> target caller --> PROXY --> target.processAll()
^ |
advice runs this.process()
|
target.process()
advice never runs
The same silence affects @Cacheable, @PreAuthorize, @Async, and every
custom aspect. Nothing fails; the behaviour simply does not happen, which makes
it hard to spot.
There are four fixes, in descending order of preference.
Move the method to another bean. This is almost always the right answer, because the two methods have different responsibilities: one orchestrates, one performs a unit of work.
@Service
public class OrderProcessor {
@Transactional
public void process(Order order) { repository.save(order); }
}
@Service
public class OrderBatchService {
private final OrderProcessor processor; // a different bean: proxied
public void processAll(List<Order> orders) {
orders.forEach(processor::process); // goes through the proxy
}
}
Inject yourself. Ugly but occasionally pragmatic.
@Service
public class OrderService {
@Lazy private final OrderService self; // @Lazy breaks the cycle
public OrderService(@Lazy OrderService self) { this.self = self; }
public void processAll(List<Order> orders) {
orders.forEach(self::process); // explicitly via the proxy
}
}
Look the proxy up at runtime, which requires exposeProxy = true on
@EnableAspectJAutoProxy and is the least readable option.
((OrderService) AopContext.currentProxy()).process(order);
Use AspectJ load-time weaving, which modifies the bytecode so there is no proxy and self-invocation works. It solves the problem completely at the cost of a build-time or JVM-agent dependency, and is rare in practice.
The other limitations all share the same root cause.
| Limitation | Why |
|---|---|
| Private methods are not advised | CGLIB cannot override them |
| Final methods are not advised | Cannot be overridden |
| Final classes cannot be proxied | Cannot be subclassed |
static methods are not advised | Not dispatched on an instance |
Objects created with new are not advised | The container never saw them |
| Constructors are not advised | The proxy's constructor runs, not an intercepted call |
| Fields are not intercepted | Spring AOP only supports method join points |
@Service
public class ReportService {
@Transactional
private void save(Report r) { } // silently non-transactional
@Cacheable("reports")
public final Report find(long id) { } // final: silently uncached
public ReportService() {
loadDefaults(); // no advice applies during construction
}
}
How to detect these problems, since none of them produce an error:
- Print the injected bean's class; a name containing
$$SpringCGLIB$$confirms it is proxied. - Enable transaction logging and check whether a transaction is actually opened.
logging.level.org.springframework.transaction.interceptor=TRACE
- Write a test that asserts the behaviour, such as a rollback, rather than assuming the annotation took effect.
@Test
void processAllRollsBackEachOrder() {
assertThrows(RuntimeException.class, () -> service.processAll(withOneBad()));
assertEquals(0, repository.count()); // fails loudly if the proxy was bypassed
}
Key Takeaways
- Advice lives on the proxy, so any call that bypasses the proxy loses it.
- Self-invocation through
thisis the most common way advice is silently lost.- The preferred fix is moving the annotated method into a separate bean.
- Self-injection with
@LazyandAopContext.currentProxy()are fallbacks.- Private, final, and static methods, and final classes, cannot be advised.
- Objects created with
neware never advised, regardless of annotations.- These failures are silent, so verify with proxy class names, transaction logs, or behavioural tests.
🧪 Practice
- Build the self-invocation example, confirm no transaction is opened, then fix it by extracting a second bean.
- Enable transaction interceptor logging and compare the output for an external call and a self-invoked one.
- Annotate a private method with
@Cacheableand prove the cache is never consulted.- Write a test asserting rollback behaviour that would fail if the proxy were bypassed.
- Interview: A
@Transactionalmethod works when called from a controller but not when called from another method in the same service. Explain the mechanism. (Hint: which object doesthisrefer to inside the target?)
<a id="173-spring-data-access"></a>
17.3 Spring Data Access
This subchapter applies the two previous ones to the database work of chapter 16: removing JDBC boilerplate with a template, managing transactions declaratively, generating repository implementations, and keeping the schema in step with the code.
JdbcTemplate and Boilerplate Reduction
Chapter 16 showed the correct shape of a JDBC query: obtain a connection, prepare a statement, bind parameters, iterate the result set, map each row, close everything, and translate the exception. Only two of those seven steps are specific to the query being run. The other five are identical every time.
JdbcTemplate implements the five and asks you to supply the two. It is an
application of the template method pattern: the framework owns the
algorithm, your code fills in the variable parts.
// Plain JDBC: 18 lines, of which 4 concern this particular query.
public Optional<Order> findById(long id) {
String sql = "SELECT id, customer_id, total FROM orders WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return Optional.empty();
return Optional.of(new Order(rs.getLong("id"),
rs.getLong("customer_id"),
rs.getBigDecimal("total")));
}
} catch (SQLException e) {
throw new DataAccessException("finding order " + id, e);
}
}
// JdbcTemplate: the same query, with the mechanics removed.
private static final String FIND_BY_ID =
"SELECT id, customer_id, total FROM orders WHERE id = ?";
public Optional<Order> findById(long id) {
// queryForObject throws if there is no row, so a list keeps it simple.
return jdbcTemplate.query(FIND_BY_ID, ORDER_MAPPER, id).stream().findFirst();
}
// The row mapper is the one piece that is genuinely about this table.
private static final RowMapper<Order> ORDER_MAPPER = (rs, rowNum) ->
new Order(rs.getLong("id"),
rs.getLong("customer_id"),
rs.getBigDecimal("total"));
The template handles connection acquisition and release, statement creation,
parameter binding, result set iteration, and exception translation. Notice that
SQLException never appears: RowMapper declares it, and the template
translates it into Spring's unchecked hierarchy, covered later in this
subchapter.
The core methods cover the operations chapter 16 introduced.
| Method | Use for |
|---|---|
query(sql, rowMapper, args...) | Many rows |
queryForObject(sql, rowMapper, args...) | Exactly one row; throws otherwise |
queryForObject(sql, Long.class, args...) | A single scalar such as a count |
queryForList(sql, args...) | Rows as List<Map<String, Object>> |
update(sql, args...) | Insert, update, delete; returns count |
batchUpdate(sql, batchArgsList) | Bulk operations |
execute(sql) | DDL |
// A count, with no mapping at all.
long total = jdbcTemplate.queryForObject(
"SELECT count(*) FROM orders WHERE customer_id = ?", Long.class, customerId);
// An update: varargs become the bound parameters, in order.
int rows = jdbcTemplate.update(
"UPDATE orders SET status = ? WHERE id = ?", status.name(), orderId);
// A batch, which is 16.2's batching with the loop written for you.
jdbcTemplate.batchUpdate(
"INSERT INTO readings (sensor_id, value) VALUES (?, ?)",
readings.stream()
.map(r -> new Object[]{ r.sensorId(), r.value() })
.toList());
Generated keys need a little more code, because the key must be captured.
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
var ps = connection.prepareStatement(
"INSERT INTO orders (customer_id, total) VALUES (?, ?)",
new String[]{ "id" }); // name the key column, as in 16.2
ps.setLong(1, customerId);
ps.setBigDecimal(2, total);
return ps;
}, keyHolder);
long newId = keyHolder.getKey().longValue();
NamedParameterJdbcTemplate replaces positional ? markers with names,
which is a real readability gain once a statement has more than three
parameters and removes an entire class of ordering bugs.
String sql = """
SELECT id, customer_id, total FROM orders
WHERE customer_id = :customerId
AND placed_at > :since
AND status IN (:statuses)
""";
var params = new MapSqlParameterSource()
.addValue("customerId", customerId)
.addValue("since", cutoff)
.addValue("statuses", List.of("NEW", "PAID")); // expands the IN list
List<Order> orders = namedTemplate.query(sql, params, ORDER_MAPPER);
That last line solves 16.2's IN clause problem outright: the template
generates the right number of placeholders from the collection's size.
Where JdbcTemplate fits. It does not map object graphs, manage entity
state, or generate SQL. That is the point: you write the SQL, so you know
exactly what runs. It remains the right tool for reporting queries, bulk
operations, and anywhere an ORM's generated SQL is not good enough, and it
coexists with JPA on the same transaction.
Key Takeaways
JdbcTemplateimplements the invariant parts of a JDBC operation and leaves the SQL and mapping to you.- Connections, statements, iteration, and closing are handled, so resource leaks are impossible.
SQLExceptionis translated, so data access code throws unchecked exceptions.- A
RowMapperis the single place that knows how a row becomes an object.queryForObjectthrows when there is not exactly one row; usequeryplus a stream when zero is valid.NamedParameterJdbcTemplateimproves readability and expands collections inINclauses.- The template writes no SQL for you, which is precisely why it is predictable.
🧪 Practice
- Rewrite a plain JDBC finder with
JdbcTemplateand count the lines removed.- Write a
RowMapperfor a class with a nullable column, handlingNULLcorrectly as 16.1 described.- Call
queryForObjectfor an identifier that does not exist and read the exception, then rewrite it to return anOptional.- Use
NamedParameterJdbcTemplateto run a query with anINclause over a list of varying size.- Interview:
JdbcTemplateguarantees connections are closed. What property of its design makes that possible? (Hint: who owns thetry-with-resources block, your code or the framework's?)
Spring Transaction Management
17.2 introduced @Transactional as an aspect. This topic covers the machinery
underneath it, because knowing what a PlatformTransactionManager is explains
how JDBC and JPA code can share one transaction and why the annotation works
identically across both.
The problem Spring solves here is that transaction APIs differ. Plain JDBC uses
Connection.setAutoCommit and commit. JPA uses
EntityTransaction.begin and commit. JTA spans several resources through a
transaction manager. Code written against any one of them is tied to it.
Spring defines a single abstraction and provides implementations.
PlatformTransactionManager
(getTransaction, commit, rollback)
|
+---------------------+---------------------+
| | |
DataSourceTransactionManager JpaTransactionManager JtaTransactionManager
| | |
JDBC Connection EntityManager several resources
| Implementation | Use with |
|---|---|
DataSourceTransactionManager | JdbcTemplate, plain JDBC |
JpaTransactionManager | JPA and Hibernate |
JtaTransactionManager | Distributed, multi-resource |
R2dbcTransactionManager | Reactive data access |
@Configuration
@EnableTransactionManagement // activates the @Transactional aspect
public class TxConfig {
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
Spring Boot configures all of this automatically, choosing the manager from what is on the classpath.
The key mechanism is thread binding. When a transaction starts, the manager
binds the connection to the current thread in a ThreadLocal. Any
JdbcTemplate or EntityManager in that thread then obtains that connection
rather than borrowing a new one from the pool.
Thread handling request 42
---------------------------------------------------
@Transactional method entered
|
+-- manager borrows connection C7, autoCommit=false
+-- binds C7 to this thread (TransactionSynchronizationManager)
|
+-- OrderRepository --> JdbcTemplate --> asks for the thread's
+-- PaymentRepository --> JdbcTemplate --> connection: gets C7
|
+-- commit C7, unbind, return to the pool
This is what makes 16.3's design work without passing a Connection through
every method signature. The repositories never mention a connection, yet they
all participate in the caller's transaction.
The programmatic API is available when a boundary does not align with a method, such as when only part of a method should be transactional.
@Service
public class ImportService {
private final TransactionTemplate transactions;
public ImportService(PlatformTransactionManager manager) {
this.transactions = new TransactionTemplate(manager);
}
public void importAll(List<Record> records) {
List<Record> valid = validate(records); // outside the transaction
transactions.execute(status -> { // the boundary starts here
valid.forEach(repository::save);
if (shouldAbort()) {
status.setRollbackOnly(); // roll back without throwing
}
return null;
});
notifyExternalSystem(); // after commit, as 16.3 advised
}
}
setRollbackOnly() is the programmatic equivalent of throwing: it marks the
transaction so that it cannot commit. It is also what happens internally when an
inner REQUIRED method fails, which produces a confusing error worth
recognising.
UnexpectedRollbackException: Transaction silently rolled back because it has
been marked as rollback-only
That message means an inner transactional method threw, its exception was
caught by the caller, and the caller then tried to commit a transaction that had
already been doomed. The fix is either to let the exception propagate or to
give the inner method REQUIRES_NEW so it has a transaction of its own.
Testing transactions is where Spring's support pays off. A test annotated
@Transactional rolls back automatically at the end, so tests leave no trace in
the database.
@SpringBootTest
@Transactional // each test method rolls back afterwards
class OrderRepositoryTest {
@Test
void savesAndFinds() {
repository.save(anOrder());
assertEquals(1, repository.count());
} // rolled back: the next test sees an empty table
}
One caveat: this means the test never verifies that a commit would succeed.
Constraint violations that fire at commit time, such as deferred constraints,
will not appear. Use @Commit or a separate integration test when that matters.
Key Takeaways
PlatformTransactionManagerabstracts JDBC, JPA, and JTA behind one interface.@EnableTransactionManagementactivates the aspect; Spring Boot does it automatically.- The manager binds the connection to the thread, so all repositories in that thread share it.
- Thread binding is why repositories need no
Connectionparameter.TransactionTemplatehandles boundaries that do not match a method.setRollbackOnly()dooms a transaction without throwing.UnexpectedRollbackExceptionmeans an inner failure was swallowed by the caller.@Transactionalon a test rolls it back, at the cost of never exercising a real commit.
🧪 Practice
- Configure a
DataSourceTransactionManagerexplicitly and confirm two repositories in one service method use the same connection.- Log the connection identity from two repositories inside one transactional method and compare.
- Reproduce
UnexpectedRollbackExceptionby catching an exception from an inner@Transactionalmethod, then fix it two different ways.- Write a repository test annotated
@Transactionaland prove the database is unchanged afterwards.- Interview: How can a repository participate in a caller's transaction without receiving a
Connection? (Hint: where is the connection stored for the duration of the call?)
Spring Data JPA Repositories
16.4 described the data access object pattern: an interface expressed in domain
terms, plus an implementation that talks to the database. In practice most of
those implementations are mechanical, since findById, save, delete, and
findAll look the same for every entity.
Spring Data JPA writes the implementation for you. You declare the interface; the framework generates a proxy implementing it at startup.
public interface OrderRepository extends JpaRepository<Order, Long> {
// No implementation. None is needed.
}
That single line provides everything below.
@Service
public class OrderService {
private final OrderRepository repository; // the generated proxy
public void demo(Order order) {
Optional<Order> found = repository.findById(1L);
List<Order> all = repository.findAll();
Order saved = repository.save(order); // insert or update
long count = repository.count();
repository.deleteById(1L);
boolean exists = repository.existsById(1L);
}
}
The repository interfaces form a hierarchy, each adding capability.
Repository<T, ID> marker only
|
CrudRepository<T, ID> save, findById, findAll, delete, count
|
PagingAndSortingRepository<T, ID> findAll(Pageable), findAll(Sort)
|
JpaRepository<T, ID> flush, saveAllAndFlush, getReferenceById,
List returns instead of Iterable
Choosing a narrower interface is a real design decision: extending
CrudRepository instead of JpaRepository keeps JPA-specific methods out of
the domain-facing contract.
Paging and sorting come free, which matters because unbounded findAll on a
large table is a production incident waiting to happen.
// Page 2, 20 rows, newest first.
Pageable request = PageRequest.of(1, 20, Sort.by("placedAt").descending());
Page<Order> page = repository.findAll(request);
page.getContent(); // the 20 orders
page.getTotalElements(); // total rows, from a second COUNT query
page.getTotalPages();
page.hasNext();
Page runs an extra count query. When the total is not needed, Slice avoids
it and is meaningfully cheaper on large tables.
Enabling it requires one annotation, which Spring Boot supplies.
@Configuration
@EnableJpaRepositories("com.shop.repository") // where the interfaces live
@EntityScan("com.shop.domain") // where the entities live
public class JpaConfig { }
What it does not do. Spring Data does not remove the need to understand JPA.
Every warning from 16.4 still applies: save on a managed entity may issue no
SQL because dirty checking already did, lazy associations still throw after the
transaction ends, and findAll on a mapped entity still triggers N+1 queries if
associations are touched. The framework removes typing, not understanding.
One specific trap: save is not an insert. It calls persist for a new
entity and merge for one with an identifier, and merge issues a SELECT
first. In a bulk load that doubles the query count, so saveAll with batching
configured is the right tool.
Key Takeaways
- Spring Data JPA generates a repository implementation from an interface declaration.
JpaRepositoryprovides create, read, update, delete, counting, paging, and sorting.- The interface hierarchy lets you expose only the capability you want.
Pageincludes a total and costs an extra count query;Slicedoes not.- Entity and repository locations must be visible to the configuration.
- Every JPA behaviour from 16.4 still applies, including N+1 and lazy loading.
saveperforms a merge for entities with an identifier, which issues aSELECT.
🧪 Practice
- Declare a repository interface for an entity and use all six inherited methods without writing an implementation.
- Print the runtime class of the injected repository and identify what created it.
- Page through a table of 1,000 rows with a page size of 50 and log the SQL, noting the count query.
- Replace
PagewithSliceand compare the statements issued.- Interview: You call
saveon an entity you loaded a moment ago inside the same transaction, and noUPDATEappears in the log until commit. Why? (Hint: which 16.4 mechanism has already noticed the change?)
Derived Query Methods and Custom Queries
The generated methods cover access by identifier. Real applications query by everything else, and Spring Data offers three ways to express that, in increasing order of control.
Derived queries are parsed from the method name. Spring Data reads the name at startup, builds the query, and fails immediately if the name refers to a field that does not exist.
public interface OrderRepository extends JpaRepository<Order, Long> {
// SELECT ... WHERE customer_id = ?
List<Order> findByCustomerId(Long customerId);
// SELECT ... WHERE status = ? AND total > ?
List<Order> findByStatusAndTotalGreaterThan(OrderStatus status, BigDecimal min);
// SELECT ... WHERE placed_at BETWEEN ? AND ? ORDER BY placed_at DESC
List<Order> findByPlacedAtBetweenOrderByPlacedAtDesc(Instant from, Instant to);
// Traverses the association: WHERE customer.email = ?
List<Order> findByCustomerEmail(String email);
// SELECT count(*) WHERE status = ?
long countByStatus(OrderStatus status);
// SELECT ... WHERE reference = ? (returns at most one)
Optional<Order> findByReference(String reference);
// DELETE WHERE status = ?
@Transactional
long deleteByStatus(OrderStatus status);
}
The grammar has a fixed shape.
[find|read|get|count|exists|delete] By <Property><Operator> [And|Or] ...
[OrderBy<Property><Asc|Desc>]
findByStatusAndTotalGreaterThanOrderByPlacedAtDesc
^^^^ ^^^^^^ ^^^ ^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^
verb prop and prop + operator sorting
| Keyword | Generated condition |
|---|---|
Is, Equals | = ? |
GreaterThan, LessThan | > ?, < ? |
Between | BETWEEN ? AND ? |
Like, Containing | LIKE ?, LIKE %?% |
StartingWith | LIKE ?% |
In | IN (?) |
IsNull, IsNotNull | IS NULL, IS NOT NULL |
True, False | = true, = false |
IgnoreCase | Wraps both sides in lower() |
Derived queries are excellent up to about three conditions, after which the
method name becomes unreadable. A name like
findByStatusAndCustomerIdAndTotalGreaterThanAndPlacedAtBetween is a signal to
switch to the next option.
@Query with JPQL gives full control while keeping the query next to the
method it serves.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("""
SELECT o FROM Order o
WHERE o.customer.id = :customerId
AND o.placedAt > :since
ORDER BY o.placedAt DESC
""")
List<Order> findRecentFor(@Param("customerId") Long customerId,
@Param("since") Instant since);
// JOIN FETCH solves 16.4's N+1 problem in a declared query.
@Query("SELECT DISTINCT o FROM Order o LEFT JOIN FETCH o.lines WHERE o.id = :id")
Optional<Order> findWithLines(@Param("id") Long id);
// A projection: no entities loaded, just the columns the caller needs.
@Query("SELECT new com.shop.OrderSummary(o.id, o.total) FROM Order o")
List<OrderSummary> summaries();
// Native SQL when JPQL cannot express it.
@Query(value = "SELECT * FROM orders WHERE total > ?1 FOR UPDATE SKIP LOCKED",
nativeQuery = true)
List<Order> lockNextBatch(BigDecimal minimum);
// Modifying queries must say so, and need a transaction.
@Modifying
@Transactional
@Query("UPDATE Order o SET o.status = :status WHERE o.placedAt < :cutoff")
int expireOlderThan(@Param("status") OrderStatus status,
@Param("cutoff") Instant cutoff);
}
Two details in that block are easy to get wrong. @Modifying is required for
UPDATE and DELETE, and without it the query fails. And a modifying query
bypasses the persistence context, so entities already loaded hold stale values;
@Modifying(clearAutomatically = true) addresses that.
Projections avoid loading entities at all, which 16.4 identified as the right answer for list views. An interface with getters is the lightest form.
// Spring Data implements this at runtime from the matching entity properties.
public interface OrderSummary {
Long getId();
BigDecimal getTotal();
String getCustomerName(); // traverses customer.name
}
public interface OrderRepository extends JpaRepository<Order, Long> {
List<OrderSummary> findByStatus(OrderStatus status); // selects only 3 columns
}
Custom implementations handle anything the above cannot, such as a query built dynamically from optional filters.
public interface OrderRepositoryCustom {
List<Order> search(OrderSearchCriteria criteria);
}
// The suffix "Impl" is the convention Spring Data looks for.
public class OrderRepositoryCustomImpl implements OrderRepositoryCustom {
@PersistenceContext private EntityManager em;
@Override
public List<Order> search(OrderSearchCriteria c) {
var cb = em.getCriteriaBuilder();
var query = cb.createQuery(Order.class);
var root = query.from(Order.class);
var predicates = new ArrayList<Predicate>();
if (c.status() != null) {
predicates.add(cb.equal(root.get("status"), c.status()));
}
if (c.minimum() != null) {
predicates.add(cb.greaterThan(root.get("total"), c.minimum()));
}
query.where(predicates.toArray(Predicate[]::new)); // only what was supplied
return em.createQuery(query).getResultList();
}
}
// The main interface extends both, and the generated proxy combines them.
public interface OrderRepository
extends JpaRepository<Order, Long>, OrderRepositoryCustom { }
| Approach | Best for |
|---|---|
| Derived method | One to three simple conditions |
@Query JPQL | Complex conditions, joins, fetch plans |
@Query native | Vendor features JPQL cannot express |
| Projection | Read-only lists where full entities are wasteful |
| Custom implementation | Dynamic queries built from optional filters |
Key Takeaways
- Derived queries are parsed from method names and validated at startup.
- Keep derived names to about three conditions before switching to
@Query.@Queryholds JPQL or native SQL beside the method it implements.JOIN FETCHin a declared query is the direct fix for N+1.@Modifyingis mandatory for update and delete queries and needs a transaction.- A modifying query leaves loaded entities stale unless the context is cleared.
- Interface projections select only the columns they declare.
- Dynamic multi-filter searches belong in a custom
Implclass.
🧪 Practice
- Write derived methods for three different conditions and log the generated SQL for each.
- Misspell a property in a method name and read the startup error, noting that it is not a runtime failure.
- Replace an unreadable five-condition derived method with a
@Query.- Add a
JOIN FETCHquery and confirm with SQL logging that N+1 disappears.- Implement a search taking three optional filters using a custom implementation and the criteria builder.
- Interview: Why is a mistyped field name in a derived query method caught at startup rather than when the method is called? (Hint: when does Spring Data build the implementation?)
Data Access Exception Translation
Chapter 16 described SQLException and the SQL state codes that distinguish a
constraint violation from a connection failure. Two problems remain in practice.
It is a checked exception, so every layer must declare or handle it, and its
error codes are vendor-specific, so handling a unique violation means writing
code that only works on one database.
// Without translation: the caller imports java.sql and hard-codes a vendor code.
try {
repository.save(user);
} catch (SQLException e) {
if ("23505".equals(e.getSQLState())) { // PostgreSQL-specific
throw new DuplicateEmailException(user.email());
}
throw new RuntimeException(e);
}
Spring translates instead. Every SQLException from a template or a Spring Data
repository is converted into a subclass of DataAccessException, which is
unchecked and vendor-neutral.
DataAccessException (unchecked root)
|
+-- NonTransientDataAccessException retrying will not help
| +-- DataIntegrityViolationException
| | +-- DuplicateKeyException
| +-- BadSqlGrammarException
| +-- InvalidDataAccessApiUsageException
| +-- EmptyResultDataAccessException
| +-- IncorrectResultSizeDataAccessException
|
+-- TransientDataAccessException retrying may succeed
| +-- QueryTimeoutException
| +-- ConcurrencyFailureException
| +-- DeadlockLoserDataAccessException
| +-- OptimisticLockingFailureException
|
+-- RecoverableDataAccessException
The hierarchy mirrors 16.1's transient versus non-transient split, so the retry-or-not decision reads the same way.
// With translation: no java.sql import, no vendor codes, no checked exception.
try {
repository.save(user);
} catch (DuplicateKeyException e) { // same class on any database
throw new DuplicateEmailException(user.email(), e);
} catch (TransientDataAccessException e) {
throw new RetryableException(e); // deadlock, timeout
}
How the translation happens depends on which tool raised the exception.
| Source | Mechanism |
|---|---|
JdbcTemplate | Built in; always translates |
| Spring Data repositories | Built in; always translates |
| Your own DAO class | Only if annotated @Repository |
That last row is the practical reason @Repository exists rather than being a
synonym for @Component. It marks the class for a post-processor that adds a
translating aspect.
@Repository // enables exception translation on this class
public class LegacyOrderDao {
@PersistenceContext
private EntityManager em;
public Order find(long id) {
// A PersistenceException here is translated into a DataAccessException.
return em.find(Order.class, id);
}
}
Translation works by consulting a mapping of SQL state and vendor error codes,
loaded from sql-error-codes.xml for the detected database. When a code is not
recognised the exception becomes a generic UncategorizedSQLException rather
than being guessed at, and custom codes can be registered.
Being unchecked is a design decision worth understanding. Spring's argument
is that almost no caller can meaningfully recover from a database failure, so
forcing every method to declare throws SQLException adds noise without
benefit. The layers that can handle it, a retry wrapper or a global error
handler, catch it deliberately; everything else lets it propagate.
// Clean signature: the exception is still possible, just not in the way.
public Optional<Order> findById(long id) {
return jdbcTemplate.query(FIND_BY_ID, ORDER_MAPPER, id).stream().findFirst();
}
Two specific exceptions to know by name, since they come up constantly:
EmptyResultDataAccessException from queryForObject when no row matched, and
OptimisticLockingFailureException, which is 16.3's version-column conflict
surfacing through the same hierarchy.
Key Takeaways
- Spring converts
SQLExceptionand JPA exceptions into uncheckedDataAccessExceptionsubclasses.- The hierarchy is vendor-neutral, so a duplicate key is one class on every database.
- It splits transient from non-transient, matching 16.1's retry decision.
- Templates and Spring Data repositories translate automatically.
- A hand-written DAO translates only when annotated
@Repository.- Unrecognised vendor codes become
UncategorizedSQLExceptionrather than a guess.- Unchecked exceptions keep signatures clean, on the argument that most callers cannot recover.
🧪 Practice
- Trigger a unique constraint violation through
JdbcTemplateand print the exception class.- Run the same failing insert on two different databases and confirm the Spring exception type matches.
- Write a DAO without
@Repository, observe the untranslated exception, then add the annotation.- Catch
EmptyResultDataAccessExceptionfromqueryForObjectand convert it into anOptional.- Interview: What does
@Repositorydo that@Componentdoes not? (Hint: it marks the class for a post-processor that adds one specific aspect.)
Schema Migration with Flyway or Liquibase
Application code is versioned, reviewed, and deployed as a unit. The database schema it depends on usually is not, and the gap causes a familiar class of incident: code that expects a column deployed against a database that does not have it yet.
The approaches that do not work are worth naming, because both are common.
Hibernate's automatic schema generation is the first. Setting
hibernate.hbm2ddl.auto to update makes Hibernate alter the schema to match
the entities at startup.
spring.jpa.hibernate.ddl-auto=update # convenient, and unsafe in production
It never drops or renames anything, so removed fields leave orphaned columns; it cannot migrate data, so splitting a column is impossible; and the exact statements it issues depend on the Hibernate version. It is fine for a local development database and is not a migration tool.
Hand-applied SQL scripts are the second. Someone runs the script before the deploy. This works until someone forgets, runs them out of order, or runs one twice, and there is no way to tell what a given database has had applied.
Migration tools solve it by treating the schema as an ordered, versioned sequence of scripts, and by recording in the database itself which have been applied.
src/main/resources/db/migration/
V1__create_orders.sql
V2__add_status_to_orders.sql
V3__create_order_lines.sql
V4__backfill_order_totals.sql
^ ^ ^
| version description
prefix
On startup Flyway:
1. reads the flyway_schema_history table
2. finds V1..V3 applied, V4 not
3. applies V4 inside a transaction
4. records V4 with a checksum
-- V2__add_status_to_orders.sql
ALTER TABLE orders ADD COLUMN status VARCHAR(20);
-- Backfill before adding the constraint, or existing rows violate it.
UPDATE orders SET status = 'COMPLETED' WHERE status IS NULL;
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;
Adding Flyway to a Spring Boot application is a dependency and a directory; it runs automatically before the application context finishes starting, so the schema is always correct before any query runs.
| Aspect | Flyway | Liquibase |
|---|---|---|
| Migration format | Plain SQL, or Java for logic | XML, YAML, JSON, or SQL |
| Database-agnostic | No, you write vendor SQL | Yes, abstract change types |
| Rollback | Paid feature | Built in |
| Learning curve | Minimal if you know SQL | Steeper, a new vocabulary |
| Best when | One database, SQL-fluent team | Several databases must be supported |
Most teams on a single database choose Flyway for the simplicity of writing the SQL that will actually run.
The rules that make migrations safe are few and absolute.
Never modify an applied migration. The tool stores a checksum, so editing
V2 after it has run fails the next startup with a validation error. That
failure is the feature: it means one version number always describes one schema.
Fix problems by adding V5.
One logical change per migration, so a failure leaves an understandable state and a review covers one idea.
Make migrations backward compatible when deploys are rolling. During a rolling deploy, old and new code run against one database at the same time, so a dropped column breaks the instances that have not been replaced yet. The standard technique is to expand, migrate, then contract across separate releases.
Release 1 add the new column, write to both, read the old (expand)
Release 2 backfill, read the new column
Release 3 stop writing the old column
Release 4 drop the old column (contract)
Test migrations against a copy of production data, because a statement that takes ten milliseconds on an empty table can lock a large table for minutes.
Key Takeaways
- Schema changes need versioning and automation as much as code does.
- Hibernate's automatic DDL cannot drop, rename, or migrate data, so it is a development convenience only.
- Migration tools apply ordered scripts once and record them in the database.
- Flyway uses plain SQL; Liquibase abstracts change types across vendors.
- Spring Boot runs migrations before the application is ready to serve requests.
- Never edit an applied migration; checksum validation will reject it.
- Rolling deploys need backward-compatible changes, split into expand and contract releases.
- Test migrations on production-sized data, since locking is the real risk.
🧪 Practice
- Add Flyway to a Spring Boot project and create two migrations, then inspect the history table.
- Edit an already-applied migration and read the validation error at startup.
- Write a migration that adds a non-null column to a populated table, handling the existing rows.
- Plan the four releases required to rename a column without breaking a rolling deploy.
- Interview: Why is editing an applied migration rejected rather than simply re-run? (Hint: what would then be true of two databases that both claim to be at version 2?)
<a id="174-spring-mvc-and-web-layer"></a>
17.4 Spring MVC and Web Layer
This subchapter covers how an HTTP request becomes a Java method call and a Java object becomes an HTTP response: the dispatcher at the centre, the controllers it routes to, the binding and validation of input, and the two mechanisms for handling errors and cross-cutting request concerns.
Front Controller Pattern and DispatcherServlet
A web application receives HTTP requests and must decide which code handles each one. The Servlet API's own answer is to map URL patterns to servlet classes, which means every endpoint needs a servlet, and every servlet repeats the same work: parse parameters, check authentication, choose a response format, handle errors.
Servlet-per-endpoint Front controller
-------------------- ----------------
/orders -> OrderServlet all requests -> DispatcherServlet
/orders/* -> OrderDetailServlet |
/customers -> CustomerServlet routing, binding, conversion,
/invoices -> InvoiceServlet error handling, all in one place
|
each repeats parsing, error v
handling, and formatting OrderController.list(...) plain method
The front controller pattern routes every request through one entry point
that performs the common work and then delegates. Spring MVC's implementation is
DispatcherServlet, and it is the only servlet a typical Spring application
has.
The request flow is worth knowing in order, because almost every "why is my endpoint not working" question resolves to one of these steps.
HTTP request
|
v
[1] DispatcherServlet receives it
|
v
[2] HandlerMapping which method handles this URL and verb?
|
v
[3] HandlerAdapter invoke it, resolving each parameter
| |
| [3a] HandlerInterceptor.preHandle
| [3b] argument resolvers: @PathVariable, @RequestBody, ...
| [3c] YOUR CONTROLLER METHOD RUNS
| [3d] HandlerInterceptor.postHandle
v
[4] Return value handling
| +-- @ResponseBody? -> HttpMessageConverter writes JSON
| +-- a view name? -> ViewResolver finds a template, renders it
v
[5] HTTP response
|
+-- on any exception: HandlerExceptionResolver (see @ControllerAdvice)
Each numbered step is a strategy interface with a default implementation you can replace, which is what makes the framework configurable without being edited.
| Component | Responsibility |
|---|---|
HandlerMapping | Map a request to a handler method |
HandlerAdapter | Invoke the handler, resolving its arguments |
HandlerExceptionResolver | Turn an exception into a response |
HttpMessageConverter | Convert between Java objects and request bodies |
ViewResolver | Resolve a view name to a template |
LocaleResolver | Determine the request's locale |
Registering the dispatcher was once a chore in web.xml, then a Java class, and
in Spring Boot it is automatic.
// Pre-Boot: register the dispatcher and point it at the configuration.
public class WebInitializer extends
AbstractAnnotationConfigDispatcherServletInitializer {
@Override protected Class<?>[] getRootConfigClasses() {
return new Class[]{ AppConfig.class }; // services, repositories
}
@Override protected Class<?>[] getServletConfigClasses() {
return new Class[]{ WebConfig.class }; // controllers, view resolvers
}
@Override protected String[] getServletMappings() {
return new String[]{ "/" }; // everything
}
}
That example shows a structure worth knowing even though Boot hides it: there are two contexts. The root context holds services and repositories; the servlet context holds web components and can see the root, but not the reverse. This prevents a service from depending on a controller.
Root ApplicationContext services, repositories, data sources
^
| visible to
|
Servlet WebApplicationContext controllers, converters, view resolvers
In Spring Boot none of this is written. spring-boot-starter-web puts an
embedded Tomcat and the dispatcher on the classpath, auto-configuration
registers it at /, and a single context is used. The value of knowing the flow
is diagnostic: a 404 means step 2 found no mapping, a 400 means step 3b failed
to bind, and a 500 with no log line usually means step 4 could not serialise the
return value.
Key Takeaways
- The front controller pattern routes every request through one entry point.
DispatcherServletis that entry point and is usually the only servlet present.- Handler mapping, argument resolution, return value handling, and exception resolution are separate strategies.
- Each strategy is an interface with a replaceable default implementation.
- Classic applications separate a root context from a servlet context, and the servlet context can see the root.
- Spring Boot configures and registers everything automatically.
- Knowing the flow turns status codes into a diagnosis of which step failed.
🧪 Practice
- Draw the request flow from the socket to a controller method and mark where JSON deserialisation happens.
- Enable
logging.level.org.springframework.web=DEBUGand read the mapping decisions for one request.- Request a URL with no mapping and one with a wrong method, and compare the two status codes.
- List every handler mapping at startup and find the one for your own controller.
- Interview: Why does the front controller pattern make a framework easier to extend than one servlet per endpoint? (Hint: where does a new cross-cutting behaviour have to be added in each design?)
Controllers and Request Mapping
A controller is an ordinary class whose methods handle requests. There is no base class and no interface; annotations do the work, which is why controller methods can be called directly in tests.
@RestController // @Controller + @ResponseBody
@RequestMapping("/api/orders") // a prefix for every method below
public class OrderController {
private final OrderService service;
public OrderController(OrderService service) { // constructor injection
this.service = service;
}
@GetMapping // GET /api/orders
public List<OrderResponse> list() {
return service.findAll().stream().map(OrderResponse::from).toList();
}
@GetMapping("/{id}") // GET /api/orders/42
public OrderResponse get(@PathVariable long id) { // 42 binds to id
return OrderResponse.from(service.findById(id));
}
@PostMapping // POST /api/orders
@ResponseStatus(HttpStatus.CREATED) // 201 instead of 200
public OrderResponse create(@RequestBody @Valid CreateOrderRequest request) {
return OrderResponse.from(service.place(request.toOrder()));
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 204, empty body
public void delete(@PathVariable long id) {
service.delete(id);
}
}
The HTTP-method-specific annotations are shorthand for @RequestMapping with a
method attribute, and they are clearer.
| Shorthand | Equivalent |
|---|---|
@GetMapping | @RequestMapping(method = RequestMethod.GET) |
@PostMapping | @RequestMapping(method = RequestMethod.POST) |
@PutMapping | @RequestMapping(method = RequestMethod.PUT) |
@PatchMapping | @RequestMapping(method = RequestMethod.PATCH) |
@DeleteMapping | @RequestMapping(method = RequestMethod.DELETE) |
Mappings can narrow on more than path and method, which is how one URL serves several representations or API versions.
@PostMapping(
value = "/import",
consumes = MediaType.APPLICATION_JSON_VALUE, // only if Content-Type matches
produces = MediaType.APPLICATION_JSON_VALUE, // sets the response type
headers = "X-Api-Version=2", // only for version 2 clients
params = "mode=async") // only when ?mode=async present
public ImportResult importAsync(@RequestBody ImportRequest request) { ... }
A request that matches the path but not consumes produces 415, and one that
matches but not produces produces 406. Recognising those two codes saves time.
Path variables support patterns, which matters when a segment can contain characters that would otherwise be treated as a delimiter.
@GetMapping("/{id}") // one segment
@GetMapping("/{year}/{month}") // several
@GetMapping("/files/{*path}") // captures the remainder, including /
@GetMapping("/{id:[0-9]+}") // regex: digits only, so /orders/new
// can map to a different method
Method parameters are resolved by type and annotation, and the range of supported types is wide.
@GetMapping("/search")
public List<OrderResponse> search(
@RequestParam String status, // ?status=NEW, required
@RequestParam(defaultValue = "0") int page, // optional with default
@RequestParam(required = false) BigDecimal minimum, // optional, may be null
@RequestHeader("X-Tenant") String tenant, // a header
@CookieValue(value = "session", required = false) String session,
Pageable pageable, // Spring Data builds it
Locale locale, // from Accept-Language
HttpServletRequest raw) { ... } // the escape hatch
Return values are handled by type too.
| Return type | Result |
|---|---|
| An object | Serialised to the negotiated format, status 200 |
void | Empty body, status 200 or the @ResponseStatus |
ResponseEntity<T> | Full control over status, headers, and body |
String (@Controller) | A view name to resolve and render |
Optional<T> | 200 with the value, or 404 when empty |
CompletableFuture<T> | Asynchronous; the servlet thread is released |
ResponseEntity is the right choice when the status depends on the outcome.
@PostMapping
public ResponseEntity<OrderResponse> create(@RequestBody @Valid CreateOrderRequest r) {
Order order = service.place(r.toOrder());
URI location = URI.create("/api/orders/" + order.id());
return ResponseEntity.created(location) // 201 plus a Location header
.body(OrderResponse.from(order));
}
Keep controllers thin. A controller's job is to translate between HTTP and the domain: bind input, call one service method, map the result to a response. Business logic, transactions, and database access belong below it. A controller that reads well is usually three to five lines per method.
Use separate request and response types rather than exposing entities directly. An entity exposed as a response leaks the schema, serialises lazy associations at the worst possible moment, and lets a client set fields it should not.
// A request type accepts only what a client may set.
public record CreateOrderRequest(@NotNull Long customerId,
@NotEmpty List<LineRequest> lines) {
public Order toOrder() { ... }
}
// A response type exposes only what a client should see.
public record OrderResponse(long id, String status, BigDecimal total) {
public static OrderResponse from(Order order) { ... }
}
Key Takeaways
- Controllers are plain classes; annotations define the mapping.
@RestControllercombines@Controllerwith@ResponseBody.- Method-specific shorthand annotations are clearer than
@RequestMappingwith attributes.- Mappings can also narrow on content type, headers, and parameters, giving 415 and 406 respectively when they do not match.
- Parameters are resolved by annotation and type, covering path, query, header, cookie, and body.
ResponseEntityis for when status or headers depend on the outcome.- Controllers should translate and delegate, not contain logic.
- Dedicated request and response types keep entities out of the API contract.
🧪 Practice
- Build a controller with the five standard REST endpoints for one resource, returning correct status codes.
- Add a regex path variable so that a literal path segment and a numeric identifier map to different methods.
- Trigger 404, 405, 415, and 406 deliberately and note what each one tells you about the mapping.
- Return
ResponseEntitywith aLocationheader from a creation endpoint and verify it.- Expose an entity with a lazy association directly and observe the serialisation failure, then fix it with a response type.
- Interview: Why should a controller return a dedicated response type rather than a JPA entity? (Hint: name one security consequence and one from 16.4.)
Request Binding and Bean Validation
Everything arriving in an HTTP request is text. A path segment is a string, a query parameter is a string, a JSON body is a byte stream. Turning that into typed Java objects is binding, and confirming it makes sense is validation. They are separate steps with separate failure modes.
Binding happens before the controller method runs.
GET /api/orders/42?status=NEW&since=2026-01-01
"42" -> @PathVariable long id -> 42L
"NEW" -> @RequestParam OrderStatus -> OrderStatus.NEW
"2026-01-01" -> @RequestParam LocalDate since -> LocalDate
JSON body -> @RequestBody CreateOrderRequest -> object, via Jackson
Simple types are converted by Spring's conversion service; the request body is
deserialised by an HttpMessageConverter, which for JSON means Jackson. A
failure at this stage is a 400 before your code is reached, which is why a
malformed body never produces a NullPointerException in the controller.
Complex parameters can bind without an annotation, which is how HTML form submissions and multi-parameter queries are handled.
// Query parameters bind to the record's components by name.
public record OrderSearch(OrderStatus status, LocalDate from, LocalDate to) { }
@GetMapping("/search")
public List<OrderResponse> search(OrderSearch criteria) { ... }
// GET /api/orders/search?status=NEW&from=2026-01-01
Custom converters handle domain types, so a controller can take a value object rather than a raw string.
@Component
public class StringToOrderReferenceConverter
implements Converter<String, OrderReference> {
@Override
public OrderReference convert(String source) {
return OrderReference.parse(source); // throws on invalid input -> 400
}
}
@GetMapping("/{reference}")
public OrderResponse get(@PathVariable OrderReference reference) { ... }
Validation is a separate concern: the input parsed correctly, but is it
acceptable? Bean Validation, the Jakarta standard, expresses the rules as
annotations on the type rather than as if statements in the controller.
public record CreateOrderRequest(
@NotNull(message = "customerId is required")
Long customerId,
@NotEmpty(message = "an order needs at least one line")
@Size(max = 100, message = "at most 100 lines per order")
@Valid // cascade into each element
List<LineRequest> lines,
@Email
String notificationEmail, // null is valid; @Email allows it
@Future(message = "delivery must be in the future")
LocalDate requestedDelivery) { }
public record LineRequest(
@NotNull Long productId,
@Positive int quantity,
@DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice) { }
| Annotation | Checks |
|---|---|
@NotNull | Not null; empty string passes |
@NotEmpty | Not null and not empty |
@NotBlank | Not null and contains non-whitespace |
@Size(min, max) | Length or collection size |
@Min, @Max, @Positive | Numeric bounds |
@Email, @Pattern | String format |
@Past, @Future | Temporal bounds |
@Valid | Cascade validation into a nested object |
The distinction between @NotNull, @NotEmpty, and @NotBlank is a common
source of bugs: @NotNull accepts "" and " ", so a required text field
almost always wants @NotBlank.
Triggering validation requires @Valid on the parameter. Without it the
annotations are inert, which is the most common reason validation "does not
work".
@PostMapping
public OrderResponse create(@RequestBody @Valid CreateOrderRequest request) {
// Reaching this line guarantees every rule above passed.
return OrderResponse.from(service.place(request.toOrder()));
}
A failure throws MethodArgumentNotValidException, which by default produces a
400 with a body that exposes internal structure. Real APIs handle it explicitly,
which the next topic covers.
Validating method parameters directly needs @Validated on the class,
because those constraints are enforced by an aspect rather than by the binder.
@RestController
@Validated // enables parameter-level validation
public class OrderController {
@GetMapping("/search")
public List<OrderResponse> search(
@RequestParam @Size(min = 3) String query, // enforced by an aspect
@RequestParam @Min(1) @Max(100) int limit) { ... }
}
Custom constraints keep domain rules declarative instead of scattering checks.
@Constraint(validatedBy = ValidReferenceValidator.class)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidReference {
String message() default "malformed order reference";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class ValidReferenceValidator
implements ConstraintValidator<ValidReference, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return value == null || value.matches("ORD-\\d{8}"); // null is @NotNull's job
}
}
Letting null pass is the convention: each annotation should check one thing, and
nullability is @NotNull's responsibility.
Validate at the boundary, and again where it matters. Bean Validation on a request type protects against malformed input. It does not replace domain invariants, which belong in the domain objects themselves, because a service called from a scheduled job never passes through a controller.
Key Takeaways
- Binding converts request text into typed objects before the controller runs.
- Binding failures produce 400 responses without reaching your code.
- Complex objects bind from query parameters by component name, with no annotation.
- Custom
Converterbeans let controllers accept domain types directly.- Bean Validation expresses rules as annotations on the type.
@NotNullpermits empty and blank strings; text fields usually want@NotBlank.- Rules are inert without
@Validon the parameter, and@Validalso cascades into nested objects.- Method parameter constraints need
@Validatedon the class.- Request validation complements, but does not replace, domain invariants.
🧪 Practice
- Bind a query string to a record with three components and confirm no annotation is needed.
- Write a custom
Converterfor a domain identifier type and use it as a path variable.- Annotate a request type with five constraints, submit invalid input, and inspect the default error response.
- Demonstrate the difference between
@NotNull,@NotEmpty, and@NotBlankwith three inputs.- Remove
@Validfrom a handler and show that the constraints stop being enforced.- Interview: A team reports that their validation annotations are ignored on one endpoint. What are the two most likely causes? (Hint: one concerns the parameter, one concerns where the constraints are declared.)
REST Controllers and Content Negotiation
REST is an architectural style, not a framework feature, and applying it well is
mostly about using HTTP as it was designed rather than tunnelling everything
through POST /doThing.
Four principles do most of the work.
Resources are nouns, identified by URLs. The verb belongs to the HTTP method.
| Operation | RESTful | Not RESTful |
|---|---|---|
| List orders | GET /orders | GET /getOrders |
| Fetch order 42 | GET /orders/42 | GET /order?action=get&id=42 |
| Create an order | POST /orders | POST /createOrder |
| Replace order 42 | PUT /orders/42 | POST /updateOrder |
| Cancel order 42 | POST /orders/42/cancel | POST /cancelOrder |
The last row shows the pragmatic exception: an operation that is genuinely an action rather than a state replacement is modelled as a sub-resource.
Methods have defined semantics, and clients, proxies, and retry logic rely on them.
| Method | Safe | Idempotent | Meaning |
|---|---|---|---|
GET | Yes | Yes | Retrieve; must not change state |
POST | No | No | Create, or a non-idempotent action |
PUT | No | Yes | Replace the resource entirely |
PATCH | No | No | Apply a partial change |
DELETE | No | Yes | Remove the resource |
Idempotence is what allows a client to retry a failed PUT safely. A POST
that creates a resource cannot be retried blindly, which is why creation
endpoints often accept an idempotency key.
Status codes carry the outcome, so clients do not have to parse the body.
| Code | Use for |
|---|---|
| 200 | Success with a body |
| 201 | Created, with a Location header |
| 204 | Success with no body, typical for DELETE |
| 400 | Malformed or invalid request |
| 401 | Not authenticated |
| 403 | Authenticated but not permitted |
| 404 | No such resource |
| 409 | Conflict, such as an optimistic locking failure |
| 422 | Well-formed but semantically invalid |
| 500 | A server fault the client cannot fix |
Returning 200 with {"error": "not found"} is the anti-pattern this table
exists to prevent: it forces every client to inspect the body to know whether
the call worked.
Content negotiation lets one endpoint serve several representations. The
client states what it wants with Accept; the server states what it sent with
Content-Type.
Request Response
------- --------
GET /api/orders/42 200 OK
Accept: application/json Content-Type: application/json
{"id":42,...}
GET /api/orders/42 200 OK
Accept: application/xml Content-Type: application/xml
<order><id>42</id>...</order>
GET /api/orders/42 406 Not Acceptable
Accept: application/pdf (no converter produces PDF)
Spring performs this through HttpMessageConverter implementations, selected by
what is on the classpath and what the request asked for.
@GetMapping(value = "/{id}",
produces = { MediaType.APPLICATION_JSON_VALUE,
MediaType.APPLICATION_XML_VALUE })
public OrderResponse get(@PathVariable long id) {
return OrderResponse.from(service.findById(id)); // one method, two formats
}
Jackson controls the JSON representation, and a few annotations cover most needs.
public record OrderResponse(
long id,
@JsonProperty("order_status") // rename in JSON
String status,
@JsonFormat(shape = JsonFormat.Shape.STRING,
pattern = "yyyy-MM-dd'T'HH:mm:ssXXX")
OffsetDateTime placedAt,
@JsonInclude(JsonInclude.Include.NON_NULL) // omit when null
String cancellationReason) { }
Global settings are usually better than per-field annotations for consistency.
spring.jackson.property-naming-strategy=SNAKE_CASE
spring.jackson.default-property-inclusion=non_null
spring.jackson.serialization.write-dates-as-timestamps=false
Versioning becomes necessary as soon as a client you do not control depends on the API. Three approaches are common.
| Approach | Example | Trade-off |
|---|---|---|
| URL path | /api/v2/orders | Obvious, but duplicates routes |
| Header | X-Api-Version: 2 | Clean URLs, harder to test by hand |
| Media type | Accept: application/vnd.shop.v2+json | Purest REST, least familiar |
@GetMapping(value = "/{id}", headers = "X-Api-Version=2")
public OrderResponseV2 getV2(@PathVariable long id) { ... }
Path versioning wins in practice because it is visible in logs and trivially testable from a browser or a command line.
Key Takeaways
- REST models resources as nouns and uses HTTP methods as the verbs.
- Safety and idempotence determine what a client may retry.
- Status codes communicate the outcome, so a body should never be the only signal.
- Content negotiation uses
AcceptandContent-Typewith message converters.- An unsupported
Acceptyields 406; an unsupportedContent-Typeyields 415.- Jackson annotations shape JSON, but global configuration gives consistency.
- Versioning by URL path is the most practical of the three common schemes.
🧪 Practice
- Design the URLs and methods for a resource with list, fetch, create, replace, delete, and one action endpoint.
- Return the correct status code from each endpoint and verify them with a command-line client.
- Add the XML converter dependency and serve the same endpoint as JSON and XML by varying
Accept.- Configure snake case globally and confirm the change without editing any response type.
- Add a second version of one endpoint behind a header and route between them.
- Interview: Why does it matter that
PUTis idempotent butPOSTis not? (Hint: think about a client whose connection drops before the response arrives.)
Centralized Error Handling with @ControllerAdvice
Without deliberate error handling, every controller method grows a try/catch
block that maps exceptions onto status codes, duplicating logic and drifting out
of sync between endpoints.
// The problem: this block, repeated in every method of every controller.
@GetMapping("/{id}")
public ResponseEntity<?> get(@PathVariable long id) {
try {
return ResponseEntity.ok(service.findById(id));
} catch (OrderNotFoundException e) {
return ResponseEntity.status(404).body(Map.of("error", e.getMessage()));
} catch (AccessDeniedException e) {
return ResponseEntity.status(403).body(Map.of("error", "forbidden"));
} catch (Exception e) {
log.error("unexpected", e);
return ResponseEntity.status(500).body(Map.of("error", "internal"));
}
}
@ControllerAdvice moves all of it into one class whose handlers apply across
every controller. It is the same idea as the aspects of 17.2: the concern is
cross-cutting, so it lives in one place.
@RestControllerAdvice // @ControllerAdvice + @ResponseBody
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(OrderNotFoundException e) {
return new ErrorResponse("ORDER_NOT_FOUND", e.getMessage());
}
// Validation failures: turn the framework exception into a useful body.
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ValidationErrorResponse handleValidation(MethodArgumentNotValidException e) {
List<FieldError> errors = e.getBindingResult().getFieldErrors().stream()
.map(fe -> new FieldError(fe.getField(), fe.getDefaultMessage()))
.toList();
return new ValidationErrorResponse("VALIDATION_FAILED", errors);
}
// 17.3's translated exceptions map cleanly onto status codes.
@ExceptionHandler(OptimisticLockingFailureException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ErrorResponse handleConflict(OptimisticLockingFailureException e) {
return new ErrorResponse("CONFLICT", "the record changed, please retry");
}
@ExceptionHandler(DuplicateKeyException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ErrorResponse handleDuplicate(DuplicateKeyException e) {
return new ErrorResponse("DUPLICATE", "that value already exists");
}
// The catch-all. Log the detail; return none of it.
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleUnexpected(Exception e) {
String id = UUID.randomUUID().toString();
log.error("unhandled error {}", id, e); // full detail in the log
return new ErrorResponse("INTERNAL_ERROR",
"unexpected error, reference " + id); // an id, not a stack trace
}
}
That last handler contains the most important rule in this topic: never return exception details to a client. A stack trace names your classes, framework versions, and sometimes SQL, all of which help an attacker. Log the detail with a correlation identifier and return the identifier.
Handler selection is by exception type, most specific first, so a handler
for Exception does not shadow one for OrderNotFoundException.
thrown: OrderNotFoundException
|
+-- handler for OrderNotFoundException ? yes -> use it
+-- handler for RuntimeException ?
+-- handler for Exception ?
Advice can be scoped rather than global, which matters when an API and a server-rendered UI need different error representations.
@RestControllerAdvice(basePackages = "com.shop.api") // JSON errors
public class ApiExceptionHandler { }
@ControllerAdvice(basePackages = "com.shop.web") // rendered pages
public class WebExceptionHandler { }
@RestControllerAdvice(assignableTypes = OrderController.class) // one controller
public class OrderExceptionHandler { }
A standard error format is worth adopting, and RFC 7807 problem details is the one Spring supports directly.
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handle(OrderNotFoundException e) {
var problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND,
e.getMessage());
problem.setTitle("Order not found");
problem.setType(URI.create("https://api.shop.com/errors/order-not-found"));
problem.setProperty("orderId", e.orderId()); // domain-specific field
return problem;
}
{
"type": "https://api.shop.com/errors/order-not-found",
"title": "Order not found",
"status": 404,
"detail": "No order with id 42",
"orderId": 42
}
@ControllerAdvice also handles two other cross-cutting jobs, which is
worth knowing because they appear in older code.
@ControllerAdvice
public class GlobalModelAdvice {
@ModelAttribute("supportedCurrencies") // added to every model
public List<String> currencies() { return List.of("EUR", "USD"); }
@InitBinder
public void configure(WebDataBinder binder) {
binder.setDisallowedFields("id"); // never bind an id from input
}
}
Finally, exceptions that map directly onto a status with no extra logic need no handler at all.
@ResponseStatus(HttpStatus.NOT_FOUND) // mapped wherever it is thrown
public class OrderNotFoundException extends RuntimeException { }
Key Takeaways
@ControllerAdvicecentralises exception handling across controllers.@RestControllerAdviceadds@ResponseBodyfor JSON error responses.- Handlers are selected by exception type, most specific first.
- Never expose stack traces or messages from unexpected exceptions; log with a correlation id.
- Handling
MethodArgumentNotValidExceptionturns validation failures into a usable body.- Translated data access exceptions map naturally onto 409 and similar codes.
- Advice can be scoped by package, annotation, or controller type.
ProblemDetailprovides a standard error format without inventing one.@ResponseStatuson an exception class covers the simplest cases.
🧪 Practice
- Write a global handler covering not-found, validation, and unexpected errors, returning a consistent body.
- Convert
MethodArgumentNotValidExceptioninto a response listing each invalid field and its message.- Throw a runtime exception from a controller and confirm no stack trace reaches the client while the log has one.
- Return
ProblemDetailfrom one handler and compare the body with your own error type.- Create two advice classes scoped to different packages and verify each handles only its own.
- Interview: Why should an unexpected exception return a correlation identifier rather than its message? (Hint: who reads the response, and what might the message contain?)
Filters, Interceptors, and Request Lifecycle
Some concerns apply to every request regardless of which controller handles it: authentication, request logging, correlation identifiers, CORS headers, compression. Spring offers two places to put them, and choosing correctly requires knowing where each sits in the request's journey.
HTTP request
|
v
[ Servlet Filter chain ] servlet-level, sees raw request/response
| runs even when no handler matches
v
[ DispatcherServlet ]
|
v
[ HandlerInterceptor.preHandle ] Spring-level, knows the handler method
|
v
[ argument resolution, controller method, return value handling ]
|
v
[ HandlerInterceptor.postHandle ] before the view renders
|
v
[ view rendering or body conversion ]
|
v
[ HandlerInterceptor.afterCompletion ]
|
v
[ Filter chain unwinds ]
|
v
HTTP response
A filter is part of the Servlet API. It sees the raw request and response, runs before Spring's dispatcher, and can wrap or replace either.
@Component
@Order(1) // filter chain order
public class CorrelationIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
String id = Optional.ofNullable(request.getHeader("X-Correlation-Id"))
.orElse(UUID.randomUUID().toString());
MDC.put("correlationId", id); // every log line in this request
response.setHeader("X-Correlation-Id", id);
try {
chain.doFilter(request, response); // continue; omitting this blocks it
} finally {
MDC.clear(); // the thread is reused: always clear
}
}
}
Extending OncePerRequestFilter rather than implementing Filter guarantees
the logic runs once even when the request is forwarded internally.
An interceptor is Spring's own, and its advantage is context: preHandle
receives the handler that is about to run, so it can read the controller
method's annotations.
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
if (handler instanceof HandlerMethod method) {
// The interceptor can inspect the target method's annotations.
RateLimited limit = method.getMethodAnnotation(RateLimited.class);
if (limit != null && exceeded(request, limit.perMinute())) {
response.setStatus(429);
return false; // false stops the request here
}
}
return true; // true continues to the controller
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
// Runs even when the handler threw; the right place for cleanup.
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final RateLimitInterceptor rateLimit;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimit)
.addPathPatterns("/api/**") // where it applies
.excludePathPatterns("/api/health"); // and where it does not
}
}
| Aspect | Filter | Interceptor |
|---|---|---|
| Defined by | Servlet API | Spring MVC |
| Runs for unmapped requests | Yes | No |
| Knows the handler method | No | Yes |
| Can modify the body stream | Yes, by wrapping | Not directly |
| Access to Spring beans | Yes, if a bean itself | Yes |
| Best for | Security, logging, CORS, compression | Business-aware checks, timing, model work |
The decision rule is short: if it needs to know which controller method will run, use an interceptor; otherwise use a filter.
WebMvcConfigurer is the general customisation hook and covers more than
interceptors.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://shop.example.com") // never "*" with credentials
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowCredentials(true)
.maxAge(3600);
}
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new StringToOrderReferenceConverter());
}
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer c) {
c.defaultContentType(MediaType.APPLICATION_JSON);
}
}
Implementing the interface adds to Boot's auto-configuration. Annotating the
class @EnableWebMvc replaces it, which switches off content negotiation
defaults, static resource handling, and message converter setup. That is almost
never what you want in a Boot application, and it is a common way to break one.
One caution about request bodies in filters: the body is a stream and can be
read once. A filter that logs the body consumes it, and the controller then
receives nothing. Wrapping the request with ContentCachingRequestWrapper is
the supported way to read it twice, at the cost of buffering.
Key Takeaways
- Filters are servlet-level, run outside the dispatcher, and see every request.
- Interceptors are Spring-level and know which handler method will run.
preHandlereturning false stops the request before the controller.afterCompletionruns even when the handler threw, so cleanup belongs there.- Thread-local state such as logging context must be cleared, since threads are pooled.
OncePerRequestFilterprevents duplicate execution on internal forwards.WebMvcConfigurercustomises CORS, converters, and negotiation without replacing defaults.@EnableWebMvcdisables Boot's web auto-configuration entirely.- A request body can be read only once unless the request is wrapped.
🧪 Practice
- Write a filter that adds a correlation identifier to every response and to the logging context.
- Write an interceptor that rejects requests missing a header, and confirm it does not run for an unmapped URL.
- Implement timing in both a filter and an interceptor, and explain why the two measurements differ.
- Configure CORS for one path prefix and verify the preflight response.
- Log a request body in a filter, observe the controller receiving an empty body, then fix it with a caching wrapper.
- Interview: You need to reject requests to methods annotated
@AdminOnly. Filter or interceptor, and why? (Hint: at which point does the framework know which method will handle the request?)
<a id="175-core-spring-annotations"></a>
17.5 Core Spring Annotations
This subchapter consolidates the annotations the rest of the chapter introduced, organised by what they do rather than by where they appeared, and finishes by showing how Spring's own annotations are built so you can compose your own.
Stereotype Annotations
A stereotype marks a class as a container-managed bean and says what kind of role it plays. Spring finds them during component scanning, described in 17.1.
The defining fact about them is that they are all @Component underneath.
// This is Spring's actual definition, simplified.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component // <-- @Service IS a @Component
public @interface Service {
@AliasFor(annotation = Component.class)
String value() default "";
}
Spring treats an annotation carrying @Component anywhere in its own annotation
tree as a component. This is meta-annotation support, and it is the
mechanism behind the whole hierarchy.
@Component any Spring-managed bean
|
+-- @Service business logic; no added behaviour
|
+-- @Repository data access; ADDS exception translation
|
+-- @Controller web handler; methods return view names
| |
| +-- @RestController @Controller + @ResponseBody
|
+-- @Configuration declares @Bean methods; ADDS proxying
| Annotation | Layer | Behaviour beyond registration |
|---|---|---|
@Component | Any | None |
@Service | Business logic | None; communicates intent |
@Repository | Data access | Exception translation, as 17.3 described |
@Controller | Web | Enables request mapping and view resolution |
@RestController | Web, REST | As @Controller, plus @ResponseBody on every method |
@Configuration | Configuration | Full-mode proxying, as 17.1 described |
The two rows with real behaviour are the ones to remember. Using @Component
where @Repository belongs silently loses exception translation, and using
@Component instead of @Configuration silently loses inter-bean singleton
semantics.
@Service // intent: business logic
public class OrderService {
private final OrderRepository repository; // constructor injection, 17.1
public OrderService(OrderRepository repository) {
this.repository = repository;
}
@Transactional // the aspect from 17.2
public Order place(Order order) {
return repository.save(order);
}
}
@Repository // intent AND exception translation
public class JdbcOrderRepository implements OrderRepository {
private final JdbcTemplate jdbcTemplate; // the template from 17.3
...
}
@RestController // intent AND @ResponseBody
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService service;
...
}
Naming. A stereotype's value sets the bean name, overriding the default
decapitalised class name.
@Service("legacyPricing") // bean name: legacyPricing
public class PricingServiceV1 implements PricingService { }
Why bother distinguishing them when three of the five behave identically?
Three reasons hold up. The layer is visible at a glance, which helps navigation.
Pointcuts can target a stereotype, so within(@org.springframework.stereotype. Service *) selects every service. And architecture tests can assert the
layering rules, such as no repository depending on a controller.
// An ArchUnit rule that enforces the layering the stereotypes declare.
@ArchTest
static final ArchRule repositoriesDoNotDependOnControllers =
noClasses().that().areAnnotatedWith(Repository.class)
.should().dependOnClassesThat()
.areAnnotatedWith(Controller.class);
Key Takeaways
- Every stereotype is a meta-annotated
@Component, which is how scanning finds them.@Serviceand@Componentare functionally identical and differ in intent.@Repositoryadds exception translation and@Configurationadds proxying.@RestControlleris@Controllerplus@ResponseBodyon every method.- The
valueattribute sets the bean name.- Correct stereotypes enable pointcuts and architecture rules that target a layer.
🧪 Practice
- Replace
@Repositorywith@Componenton a DAO and show that exception translation stops.- Write a pointcut matching every class annotated
@Serviceand log each call.- Name two beans of the same type explicitly and inject each by name.
- Interview: Which two stereotypes change behaviour rather than only documenting intent, and what does each add? (Hint: one relates to chapter 16's exceptions, one to how
@Beanmethods call each other.)
Configuration and Bean Annotations
These annotations declare what beans exist and where their settings come from. 17.1 introduced the first two; this topic covers the full set as a reference.
@Configuration and @Bean declare beans in code.
@Configuration
public class InfrastructureConfig {
@Bean // bean name: dataSource
public DataSource dataSource(DataSourceProperties props) {
var config = new HikariConfig();
config.setJdbcUrl(props.url());
config.setMaximumPoolSize(props.poolSize());
return new HikariDataSource(config);
}
@Bean(initMethod = "start", destroyMethod = "stop") // lifecycle for a
public MetricsReporter metricsReporter() { // third-party class
return new MetricsReporter();
}
@Bean
@Scope("prototype") // the scopes from 17.1
public ImportJob importJob() {
return new ImportJob();
}
}
initMethod and destroyMethod are the @Bean equivalent of @PostConstruct
and @PreDestroy, and exist because you cannot annotate a class you do not own.
Composition annotations assemble configuration from several classes, which is how a large application stays organised.
@Configuration
@Import({ InfrastructureConfig.class, SecurityConfig.class }) // explicit
@ComponentScan(basePackages = "com.shop") // by scanning
@PropertySource("classpath:application.properties") // external values
@EnableTransactionManagement // 17.3's aspect
@EnableAspectJAutoProxy // 17.2's proxies
@EnableJpaRepositories("com.shop.repository") // 17.3's repositories
@EnableCaching // 17.2's cache aspect
@EnableScheduling // @Scheduled support
public class ApplicationConfig { }
The @Enable* family follows one pattern: each imports the configuration that
registers the beans for a feature. Spring Boot's auto-configuration applies most
of them for you based on the classpath.
| Annotation | Registers |
|---|---|
@EnableTransactionManagement | The @Transactional aspect |
@EnableAspectJAutoProxy | The proxy creator for @Aspect beans |
@EnableCaching | The caching aspect |
@EnableScheduling | The @Scheduled task scheduler |
@EnableAsync | The @Async executor |
@EnableWebMvc | Full MVC configuration, replacing Boot's |
@EnableJpaRepositories | Repository proxy generation |
Property injection brings external configuration into beans.
@Component
public class PaymentGateway {
private final String endpoint;
private final Duration timeout;
public PaymentGateway(
@Value("${payment.endpoint}") String endpoint, // required
@Value("${payment.timeout:5s}") Duration timeout) { // default
this.endpoint = endpoint;
this.timeout = timeout; // "5s" is converted to a Duration
}
}
@Value supports defaults after a colon, Spring Expression Language, and type
conversion. It becomes unwieldy past a few properties, at which point
@ConfigurationProperties binds a whole group to a typed object.
@ConfigurationProperties(prefix = "payment") // binds payment.* to these fields
@Validated // 17.4's Bean Validation, at startup
public record PaymentProperties(
@NotBlank String endpoint,
@NotNull Duration timeout,
@Min(1) @Max(10) int retries) { }
payment.endpoint=https://pay.example.com
payment.timeout=5s
payment.retries=3
This is the better choice for any group of related settings: it is typed, validated at startup rather than at first use, and discoverable in one place.
@DependsOn forces an initialisation order when one bean has a side effect
another relies on and there is no injection between them.
@Bean
@DependsOn("flywayMigration") // ensure the schema exists first
public CacheWarmer cacheWarmer(OrderRepository repository) { ... }
Needing it is usually a sign that a dependency should be explicit instead.
@Lazy defers creation until first use, which contradicts the fail-fast
startup that 17.1 described as a feature. Use it for expensive beans that are
rarely needed, not as a way to silence a circular dependency.
@Bean
@Lazy // created on first injection or getBean
public ExpensiveReportEngine reportEngine() { ... }
Key Takeaways
@Configurationwith@Beanmethods declares beans in code, with full logic available.initMethodanddestroyMethodprovide lifecycle hooks for third-party classes.@Import,@ComponentScan, and@PropertySourceassemble configuration from parts.- The
@Enable*family each imports the configuration for one feature.@Valueinjects single properties with defaults and type conversion.@ConfigurationPropertiesbinds a group to a typed, validated object and scales better.@DependsOnforces ordering and usually signals a missing explicit dependency.@Lazydefers creation and trades away fail-fast startup.
🧪 Practice
- Declare a third-party class as a bean with an init and a destroy method, and log both.
- Inject the same setting with
@Valueand with@ConfigurationProperties, then compare what happens when it is missing.- Add
@Validatedto a properties record and start the application with an invalid value.- Split one configuration class into three and compose them with
@Import.- Interview: When would you prefer
@ConfigurationPropertiesover several@Valueannotations? (Hint: think about when an invalid value is discovered.)
Injection and Wiring Annotations
These annotations control how the container satisfies a dependency. 17.1 covered the mechanics; this topic is the reference, including the standard Java alternatives you will meet in mixed codebases.
@Autowired requests injection by type. Modern code uses it rarely, because
a single constructor is injected automatically.
@Service
public class OrderService {
// No @Autowired needed: one constructor means Spring uses it.
public OrderService(OrderRepository repository) { ... }
}
@Service
public class ReportService {
// With several constructors, one must be marked.
@Autowired
public ReportService(OrderRepository repository) { ... }
public ReportService() { } // for tests, not for the container
}
// Optional dependencies: three ways to express the same thing.
@Autowired(required = false)
private MetricsCollector metrics; // null when absent
public OrderService(Optional<MetricsCollector> metrics) { ... } // preferred
public OrderService(ObjectProvider<MetricsCollector> metrics) {
this.metrics = metrics.getIfAvailable(NoOpCollector::new); // with a fallback
}
@Qualifier and @Primary resolve ambiguity, as 17.1 detailed.
@Component @Primary public class EmailSender implements NotificationSender { }
@Component public class SmsSender implements NotificationSender { }
@Service
public class AlertService {
public AlertService(@Qualifier("smsSender") NotificationSender sender) { ... }
}
@Value injects a value rather than a bean, and supports expressions.
@Value("${app.name}") // a property
@Value("${app.retries:3}") // with a default
@Value("#{systemProperties['user.region']}") // SpEL against system properties
@Value("#{orderProperties.maxLines * 2}") // SpEL referencing another bean
@Value("${app.allowed.origins}") // comma-separated to List<String>
private List<String> origins;
The Jakarta standard annotations are supported and behave almost identically, which matters in code that avoids Spring-specific imports.
| Spring | Jakarta standard | Difference |
|---|---|---|
@Autowired | @Inject | @Inject has no required attribute |
@Qualifier | @Named("beanName") | @Named matches by name only |
@Component | @Named | @Named on a class also registers it |
@Scope("singleton") | @Singleton | Equivalent for the default scope |
@Named // equivalent to @Component
public class OrderService {
@Inject // equivalent to @Autowired
public OrderService(@Named("smsSender") NotificationSender sender) { ... }
}
Collection injection gathers every bean of a type, which 17.1 showed as the
basis for plugin designs. The Map form is useful when the bean name carries
meaning.
@Service
public class NotificationDispatcher {
private final Map<String, NotificationSender> sendersByName;
// Keys are bean names: "emailSender", "smsSender", ...
public NotificationDispatcher(Map<String, NotificationSender> senders) {
this.sendersByName = senders;
}
public void send(String channel, Message message) {
sendersByName.get(channel + "Sender").send(message); // dispatch by name
}
}
@Lookup solves the prototype-in-singleton problem that 17.1 raised, as an
alternative to ObjectProvider.
@Service
public abstract class ImportService {
// Spring overrides this method to return a fresh prototype each call.
@Lookup
protected abstract ImportJob newJob();
public void run(File file) {
newJob().process(file); // a new instance every time
}
}
Self-injection with @Lazy is 17.2's workaround for self-invocation, and
@Lazy is what prevents the circular dependency error.
@Service
public class OrderService {
private final OrderService self;
public OrderService(@Lazy OrderService self) { this.self = self; }
}
Key Takeaways
- A class with one constructor needs no
@Autowired.Optional<T>andObjectProvider<T>express optional dependencies better thanrequired = false.@Qualifierand@Primaryare the two halves of ambiguity resolution.@Valueinjects properties and Spring Expression Language results, with defaults and conversion.@Injectand@Namedare the Jakarta equivalents and interoperate with Spring's.- Injecting a
Listgives every implementation; aMapkeys them by bean name.@Lookupreturns a fresh prototype from a singleton withoutObjectProvider.@Lazyon a self-injected reference breaks the construction cycle.
🧪 Practice
- Write a class with two constructors and observe the failure, then fix it with
@Autowired.- Express one optional dependency three ways and compare the code at the use site.
- Inject a
Map<String, Handler>and dispatch to one implementation by key.- Replace Spring's annotations with
@Injectand@Namedin one class and confirm it still wires.- Use
@Lookupto obtain a prototype from a singleton and prove each call returns a new instance.- Interview: Why is
Optional<T>preferred over@Autowired(required = false)for an optional dependency? (Hint: what does each communicate to someone reading the constructor?)
Conditional and Profile Annotations
Applications run in several environments, and each needs different beans. A local run wants an in-memory database and a mail sender that logs; production wants a real pool and a real SMTP server. Hard-coding the choice means editing code to deploy, which is exactly what configuration exists to avoid.
Profiles are named sets of beans, activated at runtime.
@Configuration
@Profile("dev") // only when "dev" is active
public class DevConfig {
@Bean
public DataSource dataSource() {
return new EmbeddedDatabaseBuilder()
.setType(EmbeddedDatabaseType.H2)
.addScript("classpath:test-data.sql")
.build();
}
@Bean
public EmailSender emailSender() {
return message -> log.info("would send: {}", message); // no real mail
}
}
@Configuration
@Profile("prod")
public class ProdConfig {
@Bean
public DataSource dataSource(DataSourceProperties props) {
return new HikariDataSource(toHikariConfig(props));
}
@Bean
public EmailSender emailSender(MailProperties props) {
return new SmtpEmailSender(props);
}
}
Profiles are activated by property, environment variable, or command line, so the same artifact runs everywhere.
spring.profiles.active=prod # in a properties file
SPRING_PROFILES_ACTIVE=prod # as an environment variable
java -jar app.jar --spring.profiles.active=prod,metrics
Expressions cover the cases a single name cannot.
@Profile("!prod") // any profile except prod
@Profile({"dev", "test"}) // dev OR test
@Profile("prod & metrics") // both active
Profiles should describe the environment, not the feature. A profile named
postgres that is only ever active alongside prod is better expressed as a
conditional on the property that actually decides.
@Conditional is the general mechanism, and profiles are one special case
of it. It registers a bean only when a condition evaluates true at startup.
| Annotation | Registers the bean when |
|---|---|
@ConditionalOnProperty | A property has a given value |
@ConditionalOnClass | A class is on the classpath |
@ConditionalOnMissingClass | A class is absent |
@ConditionalOnBean | Another bean is already defined |
@ConditionalOnMissingBean | No such bean exists yet |
@ConditionalOnResource | A resource is present |
@ConditionalOnExpression | A SpEL expression is true |
@ConditionalOnWebApplication | The context is a web application |
@Configuration
public class FeatureConfig {
// A feature toggle: on when the property is "true", and on by default.
@Bean
@ConditionalOnProperty(name = "features.audit.enabled",
havingValue = "true",
matchIfMissing = true)
public AuditAspect auditAspect(AuditRepository repository) {
return new AuditAspect(repository);
}
// Only register a Redis cache if the driver is actually present.
@Bean
@ConditionalOnClass(name = "redis.clients.jedis.Jedis")
public CacheManager redisCacheManager() { ... }
// The fallback: used only when nothing else supplied a CacheManager.
@Bean
@ConditionalOnMissingBean(CacheManager.class)
public CacheManager simpleCacheManager() {
return new ConcurrentMapCacheManager();
}
}
@ConditionalOnMissingBean is the annotation that makes Spring Boot's
auto-configuration overridable: every auto-configured bean is declared with it,
so defining your own simply wins. Chapter 18 covers the ordering that makes this
reliable.
Custom conditions cover anything the built-in set does not.
public class OnLinuxCondition implements Condition {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata md) {
return System.getProperty("os.name").toLowerCase().contains("linux");
}
}
@Bean
@Conditional(OnLinuxCondition.class)
public NativeFileWatcher fileWatcher() { ... }
@Profile is itself implemented as a condition, which is a good
illustration of the composition this subchapter closes with.
// Spring's actual definition, simplified.
@Conditional(ProfileCondition.class)
public @interface Profile {
String[] value();
}
A caution about conditions and tests. Because conditions are evaluated at startup, a bean present in production may be absent in a test context, producing a failure that looks unrelated. Spring Boot's condition evaluation report makes this diagnosable.
java -jar app.jar --debug # prints matched and unmatched conditions
Key Takeaways
- Profiles activate named groups of beans so one artifact runs in every environment.
- Activate them by property, environment variable, or command line argument.
- Profile expressions support negation, alternatives, and conjunction.
- Name profiles after environments, not features; features belong on properties.
@Conditionalis the general mechanism, and@Profileis built from it.@ConditionalOnPropertyimplements feature toggles, with a default viamatchIfMissing.@ConditionalOnMissingBeanis what lets applications override auto-configuration.- The condition evaluation report explains why a bean is missing.
🧪 Practice
- Define a
devprofile with an embedded database and aprodprofile with a pooled one, and switch between them from the command line.- Add a feature toggle with
@ConditionalOnPropertyand verify both states.- Write a bean annotated
@ConditionalOnMissingBeanand override it with your own definition.- Implement a custom
Conditionand apply it to one bean.- Run with
--debugand locate one bean that was not registered, with the reason.- Interview: How does
@ConditionalOnMissingBeanlet you override a framework default without editing the framework? (Hint: what must be true about the order in which the two definitions are considered?)
Building Custom Annotations in Spring
Every annotation in this subchapter is built from smaller ones. Spring's
meta-annotation support, which makes @Service work as a @Component,
is available to your code too, and using it removes repetition and encodes
conventions your team otherwise has to remember.
Composed annotations combine several into one name.
// Four annotations that always appear together on every API controller.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController // meta-annotated
@RequestMapping(produces = MediaType.APPLICATION_JSON_VALUE)
@Validated
public @interface ApiController {
@AliasFor(annotation = RequestMapping.class, attribute = "path")
String[] value() default {}; // forwards to @RequestMapping
}
// Before
@RestController
@RequestMapping(value = "/api/orders", produces = MediaType.APPLICATION_JSON_VALUE)
@Validated
public class OrderController { }
// After
@ApiController("/api/orders")
public class OrderController { }
@AliasFor is the mechanism that forwards an attribute from your annotation to
the one it is built on, and it is what makes composed annotations configurable
rather than fixed.
A custom stereotype gives a layer its own marker, which pointcuts and architecture rules can then target.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Service
@Transactional // every use case is transactional
public @interface UseCase {
@AliasFor(annotation = Service.class)
String value() default "";
}
@UseCase // a bean, a service, and transactional
public class PlaceOrderUseCase {
public Order execute(PlaceOrderCommand command) { ... }
}
An annotation plus an aspect is the pattern 17.2 introduced, and it is the most useful of the three. The annotation declares intent at the call site; the aspect implements it once.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retryable {
int attempts() default 3;
long backoffMillis() default 100;
Class<? extends Throwable> on() default TransientDataAccessException.class;
}
@Aspect
@Component
public class RetryAspect {
// Binding the annotation as a parameter gives the aspect its settings.
@Around("@annotation(retryable)")
public Object retry(ProceedingJoinPoint pjp, Retryable retryable)
throws Throwable {
Throwable last = null;
for (int attempt = 1; attempt <= retryable.attempts(); attempt++) {
try {
return pjp.proceed(); // 17.2: must be called
} catch (Throwable t) {
if (!retryable.on().isInstance(t)) throw t; // not retryable: rethrow
last = t;
Thread.sleep(retryable.backoffMillis() * attempt); // linear backoff
}
}
throw last;
}
}
@Service
public class PaymentService {
@Retryable(attempts = 5, backoffMillis = 200) // intent, visible in place
public Receipt charge(Card card, BigDecimal amount) { ... }
}
A custom validation constraint is the same idea applied to 17.4's Bean Validation, and was shown there. The pattern is identical: an annotation plus a class that implements the behaviour.
A custom conditional composes with the previous topic's mechanism.
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ConditionalOnProperty(name = "features.beta.enabled", havingValue = "true")
public @interface BetaFeature { }
@Bean
@BetaFeature // reads better than repeating the property
public NewPricingEngine pricingEngine() { ... }
Three rules keep custom annotations useful rather than mystifying.
@Retention(RUNTIME) is mandatory. The default, CLASS, means the annotation
is discarded before the JVM can see it, so Spring finds nothing and the
annotation silently does nothing.
@Target should be as narrow as the intent. A method-level concern annotated
onto a class is a bug waiting to happen.
Do not hide too much. An annotation that bundles seven behaviours saves typing and costs comprehension. The test is whether a new team member can predict what it does from its name.
Finally, remember that custom annotations backed by aspects inherit every limitation from 17.2: they apply only to Spring-managed beans, only to public methods, and not to self-invoked calls.
Key Takeaways
- Spring resolves meta-annotations, so your annotation can be built from its own.
- Composed annotations replace a repeated stack with one meaningful name.
@AliasForforwards attributes to the meta-annotation, keeping them configurable.- A custom stereotype gives a layer a marker that pointcuts and architecture tests can target.
- Annotation plus aspect is the general pattern for declarative behaviour.
- Binding the annotation as an advice parameter lets each use site configure the aspect.
@Retention(RUNTIME)is required, or the annotation is invisible at runtime.- Custom annotations backed by aspects inherit every proxy limitation from 17.2.
🧪 Practice
- Create an
@ApiControllercomposed annotation and apply it to two controllers.- Use
@AliasForso that your annotation's value sets the request mapping path.- Build a
@Retryableannotation and aspect, then prove the retry count is read from the annotation.- Set retention to the default and demonstrate that the aspect stops matching.
- Write a
@UseCasestereotype that is also transactional, and confirm both behaviours apply.- Interview: Your custom annotation and its aspect work on one method but not on another in the same class. What would you check first? (Hint: 17.2 listed several ways a call avoids the proxy.)
<a id="18-spring-boot-and-production-applications"></a>