The Order Service: Java and Spring Boot in depth#
One microservice built with Domain-Driven Design (DDD), every mechanism explained, for C# developers
This book follows one service, the shop's order service, from the reasons for Domain-Driven Design to the team's playbook. Read it in order. Each chapter opens with a table that gives its answers first, so you can come back to one chapter later. The whole book takes about 19 hours 25 minutes.
Start reading How to read this book Java words in plain English The handbook: Java for C# Developers
October 2026 edition
The facts were verified 25 and 26 October 2026. This copy was built 1 November 2026. The edition says how current the content is. The build date does not say that.
Preface#
This book explains one service completely.
The service is the order service of the book's online shop. It is built with Domain-Driven Design (DDD) in Spring Boot 4.1, on Java 25, with Amazon DocumentDB as its database. Every Spring feature the service uses is explained in full, in the chapter where the service first needs it. For each feature, the chapter says how it works inside and which settings the service uses. It says which choices were open, what goes wrong, how to test it, and what ASP.NET Core does instead.
The book is a sibling of the handbook Java for C# Developers. The handbook teaches each feature at a first-read level, in a chapter you can read in a few minutes. This book starts where the handbook stops. When a chapter here needs the basics, it links to the handbook instead of repeating them.
Who it is for#
You write C#, and you now build Spring Boot services. You have read the handbook, or you have worked with Spring Boot for a few months. You want to judge design advice, not only follow it. When a colleague or a tool proposes a design, you want to know which rule it bends and what that costs.
How to read it#
Read it in order. Each chapter adds one layer or one concern to the same service, and later chapters build on earlier ones. The book has three parts of work in each chapter:
| Where | What you find | Use it for |
|---|---|---|
| the table at the top | every difference the chapter covers, one row each, with the answer in the row | the first read, and every refresh |
| the sections | the full explanation of each row, with the service's own code | the first read |
| the pragmatic callouts | each place where the service departs from a strict rule, with the reason and the cost | judging a design |
Every section of this book is open in every reading mode, because the book is read in order. The book opens in Complete. Essentials and Everyday fold only the asides that are marked as legacy or as deeper notes. The reading time in the sidebar is for the whole book.
Every Java example in this book is copied from one real project. The project is a multi-module Maven build with Spring Boot 4.1.2, a platform library and the order service. Its tests pass, and a script compares each example with its source file. So an example here is not a sketch. It compiles, and it runs.
Four chapters stand apart from the others. Each one follows one run of the service as a story, in scenes, with one protagonist. Every scene ends with what you can see at that moment, and what you can hook or tune there. The chapter Maven in depth: resolution and the lifecycle follows the chapter on the build. The chapter Starting the service: from main() to the first request follows the chapter on the container. Two chapters on the Java Virtual Machine (JVM), The JVM under the service: memory and The JVM under the service: code, threads and the compiler, follow the chapter on running and shipping. Their numbers come from runs of the service, and each chapter says on which machine.
The last part, Reasoning from first principles, is for the refresh. It defines every DDD and microservices word in one or two sentences. It gives nineteen principles. Then it answers the questions that experienced teams ask, and each answer names the principle that decides it. Every principle and every question is a card in Review. To drill one topic, choose the part in Review.
The two books#
A link to the handbook opens the handbook's page on the website, at the right place. Such a link needs a network connection. The glossary, Java words in plain English, is included in this book too, so the word tooltips work without a connection. The glossary is shared between the two books, and its heading still carries the handbook's appendix letter.
Review asks you the book's 341 questions one at a time. It asks a question again if you missed it. These questions are a deck of their own. They are not part of the handbook's 90-minute refresh.
To find one fact, press ⌘K or Ctrl+K. Then type a Spring annotation, a setting, a C# name or an error message.
An appendix at the back says what the book targets, how its facts were checked, and what it leaves out. See About this book.
Why DDD, and the service we build#
Domain-Driven Design (DDD) is a way to design software around the business rules. Each rule is written once, in the object it protects. This part builds one service with DDD, the shop's order service. This chapter says what DDD solves, what it costs, and when it is not worth it. Then it shows the service.
| Kind | A usual ASP.NET Core service | The order service, with DDD | Note |
|---|---|---|---|
| new | order.Status = OrderStatus.Shipped; | order.ship(clock) | the rule is a method of the order, so no caller can change the status without it Why |
| new | one EF Core entity for each table | business words, a bounded context and aggregates | DDD names the business ideas first and designs the code around them Why |
| new | no equivalent | event storming, bounded contexts and aggregates, but no event sourcing | use the parts of DDD that find rules and boundaries, and keep the rest light Why |
| new | no equivalent | the conditions that make DDD worth its cost | complex rules, many states, rules nobody may bypass, a long life and old data are the signs Why |
| new | a CRUD service with EF Core entities | the reference feature, with no DDD building blocks | data with no rules does not need DDD, even inside a DDD service Why |
| new | one class for each table | a model, a document, a mapper, a view, and request and response classes | DDD adds types and mapping, and it takes time with business experts Why |
| new | rules spread over services and controllers | one place to look for each rule | the code answers questions such as when an order can ship, and whether anything bypasses it Why |
| trap | a method call you can follow in the IDE | an event that a listener in another package receives | more types and events hide the flow, so a team must work to keep it easy to follow Why |
| new | conventions that each team member keeps in mind | one layout, business names, architecture tests and decision notes | the same structure everywhere is what keeps a DDD service readable Why |
| twist | public setters and an outbox row added by the service | a rule method that records its event, and one save step | an outbox makes events reliable, but it does not stop a caller that forgets them Why |
| new | entities with private setters | state-change methods, recorded events and value objects | the middle ground keeps most of the value of DDD for a small part of its cost Why |
| new | a rules engine called from a service | a DMN decision table behind a domain interface | a table of many cases is easier to read and change in a rules engine than in code Why |
| new | an order with a long id | an order with a typed OrderId that holds a universally unique identifier (UUID) | the service chooses the ID before it saves, because it sends the ID to the stock service first Why |
| new | no equivalent | three events out, one event in, and two services called | every message and call of the service is listed in one place Why |
| new | one solution with several projects | three repositories for the platform, the service and the local environment | the platform is released on its own, and every service shares the local environment Why |
| new | no equivalent | a map of the 26 chapters of this part | each chapter adds one layer or one concern to the same service Why |
How this part was checked. Every Java, YAML, JSON and shell example of this part is copied from a companion service. The companion builds with Spring Boot 4.1.2 and Java 25, and its tests pass. The C# examples were compiled with the .NET SDK 10.0.300 and C# 12. Nothing was run against Amazon DocumentDB.
The problem DDD solvesnew#
The shop has a rule. An order ships only when it is paid and every line is picked. In a usual
ASP.NET Core service, the rule is an if in a service method. Months later, a second
team adds a batch job that ships orders. The job sets the status directly, and the rule is
skipped. Nothing fails until a customer receives an order that nobody paid for.
DDD solves this problem with one idea. Each business rule is written once, inside the object whose data it protects. The object has no setter for that data. So the only way to change the status is the method that checks the rule.
The code below shows both designs. On the left, the rule is in a service, and the status has a public setter. On the right, the rule is a method of the order, from the companion service of this part.
public class OrderService(ShopDb db, TimeProvider clock)
{
public async Task ShipAsync(long orderId)
{
var order = await db.Orders.Include(o => o.Lines).SingleAsync(o => o.Id == orderId);
if (order.Status != OrderStatus.Paid)
throw new InvalidOperationException("The order is not paid.");
if (order.Lines.Any(line => line.PickedAt is null))
throw new InvalidOperationException("A line is not picked.");
order.Status = OrderStatus.Shipped;
order.ShippedAt = clock.GetUtcNow();
await db.SaveChangesAsync();
}
}public void ship(Clock clock) {
requireNotFinished();
if (status != OrderStatus.PAID) {
throw new OrderNotPaidException(id());
}
if (lines.stream().anyMatch(line -> !line.isPicked())) {
throw new UnpickedLinesException(id());
}
status = OrderStatus.SHIPPED;
shippedAt = clock.instant();
raise(new OrderShipped(id(), shippedAt));
}order.Status = OrderStatus.Shipped;In C#,
Statushas a public setter. Any class can write this line, with or without the two checks above it.public void ship(Clock clock)In Java,
shipis a method ofOrder. It receives the clock, so a test can fix the time.Clockis fromjava.time. It has the same purpose asTimeProviderin .NET.requireNotFinished();This private method refuses an order that is already shipped or cancelled. The rule about finished orders is written once, and every state change calls it.
throw new OrderNotPaidException(id());Each broken rule has its own exception class. The platform turns it into an HTTP 422 response with a stable code, here
ORDER_NOT_PAID.status = OrderStatus.SHIPPED;The field is private, and the class has no setter for it. So this line inside
shipis the only place in the service that can make an order shipped.raise(new OrderShipped(id(), shippedAt));The order adds an object that says it shipped. This object is called a domain event. Every use case of the order saves it through one step, which also publishes its events. So a use case cannot save the change and forget the event.
The two designs check the same rule. The difference is who can skip it. In the C# design, the
rule protects one entry point. In the Java design, the rule protects the data itself. A batch
job, a message listener and a controller all call order.ship(clock), because no
other way exists.
In the companion, a test runs on every build. ArchitectureTest fails if an entity
has a public setter. For more about these tests, see the section
What keeps a DDD service readable.
What DDD is, in this book's wordsnew#
Eric Evans described DDD in his book Domain-Driven Design, with the subtitle Tackling Complexity in the Heart of Software. Addison-Wesley published it in August 2003. DDD works at two levels. This part uses words from both levels, and each word is introduced where the service first needs it.
The first level is about the business and its boundaries. It is called strategic design.
- The domain is the business that the software serves. Here it is the shop.
- The words that the business experts use are called the ubiquitous language. This book calls them the business words. The code uses the same words. An order is placed, paid, shipped or cancelled, and its lines are picked. An order is never "processed" or "updated".
- A bounded context is a part of the business with its own model and its own meaning for each word. In the shop, the order service is one bounded context. The stock service is another one. Each owns its data.
The second level is about the code inside one bounded context. It is called tactical design. Its building blocks are the aggregate, the entity, the value object, the domain event, the repository and the services. The table shows where each one appears in the order service.
| Building block | In the order service | Taught in |
|---|---|---|
| aggregate | Order, with its lines and its last payment, saved as one document | the chapter on aggregates |
| entity | OrderLine, which has its own identity inside the order | the chapter on aggregates |
| value object | Money, ProductCode and Payment, which have no identity and never change | the chapter on aggregates |
| domain event | OrderShipped, which the order records when it ships | the outbox chapter |
| repository | OrderRepository, the interface that loads and saves orders | the chapter on layers |
| domain service | ShippingPolicy, a rule that needs an order and its notes | the chapter on rules |
| application service | OrderService, which runs the steps of each use case | the use-case chapter |
A bounded context has its own words for the things of another context. The stock service has its own model of stock. The order service needs one answer from it, and it keeps that answer in its own words:
/**
* The stock service's answer, in this service's words: either every product is held for the
* order, or some are not available.
*
* <p>The domain decides what the answer means: an order is placed only if everything is available.
*
* @param available whether every product is held
* @param unavailableProducts the products that are not available; empty when {@code available}
*/
public record StockAvailability(boolean available, List<ProductCode> unavailableProducts) implements ValueObject {
public StockAvailability {
unavailableProducts = List.copyOf(unavailableProducts);
}StockAvailability is a record with two
components. It says whether the stock service holds every product, and which products it does
not have. The order service never sees the stock service's own classes. A class that translates
another service's model into this service's words is called an anti-corruption layer.
For how the service calls the stock service, see the chapter
Calling other services.
What to use, and what to keep lightnew#
DDD has many parts, and a team does not need all of them. This book uses the parts that find the rules and the boundaries. It keeps the rest light, or leaves it out.
Use these parts:
- Event storming. EventStorming is a workshop format. Its official book is by Alberto Brandolini. Its site describes it as a flexible workshop in which people explore a complex business together. In the workshop, developers and business experts look at what happens in the business together. Then the team looks for the rules, and for the boundaries between the parts of the business.
- Bounded contexts. Each service owns one part of the business and its data. Other services use its API or its events, never its database.
- Business words, not jargon. A class, a method and an exception use the word that a business expert would use. A business expert says "ship". Nobody in the warehouse says "update the status to 3".
- Aggregates as consistency boundaries. An aggregate is the group of data that one rule must see at once, and that is saved together. For how to find one, see the chapter Finding the aggregates.
- The full model only where there are rules. Lookup data and simple features use simpler code. For the three levels of the order service, see the chapter How much DDD: three levels in one service.
The business words reach every name, even the values of an enum. The companion stores the status of an order as its name, so the names are business words that never change:
/**
* Where an order is in its life. It is stored as its name, so never rename a value.
*
* <p>NEW becomes PAID when the payment arrives, and PAID becomes SHIPPED. NEW or PAID may become
* CANCELLED. SHIPPED and CANCELLED are final.
*/
public enum OrderStatus {
NEW, PAID, SHIPPED, CANCELLED
}The comment above OrderStatus names every allowed change of state. A business expert can read
it and correct it.
Leave these parts out, unless one bounded context clearly needs them:
- Event sourcing. With event sourcing, a service stores the list of events of an aggregate instead of its current state. It is useful when the business needs the full history of every change, as an audit or for replay. The order service stores the current state, and it keeps an audit log beside it.
- Full CQRS. Command query responsibility segregation (CQRS) uses one model for changes and another model, often another database, for reads. It is useful when reads and changes need very different shapes or scale. The order service reads and changes the same documents. For the choice, see the chapter Features, layers and the rules.
Do you need DDD? The conditions that make it worth its costnew#
DDD costs time and code. It is worth that cost when the rules are the hard part of the software. Look for these conditions. The more of them a service has, the more DDD is worth.
| Condition | In the shop | Why DDD helps |
|---|---|---|
| complex rules | an order ships only when it is paid, picked, and no note needs action | each rule has one home, and a test that names it |
| many states and workflows | an order is new, paid, shipped or cancelled, and each state allows other actions | the methods of the aggregate allow only the changes that the state permits |
| rules that must never be bypassed | a shipped order must never change | the data has no setter, so the only way in is the method that checks |
| a long life | the order service will run for years and change many times | a new developer finds each rule in one place, not spread over the code |
| high stakes | shipping an unpaid order loses money | a mistake in a rule is found by a fast unit test, not by a customer |
| rules to recover from an old system | an old shop system may hold some rules only in its stored procedures | each recovered rule becomes a method and a test with a business name |
| data that breaks rules during a migration | old orders without a valid e-mail address | the model refuses bad values, so the migration finds them before customers do |
| finding service boundaries | orders, stock and payments change for different reasons | bounded contexts show where one service should end |
Two conditions need a closer look. Both appear when a new service replaces an old system.
Rules to recover from an old system. An old system often holds rules that nobody wrote down. They are in stored procedures, in screens and in the memory of its users. DDD gives each recovered rule a place and a name. Write it as a method of the aggregate. Then write a test whose name a business expert can read. The order tests of the companion read like a list of rules:
@Test
void refusesAnUnpaidOrder() {
assertThatThrownBy(() -> newOrder().ship(CLOCK)).isInstanceOf(OrderNotPaidException.class);
}
@Test
void refusesAnOrderWithAnUnpickedLine() {
Order order = newOrder();
order.recordPayment(payment(NOW));
assertThatThrownBy(() -> order.ship(CLOCK)).isInstanceOf(UnpickedLinesException.class);
}
// ...
@Test
void aRepeatedOrEarlierPaymentChangesNothing() {
Order order = newOrder();
order.recordPayment(payment(NOW));
order.pullEvents();
assertThat(order.recordPayment(payment(NOW))).isFalse();
assertThat(order.recordPayment(payment(NOW.minusSeconds(60)))).isFalse();
assertThat(order.pullEvents()).isEmpty();
}The tests use plain JUnit, with no Spring and no mocks. Each one runs in a few milliseconds. newOrder(),
payment(NOW) and CLOCK come from a shared class of test data.
assertThatThrownBy is from AssertJ. It runs the code and checks the exception that it
throws. Show the test names to a business expert, and ask which rule is missing.
Data that breaks rules during a migration. Old data was written under old rules, or under no rules. In the order service, loading a stored order applies no business rules. But the loader still builds each value object, and each value object checks its own value:
@Override
public Order toDomain(OrderDocument document) {
return Order.rehydrate(
OrderId.of(document.getId()),
document.getVersion(),
new CustomerContact(document.customerName, document.customerEmail),
new DeliveryMethod(document.deliveryMethod),
document.placedAt,
document.lines.stream().map(OrderDocumentMapper::line).toList(),
OrderStatus.valueOf(document.status),
payment(document.lastPayment),
document.shippedAt,
document.cancelledAt);
}Order.rehydrate rebuilds an order without checking the rules of the order, because
loading is not a business action. But new CustomerContact(...) checks the format of
the e-mail address. So a migrated order with a broken address fails to load. That is the right
result, but the wrong time to find it. Before the new service takes traffic, run a check that
loads every migrated document through the mapper. Fix or set aside each document that fails.
For how the service runs migrations, see the chapter
Migrations, indexes and seed data.
When DDD is not worth itnew#
DDD is not worth its cost when a service has few rules. Microsoft's guide to .NET microservices says the same. Its page "Designing a microservice domain model" discusses a very simple CRUD service. CRUD means create, read, update and delete. For such a service, the page says, entities that hold only data may be good enough. The guide's sample shop uses DDD for its ordering service, but not for its catalog service.
Do not use DDD in these cases:
- A service that stores and returns data, with no rules beyond the format of each field.
- Read-only lookup data, such as the delivery methods of the shop.
- Reports and search pages, which read data and change nothing.
- Code that connects two systems, and only translates and forwards messages.
- A prototype that will be thrown away. Write it simply, and model the rules in the real service.
The choice is made per feature, not per service. The order service itself has a feature with no DDD building blocks. Its package description says why:
/**
* Lookup values, such as delivery methods and note categories, for drop-down lists and for
* checking codes. The data changes only with a deployment, through a seed migration.
*
* <p>This feature uses the simple level: no aggregate, no domain model and no events. A
* document record, a Spring Data repository, and the logic in the application service. Read-only
* lookup data has no business behaviour, so DDD's building blocks would only add files.
*/
package com.acme.orderservice.reference;A file named package-info.java holds the comment and the annotations of a
whole package. Here the line package com.acme.orderservice.reference;
names the package it describes. The reference feature has a document, a
repository and a service, and nothing else. For the three levels side by side, see the chapter
How much DDD: three levels in one service.
What DDD costsnew#
A team that chooses DDD should know the costs before it starts. There are three.
The learning curve. DDD has its own words, and a new developer needs weeks to use them well. A developer from a usual ASP.NET Core service expects public setters, rules in services and one class for each table. Each habit must change. The chapters of this part teach each change with its reason.
More types and more mapping. The full style keeps the model and the stored shape apart. So one order has a domain class, a document class and a mapper between them. A view and two data transfer objects (DTOs), one for the request and one for the response, carry it in and out. In the companion, the delivery method of an order appears in 13 files of the order feature. Here is half of the mapper that copies an order to its stored document:
@Override
public OrderDocument toDocument(Order order) {
OrderDocument document = new OrderDocument();
document.customerName = order.customer().name();
document.customerEmail = order.customer().email();
document.deliveryMethod = order.deliveryMethod().code();
document.status = order.status().name();
document.placedAt = order.placedAt();
document.shippedAt = order.shippedAt();
document.cancelledAt = order.cancelledAt();
document.lines = order.lines().stream().map(OrderDocumentMapper::lineDocument).toList();
document.lastPayment = paymentDocument(order.lastPayment());
return document;
}Each new field of an order needs a line in toDocument, and a line in the method that reads the
document. The light style of the note feature has no mapper at all. It has other costs, which
the chapter How much DDD: three levels in one service explains.
Modelling time with experts. The model is only as good as the team's understanding of the business. That understanding comes from hours with the people who do the work. A team without access to business experts gets the extra types, but not the better model.
Easier to reason about: the questions answered in one placenew#
The value of DDD shows when someone asks a question about the code. In a service where the rules are spread over controllers, services and jobs, each answer needs a search. In the order service, each answer has one place. The table lists the usual questions and where the answer is.
| Question | Where the answer is | Why it is enough to look there |
|---|---|---|
| When can an order ship? | Order.ship and ShippingPolicy | every rule about shipping is in these two classes |
| Can anything bypass that rule? | Order has no setter, and ArchitectureTest checks it | the build fails when someone adds a public setter to an entity |
| What does this use case do? | the method in OrderService, such as ship | each use case is a short list of steps, with no rule in it |
| What happens when an order ships? | OrderIntegrationEventMapper and the listeners of OrderShipped | every reaction to the event is a listener of that event type |
| Is the rule tested? | OrderTest, in the nested class Shipping | each rule has a test whose name states the rule |
| What breaks if storage changes? | the persistence package of the feature | the domain package uses no MongoDB class, and a test checks it |
| Where is the code for notes? | the note package | each business feature is one package, with the same folders inside |
Here is the use case that ships an order. It names its steps, and it holds no rule:
/** Ships the order. The rule across the order and its notes is checked by the domain service. */
@Transactional
public void ship(OrderId orderId) {
Order order = orders.get(orderId); // load
long notesNeedingAction = notes.countNeedingAction(orderId.value()); // ask the note feature
shippingPolicy.assertCanShip(order, notesNeedingAction); // rule across aggregates
order.ship(clock); // rule and change
saveAndPublish(order, AuditActions.ORDER_SHIPPED); // save, publish, audit
}@Transactional runs the method in one database transaction. The four steps load
the order, ask the note feature for a count, check the rule across the order and its notes, and
ship. The last line saves the order, publishes its events and records the audit. A reader learns
what shipping does from five lines. For the steps of every use case, see the chapter
Use cases: the application service.
What gets hardertrap#
DDD does not make everything easier. An honest team says what gets harder, and works against it. Four things get harder.
More indirection. A request passes through a controller, an application
service, an aggregate, a repository port, an adapter and a mapper. In a usual ASP.NET Core
service, the same request passes through a controller, a service and a
DbContext. Each extra layer is one more file to open.
Events hide the flow. When a method raises an event, the code that reacts
to it is in another class. The IDE does not lead you there with one click. In the companion, two
classes react to OrderShipped. One counts the shipped orders. The other decides
which events leave the service:
/**
* Decides which order events leave the service. Placed, shipped and cancelled orders are
* published; picked lines and recorded payments stay internal. Every published event is a
* contract to maintain, so publish only what another service needs.
*/
@Component
class OrderIntegrationEventMapper implements IntegrationEventMapper {
@Override
public Optional<IntegrationEvent> toIntegrationEvent(DomainEvent event) {
return switch (event) {
case OrderPlaced placed -> Optional.of(new OrderPlacedV1(newEventId(), placed.occurredAt(),
placed.orderId().value(), placed.deliveryMethod().code(),
placed.items().stream()
.map(item -> new OrderPlacedV1.Line(item.product().value(), item.quantity()))
.toList()));
case OrderShipped shipped -> Optional.of(new OrderShippedV1(newEventId(), shipped.occurredAt(),
shipped.orderId().value()));
case OrderCancelled cancelled -> Optional.of(new OrderCancelledV1(newEventId(), cancelled.occurredAt(),
cancelled.orderId().value()));
default -> Optional.empty();
};
}The switch matches the type of the event, as a switch with type
patterns does in C#. It turns three domain events into the public events of the service, and
keeps the others inside. This one class answers the question "which events leave the order
feature?". Without it, the answer would need a search through every listener.
The learning curve. The words and the rules of DDD take time to learn, as the section What DDD costs says. A team of beginners writes DDD slowly at first.
DDD done badly is worse than no DDD. A team can copy the layers without the idea.
The layers of DDD without its rules are the worst of both designs. Aggregates
copied from database tables, entities with public setters, and rules in the application
services give all the extra files of DDD. They give none of its protection. Watch for three
signs. An aggregate method only sets a field. An application service holds an if
that a business expert would recognise as a rule. A test of a rule needs mocks. For where each
rule belongs, see the chapter Where each rule goes.
What keeps a DDD service readablenew#
The costs of the section What gets harder grow with each developer who does things differently. These practices keep a DDD service readable.
- The same structure everywhere. Every feature has the same folders, and every service has the same features layout. A developer who knows one feature finds the code of another one at once.
- Steps-only application services. A use case loads, lets the domain decide, saves, publishes and audits. It holds no rule.
- Business names. A class is named after the business idea, such as
ShippingPolicy. It is never named after a pattern alone, such asOrderDomainService. - Simple where simple. A feature with no rules uses the simple level. The full model is only where it protects a rule.
- Traceable events. One mapper per feature decides which events leave it. Give every log line and message a trace ID, so one request can be followed across services. A flow that spans many steps and days uses a workflow engine or a process manager. Either one keeps the state of the flow in one place. For trace IDs, see the chapter Logging, tracing and health.
- Architecture tests. The rules of the design are tests, so the build fails when someone breaks one.
- Docs and decision records beside the code. Each service has a README with its rules, its events and its decisions. A decision that a team may question later is written as an architecture decision record (ADR). An ADR is a short file that states a decision, its reason and its consequences.
Here are two of the architecture tests of the companion:
@Test
void entitiesHaveNoPublicSetters() {
ArchitectureRules.entitiesHaveNoPublicSetters().check(CLASSES);
}
@Test
void featuresUseEachOtherOnlyThroughApplication() {
ArchitectureRules.featuresUseEachOtherOnlyThroughApplication(BASE).check(CLASSES);
}Each test calls a rule from the platform library, and check fails the test when a
compiled class breaks it. The rules use ArchUnit, a Java library that reads the compiled classes
and checks their dependencies. ArchUnitNET is a separate library for .NET. Its README calls it the C# fork of ArchUnit. The first rule finds a public setter on an entity. The second one
finds a feature that uses the inside of another feature. For all the rules, see the chapter
Testing in depth.
An anaemic model with an outbox, compared fairlytwist#
A model whose entities hold data but no rules is called an anaemic domain model. Martin Fowler described it in an article of November 2003. He said that its objects are hardly more than getters and setters. Many good .NET services use this design. With an outbox, they also publish their events reliably. An outbox is a table or collection that stores each event in the same transaction as the change. A background process sends it after the commit. So this design deserves a fair comparison.
public async Task ShipWithOutboxAsync(long orderId)
{
var order = await db.Orders.Include(o => o.Lines).SingleAsync(o => o.Id == orderId);
// ... the same two checks as in ShipAsync
order.Status = OrderStatus.Shipped;
order.ShippedAt = clock.GetUtcNow();
db.OutboxMessages.Add(OutboxMessage.For(new OrderShippedV1(order.Id, order.ShippedAt.Value)));
await db.SaveChangesAsync(); // the order and the message, in one transaction
}/**
* Saves the order, publishes its events and records the audit, all in the caller's
* transaction.
*
* <p>The events are pulled before saving, because the saved copy that the repository returns
* is rebuilt from the database and has none. The caller continues with the returned copy,
* which holds the new version.
*/
private Order saveAndPublish(Order order, String auditAction) {
List<DomainEvent> raised = order.pullEvents();
Order saved = orders.save(order);
events.publish(raised);
audit.record(auditAction, AuditActions.ORDER, saved.id().value());
return saved;
}In C#, the service sets the fields, writes the outbox row and saves. In Java, the order has
already recorded its events, inside ship. saveAndPublish pulls them,
saves the order and publishes the events in the same transaction. Every use case of the feature
calls this one method. So no use case can save a change and forget its event.
The table compares what the anaemic design keeps and what it loses.
| Concern | Anaemic model with an outbox | Rich model with an outbox |
|---|---|---|
| events reach other services after a commit | yes, the outbox is reliable | yes, the same outbox |
| the change and its event commit together | yes, if the service adds the row | yes, the save step always publishes |
| a rule has one home | no, each service method checks it again | yes, the method of the aggregate |
| a caller can skip a rule | yes, any class can set the status | no, the status has no setter |
| a new developer can learn it quickly | yes, it is the usual design | it takes some weeks |
| proof that a rule always holds | read every class that sets the field | read one method and its test |
The anaemic design breaks down in four ways, and each one grows with the size of the team.
- The same rule is copied. The controller checks that the order is paid. The batch job checks it too, a little differently. A year later, the two checks disagree.
- A loader bypasses everything. An import or a data fix writes entities directly. It sets a status that no rule would allow.
- Events are forgotten. The outbox sends every row that a service writes. It cannot send a row that a new endpoint forgot to write.
- Proof of correctness is slow. To prove that no order ships unpaid, a
reviewer must find every line that sets the status. In the rich model, a reviewer reads
Order.shipand its tests.
So the outbox fixes the delivery of events, and only that. It does not fix where the rules are, or who may skip them.
The middle groundnew#
A team does not need the full style to get most of the value. Four practices give most of it, and each one is cheap.
- State-change methods with their rules, and no state setters. An entity
changes only through methods such as
shipandresolveAction. - The entity records its events. The method that changes the state also records what happened. The use case publishes the recorded events when it saves.
- Value objects for IDs, money, dates and codes. A value that has a format
or a rule is a small type that checks itself, such as
MoneyorProductCode. - Boundaries decided by the strongest people. The boundaries of the services and of the aggregates are the hardest decisions to change later. A small group of the most experienced developers makes them, with the business experts. The team writes everything else.
The note feature of the companion uses exactly this middle ground. Its entity is also its stored document, and it has no mapper. But it keeps its rules:
public void resolveAction(String actorId, Clock clock) {
if (!actionRequired) {
throw new NoteHasNoActionException(id);
}
if (actionResolvedAt != null) {
throw new NoteActionAlreadyResolvedException(id);
}
if (actorId.equals(createdBy)) {
throw new NoteResolvedByItsAuthorException(id);
}
actionResolvedAt = clock.instant();
events.add(new NoteActionResolved(id, orderId, actionResolvedAt));
}resolveAction checks three rules, changes one field and records the event
NoteActionResolved. The third rule depends on who acts, so the use case passes the
actor's ID in as a plain value. No setter exists for actionResolvedAt. For what
this light style drops and what it keeps, see the chapter
How much DDD: three levels in one service. For who decides what in a team,
see the chapter DDD for a team: the playbook.
Rules engines and decision tablesnew#
Some rules are calculations with many cases. A delivery charge, for example, can depend on
the delivery method, the zone and the weight. Thirty cases written as if statements
are hard to read and hard to check. A table of the cases is easier, and a business expert can
check it.
Decision Model and Notation (DMN) is a standard for such tables. The Object Management Group (OMG) publishes it. Its page says that DMN lets a team define business rules simply and exactly, in decision tables. Among the Java libraries that evaluate DMN tables are these two, as of October 2026.
- The Camunda DMN engine is a Java library that evaluates DMN decision tables. Its page for Camunda 7.24 says that it implements DMN 1.3, and that it can be embedded in an application.
- Drools is a group of projects for decision management, mostly in Java. Its user guide for version 8.44.0.Final lists a decision engine for DMN.
A decision table has one row for each case. Here is an example for the shop. The order service has no delivery charges, so this table is only an illustration:
| Delivery method | Zone | Weight up to | Charge |
|---|---|---|---|
| STANDARD | UK | 2 kg | 3.95 GBP |
| STANDARD | UK | 10 kg | 6.95 GBP |
| EXPRESS | UK | 10 kg | 12.50 GBP |
| STANDARD | EU | 10 kg | 14.00 GBP |
Keep the rule's place in the model, even when an engine calculates it. The domain names the rule as a class, and the class asks a port for the answer. The order service has a rule class of this kind, without a port:
/**
* The rule that needs two aggregates: an order cannot ship while a note on it needs action.
*
* <p>It is a domain service because neither aggregate holds the data alone. It is named after
* the rule, not {@code OrderDomainService}, so unrelated code does not collect in it. It takes a
* number, not the notes, so the order feature never sees note objects. It has no state, no
* repository and no Spring annotation, and the order feature's configuration creates it.
*
* <p>The guarantee holds only at the moment of shipping. An agent may add a note that needs
* action a second after the order shipped. The service accepts that, and the README records the
* decision.
*/
public class ShippingPolicy {
public void assertCanShip(Order order, long notesNeedingAction) {
if (notesNeedingAction > 0) {
throw new OrderHasOpenActionsException(order.id(), notesNeedingAction);
}
}
}A delivery charge would get a class such as DeliveryChargeCalculator, beside
ShippingPolicy. Its port would be an interface in the domain. An adapter outside the
domain would implement the port, and evaluate the DMN file with the engine. So the domain stays
plain Java, and a unit test can replace the table.
Use an engine when the table is large, or when business experts change it more often than the code is released. Keep the rule in code when it has a few cases. An engine adds a library, a file format and a second kind of test.
The order service: its features and its rulesnew#
The rest of this part builds one service, the shop's order service. It keeps the orders that customers place, the lines that warehouse staff pick, and the payment that the payment service reports. It also keeps the notes that customer-service agents write on orders. The package description of the order feature states its rules:
/**
* Orders: what a customer bought, the lines that warehouse staff pick, the payment, and shipping.
*
* <p>This feature uses the full style. Its {@code domain} package is plain Java with typed IDs.
* The {@code persistence} package stores the aggregate as a separate document through a mapper,
* behind the {@code OrderRepository} port. The stock service is called through the
* {@code StockChecker} port.
*
* <p>The rules: an order is placed only if the stock service confirms and holds the products.
* An order ships only when it is paid, every line is picked, and no note on it needs action. A
* payment is recorded from the payment service's event, and a repeated or late one changes nothing.
*/
package com.acme.orderservice.order;The comment describes the package com.acme.orderservice.order. The service has five
top-level packages. Each of the first four is a business feature.
| Package | What it holds | Level of DDD |
|---|---|---|
| order | orders, their lines, the last payment, and shipping | full |
| note | the notes of customer-service agents, some of which need action | light |
| reference | lookup values, such as delivery methods and note categories | simple, no DDD building blocks |
| migration | the indexes and the seed data of the database | not a feature with rules |
| shared | the permission annotations, the audit names and the OpenAPI description | not a feature |
These are the rules that the README of the service lists:
- An order is placed only if the stock service confirms and holds every product.
- An order has 1 to 50 lines. A line is picked once.
- An order ships only when it is paid, every line is picked, and no note on it needs action.
- A shipped or cancelled order cannot change. A payment for it is still recorded, because the money was received. The payment service handles refunds.
- A payment is recorded from the event
PaymentReceivedV1. A repeated or earlier payment changes nothing. - Only a note that needs action can be resolved, and only once. The text of a note never changes.
- Delivery methods and note categories must be active lookup values.
The packages of each feature follow one layout. Here are the folders of the order service:
com/acme/orderservice/
order/
api/ the controller, its DTOs and their mapper
application/ the use cases, the views, and the port to the stock service
client/ the HTTP client of the stock service
domain/ plain Java: the model, events, exceptions, repository port, domain service
messaging/ the events that leave, and the listener of payments
persistence/ the stored document, its mapper, and the repository adapter
note/
api/
application/
model/ the entity that is also its stored document, and its repository
reference/
api/
application/
persistence/
migration/
shared/Each folder name says what the classes in it do. For example, only the domain/ folder of a feature holds its business rules. In the
note feature, the model/ folder holds them. For the reasons behind this layout, see the
chapter Features, layers and the rules.
The earlier chapters of this book give an order a long id. This part gives it a
typed OrderId, which holds a universally unique identifier (UUID) as text. The service needs the ID before it saves
the order, because it sends the ID to the stock service first. A database cannot generate an ID
that the service needs before the first save. For the typed ID in full, see the chapter
The domain model in plain Java.
Its events and the services it talks tonew#
The diagram shows every call and every message of the order service. The service sends and receives messages through Amazon Simple Notification Service (SNS) and Amazon Simple Queue Service (SQS).
SNS and SQS are two messaging services of Amazon Web Services (AWS). A topic of SNS sends each message to every subscriber. A
queue of SQS holds messages until one consumer takes them. The table lists the events of
the service. Each event has a JSON schema in the folder contracts/events.
| Direction | Event | Where | Meaning |
|---|---|---|---|
| out | OrderPlacedV1 | SNS topic order-events | an order was placed, with its delivery method and lines |
| out | OrderShippedV1 | SNS topic order-events | an order was shipped, and it can no longer change |
| out | OrderCancelledV1 | SNS topic order-events | an order was cancelled before it shipped |
| in | PaymentReceivedV1 | SQS queue order-service-payment-received | the payment service received money for an order |
A public event is a contract, so its fields change only with care. Here is the schema of
OrderShippedV1:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "order-service/OrderShippedV1",
"title": "OrderShippedV1",
"description": "An order was shipped. Published to the order-events SNS topic.",
"type": "object",
"required": ["eventId", "occurredAt", "orderId"],
"properties": {
"eventId": { "type": "string", "format": "uuid" },
"occurredAt": { "type": "string", "format": "date-time" },
"orderId": { "type": "string", "format": "uuid" }
},
"additionalProperties": true
}The event carries only its own ID, its time and the order ID. A consumer that needs more asks
the order service. The suffix V1 is the version of the contract. For event schemas
and their versions, see the chapter API and event contracts.
The service depends on five other systems. The README says what happens when each one is down.
| Dependency | Used for | When it is down |
|---|---|---|
| DocumentDB | all data | readiness is down, so no traffic arrives, but liveness stays up, so instances are not restarted |
| stock service | placing an order | only POST /orders answers 503, and everything else works |
| SNS | publishing events | events wait in the outbox and are resent |
| SQS | payments | payments wait in the queue |
| token issuer | checking tokens, and the token of the stock call | requests get 401, and placing an order gets 503 |
For each of these, the part has a chapter. For the outbox, see the chapter Publishing events: the outbox. For the payment queue, see the chapter Consuming events: SQS and the inbox.
Three repositories: platform, service and local environmentnew#
The companion is in one folder, but in real life it is three repositories. Each one has its own release cycle.
| Repository | What it holds | Who uses it |
|---|---|---|
| shop-platform | a parent POM, and a library of code that every service needs, but no business code | every shop service, as a versioned dependency |
| order-service | the order service, with its tests, its contracts and its Dockerfile | the team of the order service |
| local-environment | a Docker Compose file with MongoDB, stubs of other services, fake AWS and a token issuer | every developer, and the end-to-end tests of the QA team |
A service names the platform's parent and one dependency, with no version. The parent manages every version. Here is the start of the order service's Project Object Model (POM):
<!--
The platform parent sets Java, the plugins and every dependency version.
In this service's own repository, remove <relativePath>: the parent then comes from the
company's artifact repository.
-->
<parent>
<groupId>com.acme.platform</groupId>
<artifactId>shop-platform-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../shop-platform/shop-platform-parent/pom.xml</relativePath>
</parent>
<groupId>com.acme</groupId>
<artifactId>order-service</artifactId>
<version>1.0.0-SNAPSHOT</version>
<dependencies>
<!-- Everything cross-cutting. No version: the parent manages it. -->
<dependency>
<groupId>com.acme.platform</groupId>
<artifactId>shop-platform</artifactId>
</dependency>The element parent names the platform's parent POM. A POM is the build file of
Maven. It has the same purpose as a .csproj file in .NET. The platform holds no domain classes. Services share contracts,
such as OpenAPI files and event schemas, but never domain classes. A shared domain library would
tie the release of every service to the others. For the parent and the library, see the chapter
The build: a parent POM and a platform library. For how to write the
library, see the chapter Writing the platform library.
The local environment is started once, and then each service runs from its own repository. The README of the service gives the two commands:
cd ../local-environment && docker compose up -d
cd ../order-service && mvn package && docker compose up --buildThe first command, docker compose up -d, starts the shared containers in the
background. The second one builds the
service and starts it in a container. For the local environment in full, see the chapter
Running locally and shipping.
A map of the partnew#
Each chapter of the part adds one layer or one concern to the order service. Read them in order the first time. Later, use this list to find a topic.
- Design. Finding the aggregates, How much DDD: three levels in one service, Features, layers and the rules.
- Build. The build: a parent POM and a platform library.
- The domain. The domain model in plain Java, Where each rule goes.
- Spring itself. Wiring the service: the container in depth, Configuration in depth.
- The way in. Use cases: the application service, The web layer in depth, API and event contracts, Security in depth.
- Storage. Storing aggregates in DocumentDB, Migrations, indexes and seed data, Transactions in depth.
- Other services. Calling other services, Publishing events: the outbox, Consuming events: SQS and the inbox.
- Running it. Logging, tracing and health, Caching and scheduling, Writing the platform library, Testing in depth, Running locally and shipping.
- Living with it. Changing the service, DDD for a team: the playbook.
1Name four conditions that make DDD worth its cost for a service.
2An expert asksA service uses entities with public setters and an outbox. Why can it still lose
an event?
3TranslateIn the order service, write this C# in Java:
order.Status = OrderStatus.Cancelled; order.CancelledAt = clock.GetUtcNow();
order.Status = OrderStatus.Cancelled; order.CancelledAt = clock.GetUtcNow();order.cancel(clock); The order has no setters. cancel refuses a
finished order, sets the status and the time, and records OrderCancelled.4An expert asksWhat makes a DDD service harder to follow, and what keeps it readable?
5Which feature of the order service uses no DDD building blocks, and why?
Finding the aggregates#
An aggregate is a group of data that one rule must see at once, and that is saved together. Outside code changes it only through one object, called its root. Finding the aggregates is the most important design decision of a service built with Domain-Driven Design (DDD). This chapter gives the questions and the tests that guide it, and shows why the order service has the aggregates it has.
| Kind | A usual EF Core model | The order service | Note |
|---|---|---|---|
| new | DbSet<Order> and DbSet<OrderLine>, each saved on its own | Order, with its lines inside, saved as one document | an aggregate is the data that one rule must see, saved together in one transaction Why |
| twist | order.Lines.Single(l => l.Id == lineId).PickedAt = now; | order.pickLine(lineId, clock); | outside code changes a line only through the order, which is the root of the aggregate Why |
| new | no equivalent | seven questions about rules, size, conflicts and life | finding an aggregate needs judgement, but questions and tests guide each step Why |
| new | an Order class with a property for each column | status, delivery method, customer, lines and last payment | each piece is inside because a rule needs it at every change, or because it has the same life Why |
| trap | public List<Note> Notes { get; set; } on Order | notes as an aggregate of their own | a list with no limit grows until saves conflict or the document reaches its size limit Why |
| trap | OrderLine as an aggregate of its own | the lines inside the order | when every change must update several aggregates together, they are one aggregate Why |
| new | no equivalent | move the part with no limit out, or move the small part in | a split keeps the rule with a count or an event, and a migration moves the data Why |
| new | no equivalent | Order and Note, two aggregates | notes have no limit, no order rule needs all of them, they conflict less, and they live differently Why |
| twist | public Order Order { get; set; } on Note | private final String orderId; on Note | a reference by ID never loads or saves the other aggregate Why |
| twist | public long OrderId { get; set; } on Note | String orderId, checked by OrderService.requireExists | the strict style gives a foreign ID its own type, but the light style keeps text Why |
| twist | public record Payment(PaymentReference Reference, Money Amount, DateTimeOffset PaidAt); | record Payment(PaymentReference reference, Money amount, Instant paidAt) | an entity has an identity, and a value object is only its values Why |
| twist | public readonly record struct OrderId(Guid Value); | final class OrderId extends Identifier | a typed ID lets the compiler catch an order ID passed where a line ID belongs Why |
| trap | a check of another table in the same service method | ShippingPolicy, checked when the order ships | a rule across two aggregates holds at the moment of the check, not forever Why |
| new | no equivalent | accept, refuse, make strict, or repair | choose with the business owner, then record the choice in a decision record and a test Why |
| new | a call to another service inside the database transaction | the stock call before the transaction, with an idempotency key | no transaction spans two services, so each step must be safe to repeat Why |
| trap | two services that inject each other | @Lazy OrderService in NoteService | a two-way dependency between features is a warning sign, and it has three ways out Why |
What an aggregate is fornew#
An agent cancels an order. At the same moment, warehouse staff ship it. Each change checks that the order is not finished, and each check passes. If nothing connects the two saves, both succeed, and a cancelled order leaves the warehouse. The rules need the status, the lines and the payment to be checked and saved as one thing.
The answer is to treat the order, its lines and its payment as one unit. The unit is loaded whole, changed by one method that checks the rules, and saved whole in one
transaction. It has one version. So when the agent and the warehouse save the same version, the
second save fails. This
unit is called an aggregate. The object that outside code holds, here
Order, is called the root of the aggregate.
So an aggregate is a consistency boundary. Everything inside it is checked and saved together. Everything outside it is checked and saved on its own. Vaughn Vernon wrote a series of three essays, Effective Aggregate Design, in 2011. Part I says that an aggregate and a transactional consistency boundary are the same thing. Microsoft's guide to .NET microservices gives the same advice. It says that the best way to find the aggregates is to think about the transactions.
The platform's base class of every root states the same idea in its comment:
/**
* The base class of an aggregate root: the only object of an aggregate that outside code may
* hold. Every change to the aggregate goes through the root, which checks the rules.
*
* <p>The root carries two things for the layers around it. The first is the version, which
* optimistic locking needs: the version comes from storage and goes back to storage unchanged,
* and no rule ever reads it. The second is the list of domain events the root raised. The
* application service pulls the events after saving and publishes them in the same transaction.
*
* @param <ID> the typed identifier of the aggregate
*/
public abstract class AggregateRoot<ID extends Identifier> extends Entity<ID> {AggregateRoot is generic in its ID type. <ID extends Identifier>
means that the ID must be a typed identifier, such as OrderId. It is like a
constraint where TId : Identifier in C#. The class holds only the version and the
recorded events. It holds no business idea.
In a document database, an aggregate maps well to one document. The order service stores each
order, with its lines and its last payment, as one document in the collection
orders. One save writes the whole document, so the lines and the status are stored
together. For how
the service stores it, see the chapter Storing aggregates in
DocumentDB.
In a usual EF Core model, each table is an entity, and each entity can be loaded and saved on its own. Nothing in the model says which entities must change together. The aggregate is the decision that the model of EF Core leaves to each service method.
The root is the only way intwist#
public class Order
{
public long Id { get; set; }
public OrderStatus Status { get; set; }
public List<OrderLine> Lines { get; set; } = [];
}
// in a service, or in any other class
order.Lines.Single(line => line.Id == lineId).PickedAt = clock.GetUtcNow();public void pickLine(LineId lineId, Clock clock) {
requireNotFinished();
OrderLine line = lines.stream()
.filter(candidate -> candidate.id().equals(lineId))
.findFirst()
.orElseThrow(() -> new LineNotFoundException(id(), lineId));
line.pick(clock);
raise(new LinePicked(id(), lineId, line.pickedAt()));
}In C#, any class can find a line and set its pick time. Nothing checks that the order is still
open, or that the line was not picked before. In Java, pickLine is the only way to
pick a line. It refuses a finished order, finds the line by its typed ID, picks it and adds the event LinePicked. An unknown line ID throws LineNotFoundException, which
the platform answers with 404.
The line itself cannot be changed from outside its package. Its methods that change it are package-private:
/**
* One line of an order: a product and a quantity that warehouse staff pick.
*
* <p>A line is an entity inside the order aggregate. It has its own identity, so a picker can
* name it, but only the order may change it. That is why {@link #add} and {@link #pick} are
* package-private: code outside this package goes through {@link Order}.
*/
public class OrderLine extends Entity<LineId> {
// ...
/** Creates a new line for an item. Only {@link Order} calls it. */
static OrderLine add(OrderItem item) {
return new OrderLine(LineId.newId(), item.product(), item.quantity(), null);
}
/** Rebuilds a stored line. Only persistence calls it. */
public static OrderLine rehydrate(LineId id, ProductCode product, int quantity, Instant pickedAt) {
return new OrderLine(id, product, quantity, pickedAt);
}
void pick(Clock clock) {
if (isPicked()) {
throw new LineAlreadyPickedException(id());
}
pickedAt = clock.instant();
}add and pick have no access modifier. In Java, no modifier means that
only classes of the same package can call the method. Order is in the same package,
order.domain.model, so it can call line.pick(clock). A controller or a
use case cannot. rehydrate is public, because the mapper in the persistence package
rebuilds stored lines. It applies no rule. For the access levels of Java, see the section
The mapping.
The getter lines() of the order returns a read-only copy. So outside code cannot
add or remove a line either. For the full set of these protections, see the chapter
The domain model in plain Java.
Art or science? Questions you can applynew#
Is finding an aggregate an art or a science? The honest answer is both. It is not a calculation, because it depends on the business. The business decides how long two pieces of data may disagree. Only a business expert can say that. But it is not guessing either. A small set of questions and tests decides most cases. Judgement decides the rest, and the reasons for each judgement can be written down.
Ask these questions about each piece of data, and each rule.
- Which rule must hold after every change? Write the rule in business words, such as "an order ships only when every line is picked". A rule of this kind is called an invariant. Vernon's first rule is "Model True Invariants In Consistency Boundaries". A rule that the business can check later is not an invariant.
- Does the rule need this data at every change? Shipping needs every line. So the lines are inside. Shipping needs only the number of notes that need action, not the notes. So the notes stay outside.
- Is the data small and bounded? An order has at most 50 lines. Notes have no limit. Data inside an aggregate is loaded and saved with every change, so it must have a maximum.
- Whose job is it to make the data consistent? Vaughn Vernon gives a guideline that he learned from Eric Evans, in Part II. If it is the job of the user who runs the use case, make the data consistent in the same transaction. If it is the job of another user, or of the system, let the data become consistent later.
- Do different people change the parts at the same time? Warehouse staff pick lines while agents write notes. Parts that different people change at once conflict if they are saved together.
- Do the parts have the same life? A line is created with its order and never exists without it. A note is written later, by someone else, and is resolved on its own.
- What does outside code need to find? The root is the object that the business names, and that outside code refers to by its ID. Nobody refers to a line without its order. So the order is the root, and the line is inside.
Then test the answer. Each test takes a few minutes.
- Write the rule as a method of the root. If the method needs data that the aggregate does not hold, the boundary is wrong, or the rule belongs to a domain service.
- Count the maximum size. Multiply the largest list by the size of one item. Compare it with the limit of a document, and with what a save should write.
- Draw two users at the same moment. Name two use cases that change the aggregate at once. If they are common and unrelated, they will conflict.
- List the use cases that save it. If most of them also save another aggregate, the two may be one.
The comment of Order writes down the result of these questions, so the next
developer can see the reasons:
/**
* An order: the aggregate root of the order feature.
*
* <p>The aggregate holds what its rules need at every change: the status, the lines and the last
* payment. Notes are a separate aggregate, because no order rule needs every note, and they can
* grow without limit.
*
* <p>The rules on the order's own data:
* <ul>
* <li>An order is placed only if every product is available, with 1 to {@value #MAX_LINES} lines.</li>
* <li>A shipped or cancelled order cannot change, except that payments are still recorded.</li>
* <li>A line is picked once.</li>
* <li>An order ships only when it is paid and every line is picked.</li>
* <li>A payment is recorded only if it is later than the last one, so repeated and late
* messages change nothing. The first payment makes a new order paid.</li>
* </ul>
*
* <p>The rule that no note may need action is across two aggregates, so it lives in
* {@code ShippingPolicy}.
*
* <p>State changes only through these methods. There are no setters, and the time always comes
* from the {@link Clock} the caller passes.
*/The comment names what is inside, what is outside and why. The last paragraph says that every
time comes from the Clock that the caller passes. Keep a comment like this on every root. It is the shortest note of the boundary decision.
These questions come from two principles. One principle says that a transaction protects one place. The other principle says that some rules must hold now and others may hold a little later. For both principles in full, see the chapter The principles.
The order aggregate, piece by piecenew#
Here are the fields of the order:
public class Order extends AggregateRoot<OrderId> {
public static final int MAX_LINES = 50;
private final CustomerContact customer;
private final DeliveryMethod deliveryMethod;
private final Instant placedAt;
private final List<OrderLine> lines;
private OrderStatus status;
private Payment lastPayment;
private Instant shippedAt;
private Instant cancelledAt;extends AggregateRoot<OrderId> makes Order a root with a typed
ID. The final fields never change after the order is placed. The other fields change
only through the methods of the order. The table says why each piece is inside.
| Piece | The rule that needs it | Why it is inside |
|---|---|---|
| status | every method, because a finished order cannot change | every rule reads it, so it must be saved with every change |
| lines | ship needs every line picked, and pickLine needs the line | at most 50, and a line never exists without its order |
| last payment | recordPayment ignores a payment that is not later than the last one | the idempotent check needs the last payment at every payment |
| customer | no rule, only the format of the name and the e-mail address | it is part of the order, and it has the same life |
| delivery method | no rule, only the format, because the reference feature checks the code | it is part of the order, and it never changes |
| notes | the shipping rule needs only the number that need action | outside, because notes have no limit and their own life |
Two pieces have no rule but are still inside. The customer and the delivery method are part of what an order is. They are created with it, they never change, and they are small. A piece with no rule may be inside when it has the same life as the root.
Signs of an aggregate that is too bigtrap#
The easiest model of an order puts everything about it inside. In EF Core, an order would get
a navigation property public List<Note> Notes { get; set; }. In a document
database, the notes would be embedded in the order document. It works in the first demo, and it
fails a year later.
An aggregate with a list that has no limit grows until its saves fail. Each change loads and saves the whole aggregate. So each new note rewrites the whole order, and two agents who add notes at once conflict with each other and with the warehouse.
Watch for four signs.
- A list with no limit. Notes, comments, history entries and messages grow for as long as the root exists. Ask for the maximum. If nobody can name one, the list does not belong inside.
- The size limit of a document. Amazon DocumentDB limits a document to 16 MiB,
as of October 2026. A save of a larger document fails. Long before that limit, each save writes
a large document. Spring Data MongoDB saves a document with
@Versionas a full replacement, not as a change of one field. - Conflicts on save. The aggregate has one version. Two saves of the same
version conflict, and the second one fails. Spring Data throws
OptimisticLockingFailureException. Inside a transaction, MongoDB usually reports a write conflict, which Spring turns intoDataIntegrityViolationException. The platform answers both with 409. If unrelated work often gets 409, the aggregate is too big. - Rules that do not need the data. If shipping loads two thousand notes to count three of them, the notes are inside for no rule.
The stored document of the order says why its lines are inside and its notes are not:
/**
* How an order is stored: one document per aggregate, with its lines and last payment embedded.
*
* <p>The lines are embedded because they are few (at most 50) and every order rule needs them.
* Notes are not: they have no limit, and embedding them would rewrite the whole order for each
* new note. A document must stay under 16 MB.
*
* <p>Only plain types are stored. Enums and codes are strings, so renaming a Java class never
* breaks stored data. Money is DECIMAL128, so it is exact. The class and its fields are
* package-private: nothing outside this package knows the storage shape.
*/
@Document("orders")
class OrderDocument extends BaseDocument {@Document("orders") names the collection. BaseDocument is the
platform's base class of a stored document, with the ID and the version. For the version and the
conflicts in full, see the chapter Storing aggregates in DocumentDB.
Signs of an aggregate that is too smalltrap#
The opposite mistake splits data that one rule needs. Suppose that each order line were an aggregate of its own, saved as its own document. Picking a line would then be simple. But shipping must check every line of the order, and these checks would read up to 50 separate documents. A line picked a moment after the check would not be seen.
The rule that the order checks today needs its lines in the same aggregate:
public void ship(Clock clock) {
requireNotFinished();
if (status != OrderStatus.PAID) {
throw new OrderNotPaidException(id());
}
if (lines.stream().anyMatch(line -> !line.isPicked())) {
throw new UnpickedLinesException(id());
}
status = OrderStatus.SHIPPED;
shippedAt = clock.instant();
raise(new OrderShipped(id(), shippedAt));
}lines.stream().anyMatch(line -> !line.isPicked()) checks every line, inside the
same object and the same save. anyMatch does the same as Any in LINQ.
If a line were outside, this check would need a query, and it would hold only at the moment of the
query.
Watch for these signs of an aggregate that is too small.
- A rule must change several aggregates together, every time. That is the main sign.
- Most use cases save the same two aggregates in one transaction.
- A rule reads many aggregates, and a change between the reads would break it.
- Someone proposes a lock across two aggregates, to make a rule hold.
One use case that saves two aggregates is not a sign by itself. The order service does it once, for a reason. See the section Why order and note are separate aggregates.
How to split or merge an aggregatenew#
A boundary can be wrong, and it can be changed. To split an aggregate that is too big, follow these steps.
- Move the part with no limit into an aggregate of its own, with its own root, its own ID and its own version.
- Give the new aggregate the ID of the old root. Remove the list from the old root.
- Keep each rule that needed the moved part. Give it a number or a yes or no answer, not the
objects. A domain service checks the rule, as
ShippingPolicydoes. - If a rule can wait, react to an event of the other aggregate instead.
- Write a migration that moves the embedded items into the new collection. For migrations, see the chapter Migrations, indexes and seed data.
In the order service, the shipping rule needs one number from the note feature. The note feature answers with a count, and never returns the notes:
/** For the order feature's shipping rule: a plain number, so no note crosses the boundary. */
public long countNeedingAction(String orderId) {
return notes.countByOrderIdAndActionRequiredTrueAndActionResolvedAtIsNull(orderId);
}countNeedingAction returns a long, so no note crosses into the order
feature. The repository method behind it is a derived query:
public interface NoteRepository extends MongoRepository<Note, String> {
Page<Note> findByOrderId(String orderId, Pageable pageable);
/** Counts the notes on an order that still need action. Spring Data reads the rule from the name. */
long countByOrderIdAndActionRequiredTrueAndActionResolvedAtIsNull(String orderId);
}Spring Data reads the method name countByOrderIdAndActionRequiredTrueAndActionResolvedAtIsNull
and builds the query from it. It counts the notes of one order that need action and are not
resolved. For derived queries, see the section Repositories and derived
queries.
To merge two aggregates that are too small, do the opposite. Make one root the owner. Turn the other root into an entity or a value object inside it, and give the owner the methods that change it. Then write a migration that embeds the old documents into their owners.
Why order and note are separate aggregatesnew#
A note is an agent's text about an order, such as "the customer asked us to leave the parcel with a neighbour". Some notes need action. An order cannot ship while one of its notes needs action. So the note and the order are linked by a rule. They are still two aggregates, for four reasons.
- Notes have no limit. An order with a problem can collect hundreds of notes. Inside the order, they would grow the document toward the 16 MiB limit.
- No order rule needs every note. The shipping rule needs one number, the count of notes that need action.
- Fewer conflicts. Each note has its own version. An agent who adds a note does not change the order's version. So the agent never blocks the warehouse, and the warehouse never blocks the agent.
- A different life. An order is placed by the customer and finished by the warehouse. A note is written by an agent at any time, and resolved later by another agent.
One use case saves both kinds of aggregate. An agent can place an order together with its first notes, and the agent sees that as one action. So the order service stores the order and its first notes in one transaction:
public PlacedOrder place(CustomerContact customer, DeliveryMethod deliveryMethod, List<OrderItem> items,
List<NewNote> firstNotes) {
referenceData.requireActive(ReferenceTypes.DELIVERY_METHOD, deliveryMethod.code(), "deliveryMethod");
OrderId orderId = OrderId.newId();
StockAvailability availability = stock.reserve(orderId, items); // outside the transaction
return transaction.execute(status -> {
Order order = Order.place(orderId, customer, deliveryMethod, items, availability, clock);
Order saved = saveAndPublish(order, AuditActions.ORDER_PLACED); // aggregate 1
List<String> noteIds = notes.addNotes(saved.id().value(), firstNotes); // aggregate 2, same transaction
return new PlacedOrder(saved.id().value(), noteIds);
});
}transaction.execute runs the lambda in one
transaction. The order is saved first, and then notes.addNotes saves each note in the
same transaction. If a note is invalid, the whole transaction rolls back, and the order is not
stored either. The test OrderJourneyIT.anOrderAndItsNotesAreStoredTogetherOrNotAtAll
shows it against a real MongoDB. For TransactionTemplate and why the stock call is
outside it, see the chapter Transactions in depth.
Saving two aggregates in one transaction departs from a strict rule. Vernon's Part I says that a well designed bounded context changes only one aggregate in each transaction. His Part II also names reasons to break the rule. One of them is creating several new aggregates in one request, when that means the same as creating them one by one. The order and its first notes are new aggregates of that kind. For the callout of this choice, see the section One request that saves the order and its first notes.
Refer to another aggregate by its IDtwist#
public class Note
{
public long Id { get; set; }
public long OrderId { get; set; }
public Order Order { get; set; } = null!;
public string Text { get; set; } = "";
}@Document("notes")
public final class Note {
public static final int MAX_TEXT_LENGTH = 2000;
@Id
private final String id;
@Version
private Long version;
private final String orderId;
private final String text;
private final String category;
private final boolean actionRequired;
private final Instant createdAt;
private final String createdBy;
private Instant actionResolvedAt;
@Transient
private final List<DomainEvent> events = new ArrayList<>();In C#, the note has an Order navigation property, so code can reach the order
through the note and change it. In Java, the note holds only orderId. It cannot reach
the order. The order holds nothing about its notes. Vernon's Part II names this rule "Reference
Other Aggregates By Identity". Microsoft's guide recommends the same for EF Core. It calls it a good practice to keep only the
foreign key of the other aggregate, with no navigation property to it.
A reference by ID keeps the boundaries real.
- Loading a note never loads an order, and loading an order never loads its notes.
- Saving a note never saves an order. So a change to a note never changes the data of the order.
- Each aggregate keeps its own version, so a note and its order never conflict.
- The note feature never sees the
Orderclass. It asks the order feature, which checks its own rules.
The annotations on Note are from Spring Data. @Document("notes")
names its collection, @Id marks its ID, and @Version marks the version
that detects conflicts. @Transient keeps the list of events out of the stored
document. The note is its own stored document, in the light style. For that style, see the
chapter How much DDD: three levels in one service.
A type for a foreign ID, or plain texttwist#
The note holds the ID of its order as a String. The strict full style would give
it a type of its own, such as OrderRef. It would not reuse OrderId
from the order feature. With its own type, the note's model would depend on nothing in the order
feature. The note feature could change, or move to another service, without the order's
classes.
The order service keeps the text, and lets the owner of the ID check it. When a note is added, the note feature asks the order feature whether the order exists:
/**
* For other features: checks that an order exists. IDs cross between features as text, and
* the owner of the ID checks it, so a bad ID gives the order feature's own 400 or 404.
*/
public void requireExists(String orderId) {
OrderId id = OrderId.of(orderId);
if (!orders.exists(id)) {
throw new OrderNotFoundException(id);
}
}OrderId.of(orderId) checks the format of the text. A bad ID gives the order
feature's own 400, and an unknown one gives its 404. So the check is in one place, the feature
that owns the ID.
The strict rule. Every ID inside a model has a type. An aggregate that refers
to another one has its own type for that ID, such as OrderRef.
What we do. IDs cross between features as text. The note keeps
orderId as a String, and the order feature checks it in
requireExists.
Why. The note feature uses the light style. A type would add a class and a conversion for a value that the note only stores and compares. Text also keeps the two features apart. The note feature imports no class of the order's domain.
When to stop. Add a type when the note's model starts to use the ID in a rule. Add one when a method takes two IDs that could be swapped, or when the feature moves to the full style.
Inside the aggregate: entities and value objectstwist#
public record Money(decimal Amount, string Currency);
public record PaymentReference(string Value);
public record Payment(PaymentReference Reference, Money Amount, DateTimeOffset PaidAt);/**
* A payment that the payment service received for an order. It is a value object made of value
* objects.
*
* @param reference the payment service's reference
* @param amount the amount paid
* @param paidAt when it was paid, which decides which payment is the latest
*/
public record Payment(PaymentReference reference, Money amount, Instant paidAt) implements ValueObject {
public Payment {
Guard.notNull(reference, "paymentReference");
Guard.notNull(amount, "amount");
Guard.notNull(paidAt, "paidAt");
}
}Both panes build a payment from other values, and both compare by value. The Java record checks its values in a compact constructor, the
block public Payment { ... }. Guard.notNull throws an error that names
the field, and the platform answers it with 400. implements ValueObject marks the
intent. The C# records check nothing until you add a constructor.
An aggregate holds two kinds of objects besides its root. An object with an identity of its own is called an entity. An object that is only its values is called a value object.
| Kind | Equal when | Changes | In the order aggregate |
|---|---|---|---|
| entity | it has the same ID, whatever its other data | yes, through the root | OrderLine, which a picker names by its line ID |
| value object | all its values are equal | never, because a new value replaces it | Payment, Money, ProductCode, CustomerContact, DeliveryMethod |
To decide, ask whether anyone must name the object. A picker must name the line that was
picked, so a line is an entity with a LineId. Nobody names a payment. A newer
payment replaces the last one, so a payment is a value object. A payment is built from three
values, a reference, an amount and a time. Two of them are value objects too.
The platform's base class of an entity defines equality by identity:
/**
* The base class of an entity: an object with its own identity.
*
* <p>Two entities are equal when they have the same class and the same ID, even if their other
* data differs. The methods are final, so a subclass cannot change what identity means.
*
* @param <ID> the typed identifier of the entity
*/
public abstract class Entity<ID extends Identifier> {
// ...
@Override
public final boolean equals(Object other) {
if (this == other) {
return true;
}
if (other == null || getClass() != other.getClass()) {
return false;
}
return id.equals(((Entity<?>) other).id);
}
@Override
public final int hashCode() {
return id.hashCode();
}equals is final, so no subclass can change what identity means. Two
lines with the same ID are the same line, even if one was loaded before it was picked. The marker
of a value object is an empty interface:
/**
* Marks a class as a value object: it is defined only by its values, it never changes, and it
* checks its own values when it is created.
*
* <p>The interface has no methods. It documents the intent, and it lets architecture tests find
* value objects. Write value objects as records, so Java generates {@code equals} and
* {@code hashCode} from the values.
*/
public interface ValueObject {
}ValueObject has no methods. A record generates equals and
hashCode from its values, as a C# record does. For value objects and the
Guard checks in full, see the chapter The domain model in plain
Java.
Typed IDs inside the aggregatetwist#
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
}
public readonly record struct LineId(Guid Value);/** The identity of an order: a UUID that the compiler cannot mix up with another ID. */
public final class OrderId extends Identifier {
private OrderId(String value) {
super(value, "orderId");
}
public static OrderId of(String value) {
return new OrderId(value);
}
public static OrderId newId() {
return new OrderId(newValue());
}
}Both panes give each ID its own type, so a method that takes an OrderId refuses a
LineId. In C#, the call does not compile. The
compiler gives error CS1503, and says that it cannot convert from LineId to
OrderId. In Java, the
compiler gives error: incompatible types: LineId cannot be converted to OrderId.
Both messages were produced by compiling the call.
The Java ID is a class, not a record. Its constructor is private, and
OrderId.of and OrderId.newId create it. The base class
Identifier checks that the text is a universally unique identifier (UUID), so every OrderId is valid.
The C# record struct accepts any Guid, including an empty one. For the base class
and why the ID holds text, see the chapter The domain model in plain
Java.
What holds between two aggregates, and what does nottrap#
The shipping rule needs an order and its notes. They are two aggregates, so the rule is
checked by a domain service, ShippingPolicy, when the order ships:
/**
* The rule that needs two aggregates: an order cannot ship while a note on it needs action.
*
* <p>It is a domain service because neither aggregate holds the data alone. It is named after
* the rule, not {@code OrderDomainService}, so unrelated code does not collect in it. It takes a
* number, not the notes, so the order feature never sees note objects. It has no state, no
* repository and no Spring annotation, and the order feature's configuration creates it.
*
* <p>The guarantee holds only at the moment of shipping. An agent may add a note that needs
* action a second after the order shipped. The service accepts that, and the README records the
* decision.
*/
public class ShippingPolicy {
public void assertCanShip(Order order, long notesNeedingAction) {
if (notesNeedingAction > 0) {
throw new OrderHasOpenActionsException(order.id(), notesNeedingAction);
}
}
}assertCanShip takes the order and a number, not the notes. Its comment already
names the problem. Here is the question that every team must answer. Does the rule still hold a
second later?
It does not. Agent A ships an order. At the same moment, agent B adds a note that needs action to the same order. MongoDB detects a write conflict when two transactions write the same document. These two transactions write different documents. So both commit, and a shipped order has a note that needs action.
This is the general rule. Separate aggregates give no lasting guarantee across them. A rule across two aggregates holds at the moment it is checked, and only then. The table lists what the order service guarantees, and what it does not.
| Statement | Guaranteed? | Why |
|---|---|---|
| an order and its first notes are stored together | yes | one transaction, shown by OrderJourneyIT.anOrderAndItsNotesAreStoredTogetherOrNotAtAll |
| a note refers to an order that exists | yes | the note feature checks it, and orders are never deleted |
| an order has every line picked when it ships | yes | the lines are inside the order, and ship checks them in the same save |
| no note needs action when an order ships | only at the moment of shipping | the rule is across two aggregates |
| a note that needs action on a shipped order | it can happen | the note feature checks only that the order exists |
A rule checked by a domain service across two aggregates is a rule about one moment. In a usual ASP.NET Core service, a check of another table in the same method looks like a lasting guarantee. The same race exists there too, because the check reads one record and the change writes another. Say in the code and in the README which rules hold only at the moment they are checked.
Four ways to handle the gap, and how to record the choicenew#
The time between the check and a later change is called the gap in this book. A team has four options. Choose with the business owner, because each one changes what users see.
| Option | What the service does | What it costs |
|---|---|---|
| A, accept | nothing more, and the note is still seen and handled | a shipped order can have a note that needs action |
| B, refuse | the note feature refuses a note that needs action on a shipped order | the note feature must ask for the order's status, and the race at the same moment remains |
| C, make strict | adding such a note also writes the order document, so the two transactions conflict | every note conflicts with every change of its order, as if the notes were inside |
| D, repair | a listener of OrderShipped looks for notes that need action, and alerts someone | more code, and the problem is fixed after it happens |
Option B narrows the gap, but does not close it. Two transactions at the same moment can still both pass. Option C closes the gap, because both transactions write the order. Its cost is the cost of an aggregate that is too big. Option D accepts the gap and adds a safety net.
The companion chose option A. The chance is small, and the result is harmless, because the note is still seen and handled. Closing the gap would need a lock across the two aggregates. The README records the decision:
**The shipping rule holds only at the moment of shipping.** An agent can add a note that needs
action to an order a moment after it shipped. The business owner accepted this: the note is
still seen and handled, and closing the gap would need a lock across the two aggregates.
`NoteServiceTest.aNoteNeedingActionCanStillBeAddedAfterTheOrderShipped` records it. Revisit it
if such notes ever need to stop a shipment.The record names the decision and its reason. It names the test that shows the behaviour,
NoteServiceTest.aNoteNeedingActionCanStillBeAddedAfterTheOrderShipped, and when to
look at it again. A test records the decision in code:
/**
* Records a decision. The shipping rule holds only at the moment of shipping: an agent can
* still add a note that needs action to an order that has shipped, because the note feature
* checks only that the order exists. The team accepted this gap; the README explains why and
* when to close it.
*/
@Test
void aNoteNeedingActionCanStillBeAddedAfterTheOrderShipped() {
String noteId = service.addNote(ORDER_ID, new NewNote("Customer says the parcel is damaged", "COMPLAINT", true));
assertThat(noteId).isNotNull();
verify(orders).requireExists(ORDER_ID);
verify(notes).save(any(Note.class));
}The test adds a note that needs action and checks that it is saved. It also checks that the
note feature asks only requireExists, whatever the status of the order. The test does
not run the race. It records the decision in the code that a change would touch. A developer who
adds option B must change this test, and its comment points to the decision and the business
owner.
For a bigger decision, write an architecture decision record (ADR) beside the code. An ADR is a short file with a title, the context, the decision, and its consequences. Its test is the same kind of test as above.
The strict rule. A rule that must always hold belongs inside one aggregate, where one transaction protects it.
What we do. The rule "no note needs action when an order ships" is checked by
ShippingPolicy at the moment of shipping. A note that needs action can still be added
a moment later.
Why. Putting the notes inside the order would make the order too big. Option C would make every note conflict with its order. The business owner accepted the gap, because the late note is still seen and handled.
When to stop. Revisit the decision when a note that needs action must stop a shipment, or when late notes on shipped orders become common.
Between services: no shared transactionnew#
An aggregate is a boundary inside one service. Between two services, the boundary is wider. No transaction spans two services, because each one has its own database. When the order service places an order, it asks the stock service to hold the products. The two changes cannot commit together.
So each step that crosses services must be safe to repeat, and the flow must end in a known state if a step fails. The order service makes the stock call before its own transaction, and sends the order ID as an idempotency key:
/**
* The port to the stock service, in this service's types. The use case does not know that the
* answer comes over HTTP, and a unit test replaces it with a mock.
*
* <p>It lives in {@code application/port}, not in the domain: calling another service is a step
* of the use case, and the domain only judges the answer.
*/
public interface StockChecker {
/**
* Asks the stock service to confirm that the items are available, and to hold them for the
* order. The stock service keeps the hold until it sees {@code OrderPlacedV1}, or releases it
* after a timeout if the order is never placed.
*
* <p>Calling it twice for the same order holds nothing twice, because the order ID is the
* idempotency key.
*/
StockAvailability reserve(OrderId order, List<OrderItem> items);
}reserve may be called twice for the same order, and the stock service holds the
products once. If the order's transaction fails after the call, no OrderPlacedV1 is
published. The stock service then releases the hold after its timeout. Each service stays consistent on its own, and the services agree after a while. This is called eventual
consistency.
A flow with several such steps, each with its own undo, is called a saga. The order service has none, because one hold with a timeout is enough. Consider a saga, or a workflow engine that runs it, when a flow crosses several services and each failure needs its own undo. For the stock call in full, see the chapter Calling other services.
Two features that call each othertrap#
The shipping rule makes the order feature ask the note feature for a count. Adding a note makes the note feature ask the order feature whether the order exists. So each feature's service needs the other one. With constructor injection alone, Spring cannot create either bean first.
The companion breaks the cycle with @Lazy:
public NoteService(NoteRepository notes, @Lazy OrderService orders, ReferenceDataService referenceData,
DomainEventPublisher events, AuditLog audit, CurrentActor currentActor, Clock clock) {
this.notes = notes;
this.orders = orders;
this.referenceData = referenceData;
this.events = events;
this.audit = audit;
this.currentActor = currentActor;
this.clock = clock;
}@Lazy on the parameter makes Spring inject a proxy
in place of the real OrderService. The proxy finds the real bean at its first call.
So Spring can create NoteService before OrderService exists. For how
Spring resolves such cycles, see the chapter Wiring the service: the container
in depth.
A two-way dependency between features is a warning sign, even when the service starts
without an error. @Lazy hides the cycle at startup. The two features still cannot
change, be tested or move to another service apart.
There are three ways out.
- Make one dependency one-way. The note controller could ask the order feature whether the order exists, before it calls the note service. Then the note service needs nothing from the order feature.
- React to an event. A feature that only needs to know that something happened can listen to an event, instead of calling.
- Merge the two features. If the two features always change together, they are one feature.
The companion keeps the cycle, and its README lists these ways out. A test of the architecture fails when a feature uses anything of another feature except its application package. For the rules of the layers, see the chapter Features, layers and the rules.
1Which two questions decide whether a piece of data belongs inside an aggregate?
2An expert asksWhy are notes an aggregate of their own, and not a list inside the order?
3An expert asksAn order ships after a check that no note needs action. Why can a note that needs
action still exist on the shipped order?
4TranslateIn the order service, write this C# in Java:
order.Lines.Single(l => l.Id == lineId).PickedAt = clock.GetUtcNow();
order.Lines.Single(l => l.Id == lineId).PickedAt = clock.GetUtcNow();order.pickLine(lineId, clock); The line is changed only through its root. The
ID lineId is a LineId. The clock is a java.time.Clock.5Name the four options for a rule across two aggregates that holds only at the moment of the
check.
6What is the main sign of an aggregate that is too small?
How much DDD: three levels in one service#
Not every feature needs the full model of Domain-Driven Design (DDD). The order service uses three levels side by side. The order feature uses the full style, the note feature a light style, and the reference feature the simple level. This chapter shows each level in code, what the light style drops and keeps, and the sign that a feature needs the next level.
| Kind | A usual ASP.NET Core service | The order service | Note |
|---|---|---|---|
| new | one style for every feature | full for order, light for note, simple for reference | each feature gets the least DDD that still protects its rules Why |
| twist | public interface IOrderRepository | interface OrderRepository extends Repository<Order, OrderId> | the full style hides storage behind a port, with a document class and a mapper Why |
| twist | an EF Core entity with private setters and methods | Note, a rich entity that is also its @Document | the light style keeps the rules of the entity and drops the mapping Why |
| twist | public record ReferenceItem(string Id, string Type, string Code, string Label, bool Active); | record ReferenceItemDocument(@Id String id, String type, String code, String label, boolean active) | data with no rules needs no aggregate, no events and no domain model Why |
| new | no equivalent | no mapper, no port, no typed IDs, and domain services only across aggregates | the light style drops the parts that protect against changes of storage and coupling Why |
| new | public setters on the entity | state-change methods, recorded events and checked values | the light style keeps every part that protects a rule Why |
| trap | catch DbUpdateConcurrencyException, then retry with the same entity | load the note again after a failed save | Spring Data writes the new version into the object even when the save fails Why |
| twist | [NotMapped] public List<object> Events { get; } = []; | @Transient private final List<DomainEvent> events | the list of events is never stored, and a test without a database proves it Why |
| twist | an Equals override that compares Id | Entity.equals, or Note.equals on its String id | the full style compares class and typed ID in a base class, but the light entity writes equals itself Why |
| twist | a private constructor that EF Core binds | a constructor marked @PersistenceCreator | Spring Data loads through a constructor that applies no rules, and create applies them Why |
| new | no equivalent | questions about rules, stored shape and other services | choose the level for each feature, and name it where the architecture tests can see it Why |
| new | no equivalent | a rule appears, or the stored shape starts to bend the model | each level has a sign that the feature has outgrown it Why |
Three levels in one servicenew#
A team that learns DDD often uses the full model everywhere. It makes every feature an aggregate. A lookup list then gets an aggregate root, a typed ID, a document class, a mapper and a port. That is five files for a list that never changes. The opposite mistake gives the core business object no protection at all.
The answer is to choose the level for each feature. A feature gets the least DDD that still protects its rules. The order service shows three levels side by side, so a reader can compare them in one codebase.
| Level | Feature | What it has | What it protects |
|---|---|---|---|
| full | order | a plain-Java domain with base classes, typed IDs, a separate document class and a mapper, a repository port with an adapter, and the stock service behind a port | the rules, and the model against changes of storage and of other services |
| light | note | a rich entity with its state-change methods, rules and events, which is also the stored document, a Spring Data repository used directly, and String IDs | the rules of the entity |
| simple | reference | a document record, a Spring Data repository with a cache, and the logic in the application service | the one rule that a code must be active, written once |
The architecture tests of the platform know the three levels. The comment of the rules says so:
* <p>The rules allow three styles of feature. A full-style feature has a plain-Java
* {@code domain} package with ports and adapters. A light-style feature keeps a rich entity that
* is also its stored document, in a {@code model} package. A simple feature has no domain model
* at all. Rules about the {@code domain} package therefore affect only full-style features, and
* the rules that keep the application layer free of adapters take the full-style features as a
* parameter.The package name tells the level. A full-style feature has a domain package. A
light-style feature has a model package. A simple feature has no package of either kind. So one rule
can check that every domain package is plain Java, and it does not touch the light
or simple features.
The full level: the order featuretwist#
public interface IOrderRepository
{
Task<Order?> FindAsync(OrderId id);
Task<Order> SaveAsync(Order order);
Task<bool> ExistsAsync(OrderId id);
Task<IReadOnlyList<Order>> FindByStatusAsync(OrderStatus status, int page, int size);
}/**
* The port for storing orders, in the domain's own types. The persistence package implements it.
* Lists are sorted newest first.
*/
public interface OrderRepository extends Repository<Order, OrderId> {
/** Loads the order, or throws the 404 exception, so a use case loads in one line. */
default Order get(OrderId id) {
return findById(id).orElseThrow(() -> new OrderNotFoundException(id));
}
PageResult<Order> findAll(PageQuery page);
PageResult<Order> findByStatus(OrderStatus status, PageQuery page);
}Both panes declare a repository in the types of the domain, with no database type.
The Java interface extends the platform's Repository<Order, OrderId>. That
interface declares the three methods that every aggregate needs, to find, save and check one. The
default method get loads an order or throws the 404 exception, so a use case loads in one line.
An interface that the inner code declares, and the outer code implements, is called a port. The class that implements it with a real technology is called an adapter. Here is the adapter of the order repository:
/**
* Implements the domain's {@link OrderRepository} port with MongoDB. The base class supplies
* load, save and exists. This class adds only the order's own queries, each backed by an index
* in migration {@code V001}.
*/
@Component
class OrderPersistenceAdapter extends MongoAggregateRepository<Order, OrderId, OrderDocument> implements OrderRepository {
private static final Sort NEWEST_FIRST = Sort.by(Sort.Direction.DESC, "placedAt");
private final OrderMongoRepository orderDocuments;
OrderPersistenceAdapter(OrderMongoRepository orderDocuments, OrderDocumentMapper mapper) {
super(orderDocuments, mapper);
this.orderDocuments = orderDocuments;
}
@Override
public PageResult<Order> findAll(PageQuery page) {
return toPageResult(orderDocuments.findAll(pageable(page, NEWEST_FIRST)));
}
@Override
public PageResult<Order> findByStatus(OrderStatus status, PageQuery page) {
return toPageResult(orderDocuments.findByStatus(status.name(), pageable(page, NEWEST_FIRST)));
}
}MongoAggregateRepository is the platform's base class of an adapter. It implements findById, save and exists with a
Spring Data repository and the mapper. The adapter adds only
the order's own queries. It is package-private, so nothing outside the persistence package can use
it. The use case sees only OrderRepository.
The full level separates the model from its stored shape. The domain class Order
holds the rules. The class OrderDocument holds what is stored, as plain types. The
mapper copies one into the other. So each side can change without the other. A field can be
renamed in the domain, and the stored name stays. Money can be stored as Decimal128, and the domain
keeps its Money type with a currency. For the document and the mapper in full, see the
chapter Storing aggregates in DocumentDB.
The light level: the note featuretwist#
public class Note
{
private Note(Guid id, Guid orderId, string text, bool actionRequired)
{
Id = id;
OrderId = orderId;
Text = text;
ActionRequired = actionRequired;
}
public Guid Id { get; private set; }
public Guid OrderId { get; private set; }
public string Text { get; private set; }
public bool ActionRequired { get; private set; }
public DateTimeOffset? ActionResolvedAt { get; private set; }
[NotMapped]
public List<object> Events { get; } = [];
public static Note Create(Guid orderId, string text, bool actionRequired) =>
new(Guid.NewGuid(), orderId, text, actionRequired);
public void ResolveAction(TimeProvider clock)
{
if (!ActionRequired)
throw new InvalidOperationException("The note needs no action.");
if (ActionResolvedAt is not null)
throw new InvalidOperationException("The action is already resolved.");
ActionResolvedAt = clock.GetUtcNow();
Events.Add(new NoteActionResolved(Id, OrderId, ActionResolvedAt.Value));
}
}@Document("notes")
public final class Note {
public static final int MAX_TEXT_LENGTH = 2000;
@Id
private final String id;
@Version
private Long version;
private final String orderId;
private final String text;
private final String category;
private final boolean actionRequired;
private final Instant createdAt;
private final String createdBy;
private Instant actionResolvedAt;
@Transient
private final List<DomainEvent> events = new ArrayList<>();
@PersistenceCreator
Note(String id, Long version, String orderId, String text, String category, boolean actionRequired,
Instant createdAt, String createdBy, Instant actionResolvedAt) {
this.id = id;
this.version = version;
this.orderId = orderId;
this.text = text;
this.category = category;
this.actionRequired = actionRequired;
this.createdAt = createdAt;
this.createdBy = createdBy;
this.actionResolvedAt = actionResolvedAt;
}
/**
* Writes a new note on an order. The caller has checked that the order exists and that the
* category is an active lookup value. {@code createdBy} is the ID of the agent who writes it.
*/
public static Note create(String orderId, String text, String category, boolean actionRequired,
String createdBy, Clock clock) {
Guard.notBlank(orderId, "orderId");
Guard.notBlank(text, "text");
Guard.maxLength(text, MAX_TEXT_LENGTH, "text");
Guard.matches(category, "[A-Z_]{1,50}", "category");
Guard.notBlank(createdBy, "createdBy");
Note note = new Note(UUID.randomUUID().toString(), null, orderId, text, category, actionRequired,
clock.instant(), createdBy, null);
note.events.add(new NoteAdded(note.id, orderId, note.createdAt));
return note;
}
/**
* Resolves the action. The agent who wrote the note may not resolve it, so a second person
* checks the work. The use case passes the actor's ID in as a plain value, so the note never
* reads the security context.
*/
public void resolveAction(String actorId, Clock clock) {
if (!actionRequired) {
throw new NoteHasNoActionException(id);
}
if (actionResolvedAt != null) {
throw new NoteActionAlreadyResolvedException(id);
}
if (actorId.equals(createdBy)) {
throw new NoteResolvedByItsAuthorException(id);
}
actionResolvedAt = clock.instant();
events.add(new NoteActionResolved(id, orderId, actionResolvedAt));
}@Document("notes")This Spring Data annotation makes the entity its own stored document, in the collection
notes. There is no separate document class and no mapper.@VersionThis field holds the version that detects a conflicting save. It is not
final, because Spring Data writes the new version into the object when it saves.@TransientSpring Data leaves this field out of the stored document. The list of recorded events is kept only in memory.
@PersistenceCreatorSpring Data calls this constructor when it loads a stored note. The constructor applies no rules, because loading is not a business action.
public static Note create(New notes come from this factory method. It checks the values with
Guard, creates a new ID, and adds the eventNoteAdded.public void resolveAction(String actorId, Clock clock)The only way to resolve a note. It checks its three rules, sets the time, and adds the event. The third rule compares the agent who acts with the author, so the use case passes the actor's ID in as a plain value. The C# pane does not have this rule.
Both panes are one class that is the model and the stored shape at once. EF Core can do the same,
and its documentation says how. EF Core calls a constructor with parameters when their names and
types match mapped properties. The page says that the constructor may be private, or have any other access level. It also says
that EF Core treats a property with a private setter as one it can write, so the property is
still mapped. The code above was run with EF Core 10.0.8. EF Core mapped the five properties with private setters, and left out
Events.
So the light style has a close match in .NET. The C# developer who writes entities with private
setters and methods already writes it. The Java version marks the constructor with @PersistenceCreator. C# relies on a
naming convention. Java also marks each field that is not stored.
The full level keeps the document and the model apart, so storage and domain can change independently. The light level gives that up. It is the right trade when the stored shape and the model are the same, and the rules are few. The note has nine stored fields, all of plain types.
The simple level: the reference featuretwist#
public record ReferenceItem(string Id, string Type, string Code, string Label, bool Active);/**
* One lookup value, as stored in {@code reference_data}. A record is enough: the data is
* read-only, so it needs no version and no behaviour.
*
* @param type the list it belongs to, such as {@code DELIVERY_METHOD}
* @param code the value that other data stores, such as {@code EXPRESS}
* @param label the text a user sees
* @param active whether new data may use it; old values stay, because stored orders still use them
*/
@Document("reference_data")
public record ReferenceItemDocument(@Id String id, String type, String code, String label, boolean active) {
}Both panes are one record with five values. The Java record is the stored document, with
@Document and @Id. It has no version, because nothing changes it, and no
methods, because it has no behaviour. Lookup values change only with a deployment, through a seed
migration.
A feature at this level has no aggregate, no domain model and no events. The repository reads each list and caches it:
/**
* Reads lookup values, and caches each list by its type.
*
* <p>The cache is on the repository, not on the service, for a reason. {@code @Cacheable} works
* through a proxy around the bean, so only calls from another bean pass through it. The service
* calls this method from two of its own methods; if the cache were on the service, the call from
* {@code requireActive} to {@code list} would skip the proxy and the cache.
*
* <p>The cached list is shared by every caller, so callers must not change it.
*/
public interface ReferenceItemRepository extends MongoRepository<ReferenceItemDocument, String> {
@Cacheable("reference-data")
List<ReferenceItemDocument> findByTypeOrderByCodeAsc(String type);
}@Cacheable("reference-data") keeps the result of each call in the cache
reference-data, keyed by the type. The comment explains why the cache is on the
repository and not on the service. For caching in full, see the chapter
Caching and scheduling.
The feature still has one rule. A code that another feature stores must be an active lookup value. The rule is written once, in the application service:
/**
* Throws a 400 for the field unless the code is an active value of the list.
*
* @param field the request field the client must correct, such as {@code deliveryMethod}
*/
public void requireActive(String type, String code, String field) {
boolean known = items.findByTypeOrderByCodeAsc(type).stream()
.anyMatch(item -> item.code().equals(code) && item.active());
if (!known) {
throw new InvalidValueException(field, "is not a known value");
}
}requireActive throws an InvalidValueException that names the field, so
the client gets a 400 that says which value to correct. The order and note features call it before
they create anything. A rule about input in one place, with no state to protect, needs no
aggregate.
The strict rule. The stored document and the model are separate classes, so each can change without the other.
What we do. One record, ReferenceItemDocument, is the stored
document. The service turns it into a view for callers, and no domain class exists.
Why. Lookup data has no behaviour and no state changes. A domain class and a mapper would copy five fields into five fields, and protect no rule.
When to stop. Add a model when a lookup value gets a rule of its own. An example is "a delivery method can be deactivated only when no open order uses it". Add one too when users start to change the values at run time.
What the light style dropsnew#
The light style drops four parts of the full style. Each part protects against something, so each drop has a cost.
| Dropped | The full style has | The light style does | What is lost |
|---|---|---|---|
| separate document and mapper | OrderDocument and OrderDocumentMapper | annotations on the entity, and @PersistenceCreator for loading | the model and the stored shape must stay the same |
| repository port | OrderRepository, with an adapter | the Spring Data interface NoteRepository, injected into the service | a test mocks a Spring Data interface, and the service sees Spring types |
| typed IDs | OrderId and LineId, checked at every boundary | String IDs, not checked, so an unknown ID is simply not found | the compiler cannot catch a swapped ID |
| domain services, except across aggregates | ShippingPolicy, for the rule across order and notes | no domain service, because the note's rules need only the note | nothing, because the note has no rule across aggregates |
A domain service is never a default. The full style also has one only where a rule needs two aggregates. So the fourth row is not a real drop. It shows that the light style does not add a service out of habit.
The use case of the note feature uses the Spring Data repository directly. Its test mocks that interface:
/**
* The note use cases. In the light style the service uses a Spring Data repository directly,
* so the test mocks that interface instead of a port.
*/
class NoteServiceTest {
private static final String ORDER_ID = UUID.randomUUID().toString();
private static final String AUTHOR = "agent-1";
private final NoteRepository notes = mock(NoteRepository.class);
private final OrderService orders = mock(OrderService.class);
private final ReferenceDataService referenceData = mock(ReferenceDataService.class);
private final DomainEventPublisher events = mock(DomainEventPublisher.class);
private final AuditLog audit = mock(AuditLog.class);
private String actingAgent = "agent-2";
private final CurrentActor currentActor = () -> actingAgent;
private final NoteService service = new NoteService(notes, orders, referenceData, events, audit, currentActor, CLOCK);mock(NoteRepository.class) creates a Mockito mock of the Spring Data interface. The
test needs no Spring context and no database. So the missing port costs little in testing. The
real cost is that the service depends on MongoRepository, a type of Spring Data, and
on its paging types.
The strict rule. The domain model is plain Java, with no framework type in it.
What we do. Note carries five annotations of Spring Data, such as
@Document and @Version. Its package is
called model, not domain, so the rule "a domain package is plain Java" does
not apply to it.
Why. The note's stored shape and its model are the same. A separate document and a mapper would copy nine fields, and the annotations change nothing in the rules.
NoteTest still tests every rule with plain JUnit.
When to stop. Move to the full style when the stored shape starts to bend the model. One sign is a field kept only for storage. Others are a type that Spring Data cannot store as it is, and a rule that has to work around an annotation.
The strict rule. The domain declares a repository port in its own types, and an adapter implements it.
What we do. NoteService receives NoteRepository, a
Spring Data interface, and calls it directly.
Why. The note feature has two queries of its own, and Spring Data writes both from their method names. A port and an adapter would add two classes that only forward calls.
When to stop. Add a port when a query needs MongoTemplate, when a
second store appears, or when the service starts to build Spring Data queries itself.
The strict rule. Every ID is a type of its own that checks its format, as
OrderId does.
What we do. A note's ID is a String that holds a universally unique identifier (UUID). It is
created by UUID.randomUUID() and never checked. An ID in a bad format is simply not
found, and the client gets 404.
Why. The methods of Note take at most one ID.
NoteService.resolveAction takes two, the order ID and the note ID. If a caller swaps
them, the note is not found, and the answer is 404. A 404 for a bad ID is a correct answer.
When to stop. Add a typed ID when a swap of two IDs could change the wrong note. Add one too when a bad ID must give 400, not 404.
What the light style keepsnew#
The light style keeps every part that protects a rule. These parts are cheap, and they are the reason to use DDD at all.
- State-change methods with their rules. A note is resolved only through
resolveAction, which checks that the note needs action and that it is not resolved yet. - No state setters. The fields that never change are
final. The two that change, the version and the time of resolution, have no setter. - The entity records its events.
createrecordsNoteAdded. The methodresolveActionrecordsNoteActionResolved. The use case publishes them when it saves. - Checked values.
createchecks the text and the category withGuard, so no new note has a blank text or a bad code.
A test of the architecture keeps the second point true. It treats a light-style entity like a domain entity:
/**
* An entity changes only through methods that apply its rules, so it has no public setter.
* This covers domain entities and the entities of light-style features, which are documents
* outside a persistence package. A persistence document, which only holds stored data, may
* have setters.
*/
public static ArchRule entitiesHaveNoPublicSetters() {
DescribedPredicate<JavaClass> entities = assignableTo(Entity.class)
.or(DescribedPredicate.describe("light-style entities",
(JavaClass type) -> type.isAnnotatedWith(Document.class)
&& !type.getPackageName().contains(".persistence")));
return methods().that().areDeclaredInClassesThat(entities)
.and().haveNameMatching("set[A-Z].*")
.should().notBePublic()
.allowEmptyShould(true)
.as("Entities have no public setter");
}The rule finds every class that extends Entity, and every class with
@Document outside a persistence package. Then it fails for a public method whose name
starts with set. A persistence document such as OrderDocument may have
setters, because it holds no rule.
The middle ground of the chapter Why DDD, and the service we build also
names value objects for IDs, money, dates and codes. The note keeps only part of that. Its category is a plain String that
create checks. Its time is an
Instant, which is already a value. A code that is used in one place may stay text with
a check. A code that is used in several places becomes a value object, as ProductCode is in
the order feature. So does a code with a rule of its own.
Versions after a savetrap#
The light style forces four differences from the full style. The first one is about the
version.
In the full style, the version of an aggregate is final. A save returns a new object
with the new version, and the use case continues with that object. The platform's repository port
says so:
/**
* Saves the aggregate and returns the stored copy, which holds the new version.
*
* <p>Continue with the returned instance. The instance you passed in keeps the old version,
* so saving it a second time fails. For a new aggregate it fails as a duplicate insert, with
* {@code DuplicateKeyException}; for a loaded one, as a concurrent update. Saving also fails
* with Spring's {@code OptimisticLockingFailureException} when someone else saved the
* aggregate after you loaded it; that exception is unchecked, so this plain-Java interface
* can only name it here.
* Inside a transaction, MongoDB usually reports the same conflict as a write conflict, which
* Spring translates to {@code DataIntegrityViolationException}. The platform answers both with 409.
*
* <p>Never retry a failed save with the same instance. Load the aggregate again, apply the
* change again, and save that.
*/
A save(A aggregate);The comment of A save(A aggregate) says to continue with the returned instance. In
the light style, Spring Data saves the note in place. It writes the new version into the same
object. The note service relies on that:
/**
* Saves, publishes and audits. Spring Data saves this mutable object in place and sets its
* new version, so the same instance stays usable. The events are pulled first, because they
* are not stored.
*/
private void saveAndPublish(Note note, String auditAction) {
List<DomainEvent> raised = note.pullEvents();
notes.save(note);
events.publish(raised);
audit.record(auditAction, AuditActions.NOTE, note.id());
}notes.save(note) returns the same object for Note, and sets the new
version on it. So the service ignores the result. The test
NoteRepositoryIT.saveWritesTheNewVersionIntoTheSameInstance checks this against a real
MongoDB.
A failed save of a light-style entity still changes its version. Spring Data increments the version on the object before it sends the update. When the save fails because someone else saved first, the object already holds the version that the other save stored. A C# habit is to catch the concurrency exception and retry with the same object. Here, that retry passes the version check, and it overwrites the other change without an error. Load the note again, apply the change again, and save that.
A test of the companion shows the trap against a real MongoDB:
@Test
void rejectsAStaleCopyAndStillChangesItsVersion() {
Note saved = notes.save(Note.create(ORDER_ID, "Call back", "DELIVERY", true, "agent-1", CLOCK));
Note firstCopy = notes.findById(saved.id()).orElseThrow();
Note secondCopy = notes.findById(saved.id()).orElseThrow();
firstCopy.resolveAction("agent-2", CLOCK);
notes.save(firstCopy);
secondCopy.resolveAction("agent-2", CLOCK);
assertThatThrownBy(() -> notes.save(secondCopy)).isInstanceOf(OptimisticLockingFailureException.class);
// Spring Data wrote the next version into the object before the update failed, so the
// failed copy must not be saved again: load the note afresh instead.
assertThat(secondCopy.version()).isEqualTo(1L);
}Two copies of the same note are loaded, and the first one is saved. The save of
secondCopy fails with OptimisticLockingFailureException. But
secondCopy.version() is now 1, the version that the first save stored. A second save of that object would match the stored version. So it would overwrite the first
change without an error. The comment of
NoteService warns about this too. For versions and conflicts in full, see the chapter
Storing aggregates in DocumentDB.
Events: a base class or a list that is not storedtwist#
In the full style, the base class AggregateRoot keeps the list of events, with
raise and pullEvents. The light entity has no base class. It keeps its own
list, marked @Transient, and its own pullEvents. A test checks the stored
shape without a database:
@Test
void theRaisedEventsAreNotStored() {
Note note = Note.create(UUID.randomUUID().toString(), "Call back", "DELIVERY", true, "agent-1", CLOCK); // has an event
Document stored = new Document();
converter.write(note, stored);
assertThat(stored).containsKeys("_id", "orderId", "text", "category", "actionRequired", "createdAt", "createdBy")
.doesNotContainKey("events");
}converter.write(note, stored) runs the same converter that Spring Data uses to
store a note. The test then checks the keys of the result. The stored document has
_id and the other fields, and no key events. In C#,
[NotMapped] has the same purpose for EF Core. The run of the C# example above showed
that EF Core left Events out of the model.
Equality: a base class or its own equalstwist#
In the full style, the base class Entity compares the class and the typed ID, and
its equals is final. The light entity writes its own:
/** Two notes are the same note when they have the same ID, as for any entity. */
@Override
public boolean equals(Object other) {
return other instanceof Note note && id.equals(note.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}other instanceof Note note checks the type and names the cast value
note in one step, as a type pattern does in C#. Two notes with the same ID are the
same note. NoteTest.twoNotesWithTheSameIdAreEqual builds a second copy of a note, as loading
would, with another version. It checks that the two copies are equal. The class is final, so no
subclass can break the comparison.
Creating and loadingtwist#
In the full style, an aggregate has two factory methods. place or
create is a business action. It applies the rules and records an event.
rehydrate rebuilds a stored aggregate for the mapper. It applies no rules and records
nothing.
The light entity has create too. For loading, it has a constructor marked
@PersistenceCreator, which Spring Data calls. This test loads a note from a stored
document:
@Test
void loadingUsesThePersistenceCreatorAndRaisesNoEvents() {
String orderId = UUID.randomUUID().toString();
Document stored = new Document("_id", "note-1").append("version", 4L).append("orderId", orderId)
.append("text", "Call back").append("category", "DELIVERY").append("actionRequired", true)
.append("createdAt", java.util.Date.from(NOW)).append("createdBy", "agent-1");
Note loaded = converter.read(Note.class, stored);
assertThat(loaded.id()).isEqualTo("note-1");
assertThat(loaded.version()).isEqualTo(4L);
assertThat(loaded.needsAction()).isTrue();
assertThat(loaded.pullEvents()).isEmpty();
}converter.read(Note.class, stored) builds a note through the constructor. The loaded
note has the stored ID and version, and it has no events. So loading through
@PersistenceCreator works like rehydrate. The constructor is
package-private, so code outside note.model cannot call it to skip
create. EF Core finds such a constructor by convention. Spring Data uses the one that
@PersistenceCreator marks.
Choosing the level for each featurenew#
Choose the level when a feature is designed, and write it down. Ask these questions in order.
- Does the feature change state under business rules? If not, use the simple level. Lookup lists, reports and read-only views are in this group.
- Are its rules about its own data, and is its stored shape the same as its model? If yes, use the light level. In the book's view, many features of a service are here.
- Does any of these apply? The stored shape and the model differ. The model needs types that Spring Data cannot store as they are. The feature calls another service, or holds the most important rules of the business. If yes, use the full level.
In the order service, the order is the core of the business. It holds money, it calls the stock service, and its stored shape differs from its model. So it is full. The note has three rules about its own data. So it is light. The reference feature only lists and checks values. So it is simple.
The architecture test names the full-style features, so the build checks the level:
/** The features in the full style, whose application layer must use ports only. */
private static final String ORDER = BASE + ".order";
// ...
@Test
void applicationOfFullStyleFeaturesUsesNoAdapter() {
ArchitectureRules.applicationDoesNotUseAdapters(ORDER).check(CLASSES);
}ORDER names the one full-style feature. The rule
applicationDoesNotUseAdapters then fails if the order's application package uses an
adapter, HTTP or MongoDB. A feature that moves to the full style is added to this list. For who
decides the level in a team, see the chapter DDD for a team: the
playbook.
The sign that a feature needs the next levelnew#
A level is not chosen forever. Each one has a sign that the feature has outgrown it. The comment of the note package names the sign for the light style:
/**
* Notes that customer-service agents keep on an order. Some notes need action, and an order
* cannot ship until every such note is resolved.
*
* <p>This feature uses the light style, to compare with the full style of the order feature.
* {@code Note} is still a rich entity: it holds its rules, changes only through its methods and
* raises its events. But it is also the stored document, with Spring Data annotations on it, and
* the application service uses a Spring Data repository directly. There is no port, no adapter
* and no mapper, and IDs are plain strings. The package is called {@code model}, not
* {@code domain}, because it is not plain Java.
*
* <p>The light style fits a small aggregate whose stored shape and model are the same, and whose
* rules are few. Move to the full style when the stored shape starts to bend the model, or the
* model needs types that Spring Data cannot store as they are.
*/
package com.acme.orderservice.note;The last paragraph of the comment above package com.acme.orderservice.note; names two
signs. The table gives the signs for each move.
| Move | The sign | What to add |
|---|---|---|
| simple to light | a value gets a rule, or users start to change the data | an entity with state-change methods, rules and events |
| light to full | the stored shape starts to bend the model | a domain package, a document class and a mapper |
| light to full | the model needs types that Spring Data cannot store as they are | a document of plain types, and a mapper that converts them |
| light to full | the feature starts to call another service | a port in the application package, and an adapter |
| light to full | a rule across aggregates appears | a domain service named after the rule |
| full to light | the mapper only copies fields of the same names and types | consider removing the document class and the mapper |
Move a feature to the next level in one change, with its tests. The domain tests stay the same, because the rules do not change. Only the persistence and its tests change. A feature can also move down. If every line of a mapper copies a field to a field of the same name, the separate document protects nothing.
1What does the light style keep from the full style?
2What does the light style drop?
3An expert asksA save of a light-style note fails with a conflict. Why must the service not
retry with the same object?
4TranslateWrite the C# property [NotMapped] public List<object> Events { get; } = [];
as a field of a Spring Data document in Java.
[NotMapped] public List<object> Events { get; } = [];
as a field of a Spring Data document in Java.@Transient private final List<DomainEvent> events = new ArrayList<>();
@Transient is from org.springframework.data.annotation. It keeps the field
out of the stored document.5What is the sign that a light-style feature needs the full style?
Features, layers and the rules#
The order service puts one folder for each business feature at the top. Every feature has
the same layers inside, such as domain, application and
api. A short list of rules says which layer may use which. Tests check the rules in
every build. This chapter explains the layout, what goes where, why the book prefers it to
other layouts, and when another layout is better.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | no equivalent | the service's folders, from pom.xml to src/test | every service has the same files in the same places, so a developer finds a class without searching Why |
| twist | Program.cs | OrderServiceApplication with @SpringBootApplication | the class must be in the base package, because Spring scans that package and every package below it Why |
| new | folders by technology, such as Controllers and Services | top-level packages by feature: order, note, reference | one change touches one folder, and a feature can later move to its own service Why |
| new | no equivalent | the same sub-packages in every feature | domain, application, api, persistence, client and messaging, and a feature leaves out what it does not need Why |
| new | no equivalent | a table of what goes where | each kind of class has one package, and the table says which packages it may use Why |
| new | no equivalent | api and messaging, then application, then domain | the domain depends on nothing, and the adapters implement ports that the inner code declares Why |
| new | no equivalent | OrderRepository in domain, StockChecker in application | storage belongs to the model, but a call to another service is a step of the use case Why |
| new | no equivalent | twelve rules for every service | each rule has a reason, and most are checked by a test that fails the build Why |
| new | no equivalent | ArchitectureTest, with the platform's ArchitectureRules | the layering is checked on the compiled classes, so a forbidden import fails the build Why |
| twist | internal sealed class OrderDocument | class OrderDocument, with no modifier | package-private is narrower than internal, so only the persistence package sees the document Why |
| twist | a namespace takes no XML comment, and CS1587 warns | package-info.java with a Javadoc comment | the comment documents a whole package, and javadoc shows it on the package page Why |
| twist | <see cref="Order"/> and <c>code</c> | {@link Order}, {@code code} and {@value #MAX_SIZE} | Javadoc tags in braces, inside a comment that starts with /** Why |
| new | no equivalent | features and layers, not adapter/in and adapter/out | the protection comes from the direction of the dependencies, which a test checks, not from folder names Why |
| new | one folder per use case, such as Features/PlaceOrder | one application service per feature | a slice per use case is fine for simple features, but the rules of one aggregate must stay in one place Why |
| new | no equivalent | one model for reads and writes, with no CQRS | a separate read model is worth its cost only when reads need a shape or a scale that the aggregate cannot give Why |
| new | no equivalent | OrderService split by area of the business | at about ten use cases, split by area, such as fulfilment, and never into a read side and a write side Why |
| new | no equivalent | a small shared package | it holds the permission annotations, the audit names and the OpenAPI description, and no business logic Why |
| new | no equivalent | three repositories: shop-platform, order-service, local-environment | the root pom.xml of the companion exists only for the book, and real services never build together Why |
| new | no equivalent | a README with purpose, rules, endpoints, events, dependencies and configuration | the same headings in every service, so a reader knows where each fact is Why |
The service's foldersnew#
A developer moves from the order service to the invoice service. They need the class that stores an invoice. If both services have the same folders, they know where to look before they open the project. So every shop service uses the same layout.
Here is the order service's repository, with the main folders only:
order-service/
├── pom.xml ← the build: a parent and one dependency
├── Dockerfile ← the image, built from the jar
├── docker-compose.yml ← runs the service against the local environment
├── README.md ← purpose, rules, endpoints, events, dependencies, configuration
├── contracts/
│ ├── openapi.yaml ← the generated API contract, committed
│ └── events/ ← one JSON schema per event, sent or consumed
└── src/
├── main/
│ ├── java/com/acme/orderservice/
│ │ ├── OrderServiceApplication.java
│ │ ├── order/ ← a feature
│ │ ├── note/ ← a feature
│ │ ├── reference/ ← a feature
│ │ ├── migration/ ← database changes, not a feature
│ │ └── shared/ ← small helpers for every feature
│ └── resources/
│ ├── application.yml
│ ├── application-local.yml
│ └── seed/ ← lookup values that a migration loads
└── test/
├── java/com/acme/orderservice/ ← the same packages as main
└── resources/The file pom.xml is the service's Project Object Model (POM), the build file of
Maven. It names the shop's
parent POM, which sets every version. For the standard Maven folders, see the section The standard
layout. The folders contracts and seed belong to the shop. For the
contracts, see the chapter API and event contracts. For the seed files, see the
chapter Migrations, indexes and seed data.
The base package is com.acme.orderservice.
The platform library uses com.acme.platform. Both start with the company's reversed
domain, com.acme. The class that starts the service is in the base package:
/**
* Starts the order service, like {@code Program.cs} in ASP.NET Core.
*
* <p>{@code @SpringBootApplication} scans this package and every package below it for
* components. The platform's beans come from its auto-configuration instead, because the
* platform's packages are not below this one.
*/
@SpringBootApplication
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}The class does what Program.cs does in ASP.NET Core. It starts the
application. It registers nothing itself, because
component scanning finds the service's own
classes. For why the class must be in the base package, see the section
Which packages are scanned. The platform's
beans come from
auto-configuration. For how, see the chapter
Writing the platform library.
The test tree has the same packages as the main tree. A test of
com.acme.orderservice.order.domain.model.Order is
com.acme.orderservice.order.domain.model.OrderTest. So the test sees the
package-private members of the class it tests. The
name of a test class also decides when it runs:
| Test class name | Example | When it runs |
|---|---|---|
| ends in Test | OrderTest, OrderControllerTest | in every build, with mvn verify, and it needs no Docker |
| ends in IT | OrderPersistenceAdapterIT, OrderJourneyIT | only with mvn verify -Pit, because it starts containers in Docker |
For how Maven chooses the tests by name, see the section Fast tests and integration tests.
Features at the top, never technologiesnew#
A product owner asks for a new rule. A note in the category DELIVERY must name a
delivery date. In a service organised by technology, the change touches four folders, one for each technology.
They are the controllers, the services, the repositories and the data transfer objects (DTOs). Four folders change for one business change. Each of those folders also holds
the classes of every other business area.
The rule of the shop is that the top-level packages are features, never technologies. A feature is a business capability that a business user recognises. The order service has three features. Each one uses a different level of Domain-Driven Design (DDD):
| Package | What a business user calls it | Its level of DDD |
|---|---|---|
| order | orders, picking, payment and shipping | full |
| note | the notes that agents keep on an order | light |
| reference | the drop-down lists, such as delivery methods | simple |
Two packages at the top are not features. migration holds the database changes,
and shared holds a few small helpers. For both, see the section
The same sub-packages in every feature.
Each package says what it holds in its own documentation. Here is the order feature's:
/**
* Orders: what a customer bought, the lines that warehouse staff pick, the payment, and shipping.
*
* <p>This feature uses the full style. Its {@code domain} package is plain Java with typed IDs.
* The {@code persistence} package stores the aggregate as a separate document through a mapper,
* behind the {@code OrderRepository} port. The stock service is called through the
* {@code StockChecker} port.
*
* <p>The rules: an order is placed only if the stock service confirms and holds the products.
* An order ships only when it is paid, every line is picked, and no note on it needs action. A
* payment is recorded from the payment service's event, and a repeated or late one changes nothing.
*/
package com.acme.orderservice.order;The comment stands before the line package com.acme.orderservice.order;, so it documents that package. For the file package-info.java, see the section
Documenting a package. For the levels, see the chapter
How much DDD: three levels in one service.
The shop has three reasons for this rule:
- One change touches one folder. The new rule about delivery notes changes
the
notepackage only. A reviewer quickly sees which business area changed. - A feature can become a service. If the notes grow into a product of their
own, the
notepackage moves to a new repository as it is. Its dependencies on other features are already few and visible. - Engineers move between services. A company with eight or ten services has the same layout in each. A developer who knows one service can work in the next one from the first day.
The rule holds even for a service with one feature. A service that only keeps invoices still
has the package com.acme.invoiceservice.invoice. The extra level costs one folder.
In return, the second feature has a place to go, and the layout matches every other service.
A layout by technology has a strong version too. Its best argument is that a developer who
looks for "the controllers" finds them in one place. Many ASP.NET Core projects start this way,
with folders such as Controllers and Services. For a small service with
one business area, the layout works well. It stops working when the service has several business
areas. Then each technology folder mixes them, and any class can call any other class in the same
folder. The shop has many services that grow, so it chooses features from the start.
The same sub-packages in every featurenew#
Inside a feature, the sub-packages are always the same, and each has one job. Here is the order feature, the most complete one:
order/
├── OrderConfig.java ← the feature's wiring, for beans without @Component
├── package-info.java ← what the feature is, and its rules
├── domain/ ← plain Java: the model and its rules
│ ├── model/ ← Order, OrderLine, OrderId, Money and the other value objects
│ ├── event/ ← OrderPlaced, OrderShipped and the other domain events
│ ├── exception/ ← OrderNotPaidException and the other rule violations
│ ├── repository/ ← OrderRepository, the port for storing orders
│ └── service/ ← ShippingPolicy, a rule across two aggregates
├── application/ ← the use cases: OrderService, OrderView, PlacedOrder
│ └── port/ ← StockChecker, the port to the stock service
├── api/ ← OrderController, OrderApiMapper
│ └── dto/ ← PlaceOrderRequest, OrderResponse, OrderPlacedResponse
├── persistence/ ← OrderDocument, OrderDocumentMapper, OrderPersistenceAdapter
├── client/ ← StockHttpClient, StockAdapter, StockClientConfig
│ └── dto/ ← ReservationRequest, ReservationResponse
└── messaging/ ← OrderIntegrationEventMapper, PaymentReceivedListener
└── event/ ← OrderPlacedV1, OrderShippedV1, OrderCancelledV1The folders inside domain tell quickly what a class is. A class in
domain/event is something that happened. A class in domain/exception is a
broken rule. Without the folders, the domain package of a large feature holds thirty classes in
one list.
A feature leaves out the sub-packages it does not need. The note feature calls no other
service and sends no event to other services. So it has no client and no
messaging. It also uses the light style, so it has a model package in
place of domain and persistence:
note/
├── package-info.java
├── model/ ← Note, which is also the stored document, NoteRepository, its events and exceptions
├── application/ ← NoteService, NoteView, NewNote
└── api/ ← NoteController, NoteApiMapper
└── dto/ ← AddNoteRequest, NoteResponse, NoteAddedResponseSo NoteRepository is in model/, next to Note, and no
mapper exists. The reference feature uses the simple level. It has no domain model at all:
reference/
├── ReferenceConfig.java ← turns on caching
├── package-info.java
├── application/ ← ReferenceDataService, ReferenceItemView, ReferenceTypes
├── api/ ← ReferenceDataController
│ └── dto/
└── persistence/ ← ReferenceItemDocument, ReferenceItemRepositorySo the three features do not have exactly the same sub-packages. The names are the same
wherever a feature has the part. A persistence package always stores data, and an
api package always translates HTTP. The rules of the platform rely on the names, so a
name that means something else in one feature would break a rule. For why the levels differ, see
the section Choosing the level for each feature.
The strict rule. Every feature has the same sub-packages, so every feature looks the same.
What we do. Every feature uses the same names, but only for the parts it has.
The light-style note feature has a model package in place of domain
and persistence. The simple reference feature has no domain package at all.
Why. A lookup list with a full domain gets five files that protect nothing. A
small entity whose stored shape is its model gains little from a separate document and a mapper.
The names still tell the level. A domain package means plain Java, and a
model package means a rich entity that is also its stored document.
When to stop. Stop when a reader cannot tell a feature's level from its package names, or when a team invents a fourth shape. Then write the shapes down and add a rule for each one.
Two top-level packages are not features. The migration package holds the database
changes. It changes the storage of every feature, so it is next to the features:
/**
* The service's database changes: indexes and seed data, as {@code MongoMigration} beans. The
* platform's runner applies each one once, in ID order, before the service takes traffic.
*
* <p>This is not a feature. It changes the storage of every feature, so it sits beside them.
* Never edit a migration that has run anywhere; add the next one.
*/
package com.acme.orderservice.migration;Each migration is a MongoMigration bean. For the migrations, see the chapter Migrations, indexes and seed data.
The shared package has its own section,
The shared package, and what stays out of it.
What goes wherenew#
A developer writes a class that turns a PlaceOrderRequest into domain values.
Does it go into api or into application? The shop answers such
questions with one table. Each kind of class has one package. The last column says which other
packages a class there may use:
| Package | Purpose | Contains | May use |
|---|---|---|---|
| domain/model | the business concepts and their rules | aggregate roots, which extend AggregateRoot, entities, which extend Entity, IDs, which extend Identifier, and value objects, records that implement ValueObject | its own domain and the platform's domain package |
| domain/event | what happened in the model | records that implement DomainEvent | its own domain model |
| domain/exception | the broken rules | classes that extend NotFoundException, InvalidValueException or BusinessRuleViolationException | its own domain and the platform's exception kinds |
| domain/repository | the port for storing one aggregate | one interface per aggregate, which extends Repository | its own domain |
| domain/service | a rule that needs more than one aggregate | plain classes, such as ShippingPolicy | its own domain |
| application | the use cases | one service per feature, its views, and records that other features pass in, such as NewNote | its own domain, the application package of other features, DomainEventPublisher and AuditLog |
| application/port | what the use cases need from other services | interfaces in this service's own types | its own domain only |
| api | HTTP in and out | controllers, mappers and DTOs with Bean Validation | its own application and its own domain types |
| persistence | how the aggregate is stored | @Document classes, Spring Data repositories, mappers and adapters | its own domain and the platform's mongo package |
| client | calls to other services | @HttpExchange clients, their DTOs and adapters | its own application ports, its own domain and the platform's HTTP support |
| messaging | events between services | integration events, the event mapper and listeners | its own application, its own domain and the platform's messaging package |
| the feature's root | the feature's wiring | the OrderConfig class | anything in the feature |
A DTO is a class that only carries data across a boundary, such as the
JSON body of a request. The class that turns a PlaceOrderRequest into domain values
reads a DTO, so it belongs in api. In the order service, that class is
OrderApiMapper.
The domain model has a base class for each kind of concept. The order is the root of an aggregate, called an aggregate root:
public class Order extends AggregateRoot<OrderId> {OrderLine extends Entity<LineId>. OrderId and
LineId extend Identifier. Money and
DeliveryMethod are records that implement ValueObject. For each base
class, see the chapter The domain model in plain Java.
An exception in the domain extends one of three kinds. The kind decides the HTTP status that the platform answers with:
| The exception extends | Example in the order feature | Status |
|---|---|---|
| NotFoundException | OrderNotFoundException, LineNotFoundException | 404 |
| InvalidValueException | thrown by a value object, such as the platform's PageQuery for a page size over 100 | 400 |
| BusinessRuleViolationException | OrderNotPaidException, UnpickedLinesException | 422 |
For the problem responses, see the chapter The web layer in depth.
The OrderConfig class at the feature's root creates the beans that cannot carry
@Component:
/**
* Creates the order feature's beans that cannot carry {@code @Component}: the domain is plain
* Java, so its classes have no Spring annotation.
*
* <p>{@code proxyBeanMethods = false} tells Spring not to wrap this class in a proxy. The bean
* methods never call each other, so the proxy would only cost startup time.
*/
@Configuration(proxyBeanMethods = false)
class OrderConfig {
@Bean
ShippingPolicy shippingPolicy() {
return new ShippingPolicy();
}
}ShippingPolicy is a domain service, so it may not import Spring. The feature's
root package is the one place where Spring meets the domain. For @Bean methods, see
the chapter Wiring the service: the container in depth.
The direction of the dependenciesnew#
The table in the section above has a pattern. The outer packages use the inner ones, and never the other way round. The domain is at the centre, and it depends on nothing. The use cases are in the application package. The integration events are in the messaging package:
Each box holds one kind of building block. The order feature has one of each:
| Building block | What it is | In the order feature |
|---|---|---|
| application service | the use cases of a feature, which order the steps and contain no rule | OrderService |
| port | an interface that the inner code needs, written in the inner code's own types | OrderRepository, StockChecker |
| adapter | a class that implements a port with real technology, such as MongoDB or HTTP | OrderPersistenceAdapter, StockAdapter |
| anti-corruption layer | an adapter that translates another service's model into this service's model | StockAdapter |
| view | the plain data that a use case returns, so a caller never holds the aggregate | OrderView |
| repository | the port for loading and saving one aggregate, one per aggregate | OrderRepository |
A port points inwards. The domain declares OrderRepository in its own types,
such as Order and OrderId. The persistence package implements it with
MongoDB. So the domain needs no MongoDB type, and a test of the domain needs no database.
The anti-corruption layer is one adapter with an extra job. The stock service speaks of a
sku and an answer INSUFFICIENT_STOCK. Those words stop in
StockAdapter:
/**
* Implements the {@link StockChecker} port over HTTP. It is the anti-corruption layer: the stock
* service's words, such as {@code sku} and {@code INSUFFICIENT_STOCK}, stop here, and the rest
* of the service sees only {@link StockAvailability}.The rest of the service sees only StockAvailability, a value object of the
order's domain. For the adapter in full, see the chapter Calling other services.
A view protects the aggregate from its callers:
/**
* What callers of the order feature see of an order: plain data, never the aggregate. A caller
* therefore cannot call {@code order.ship()} and skip the use case's steps.
*
* <p>The customer's contact is left out. A view carries personal data only when a use case
* needs it.
*/
public record OrderView(String orderId, String status, String deliveryMethod, Instant placedAt, Instant shippedAt,
Instant cancelledAt, List<Line> lines, LastPayment lastPayment) {If the controller got an Order, it could call order.ship()
directly. Then nothing would check the notes, publish an event or write an audit record. A view has no such method. For the steps of a use case, see the chapter
Use cases: the application service.
The light and simple features have fewer boxes. The note feature has no ports and no adapters. Its application service uses a Spring Data repository directly. So the diagram fits the full style exactly, and the other styles drop the adapter column.
Repositories in the domain, other services in the applicationnew#
Both OrderRepository and StockChecker are ports. Both are
interfaces in the service's own types, and both have an adapter. But they are in different
packages. The repository is in domain/repository:
/**
* The port for storing orders, in the domain's own types. The persistence package implements it.
* Lists are sorted newest first.
*/
public interface OrderRepository extends Repository<Order, OrderId> {
/** Loads the order, or throws the 404 exception, so a use case loads in one line. */
default Order get(OrderId id) {
return findById(id).orElseThrow(() -> new OrderNotFoundException(id));
}The default method get loads an order or throws the 404 exception. The port to
the stock service is in application/port:
/**
* The port to the stock service, in this service's types. The use case does not know that the
* answer comes over HTTP, and a unit test replaces it with a mock.
*
* <p>It lives in {@code application/port}, not in the domain: calling another service is a step
* of the use case, and the domain only judges the answer.
*/
public interface StockChecker {Both interfaces, OrderRepository and StockChecker, use only the
service's own types. The reason for the two places is what each port means. Storage is part of the model. An order that cannot be
stored and loaded again is not a useful order. So the domain declares what it needs from storage,
and the persistence package decides how to do it.
A call to another service is a step of a use case. The use case asks the stock service, and
then gives the answer to Order.place. The domain judges the answer, a
StockAvailability, but never makes the call. So the port belongs to the
application.
The placement has a second effect. A domain service such as ShippingPolicy may
use only the domain. If StockChecker were in the domain, a domain service could call
the stock service in the middle of a rule. A slow HTTP call would then hide inside the model, and
perhaps inside a transaction. With the port in the application, only a use case can make the call,
and it makes it before the transaction starts.
A careful team often puts every port in one place, either all in the domain or all in the application. Its argument is simple. A port is a port, so one rule should place them all. That works, and it is easier to explain. The shop separates the two kinds because the separation stops domain services from calling other services. If a team has no domain services, the benefit is small, and one place for all ports is a fine choice.
The rules a service followsnew#
The layout is not enough on its own. A developer can put a correct class in the correct package, and still read another feature's repository from it. So the shop writes its design as twelve rules. Each rule has a reason and a home chapter:
| # | Rule | Why | Where it is taught |
|---|---|---|---|
| 1 | A service owns its data. Other services use its API or its events. | Two services that share a collection can never change it apart. | Storing aggregates in DocumentDB |
| 2 | The top-level packages are features, never technologies. | One business change touches one folder. | this chapter |
| 3 | Every feature has the same sub-packages, and leaves out the ones it does not need. | A developer finds a class without searching. | this chapter |
| 4 | The domain is plain Java. It imports no Spring, no MongoDB and no other layer, but it may use the platform's domain package. | The rules can be tested with no framework, and they survive a change of storage. | The domain model in plain Java |
| 5 | Business rules live in the domain. Write them in the aggregate first, and in a domain service only when the rule needs more than one aggregate. | Each rule has one home, so it cannot be bypassed. | Where each rule goes |
| 6 | The application package runs the use cases. It orders the steps and holds no business rule. | A use case reads as a list of steps. | the chapter on use cases |
| 7 | Features talk to each other only through their application package, and IDs cross as String. | A feature's model stays private, so it can change. | this chapter |
| 8 | Outside models stay outside. DTOs stay in api, documents in persistence, the other service's DTOs in client, and integration events in messaging. Map at the edge. | A change in another model never reaches the domain. | this chapter |
| 9 | A transaction changes one aggregate, and refers to others by ID, with one named exception. | Transactions stay small, and a later split of the service can keep the boundaries. | Transactions in depth |
| 10 | Cross-cutting code comes from the platform library. | Every service gets the same errors, security, logging and storage support. | Writing the platform library |
| 11 | Events go out through an outbox and come in through an inbox, even with SNS and SQS. | Delivery is at least once in both directions, and nothing is lost. | Publishing events, and Consuming events |
| 12 | Every dependency can run locally, with a WireMock stub for each HTTP service and an emulator for each AWS resource. | A developer runs the whole service on a laptop. | Running locally and shipping |
The last column names the chapter that teaches each rule. For example, rule 9 is taught in the chapter Transactions in depth. Rule 11 needs an outbox and an inbox. It is taught in the chapter Publishing events: the outbox and in the chapter Consuming events: SQS and the inbox.
Rule 1 is visible in the stored documents. Each collection, such as orders,
belongs to this service alone:
@Document("orders")
class OrderDocument extends BaseDocument {No other service reads the collection orders. The invoice service learns about
an order from OrderPlacedV1, or asks the API. For the events, see the chapter
API and event contracts.
A strict form of rule 6 also says that the application layer checks permissions. The order service does not follow that part. It checks permissions on the controllers, where users enter. For the reasons and the measured options, see the section Choosing the place by the shape of the service.
Rule 9 names one exception. Placing an order saves the order and its first notes in one
transaction. For that exception, see the chapter Use cases: the application
service. For why IDs cross as String in rule 7, see the section
A type for a foreign ID, or plain text.
The rules as tests that fail the buildnew#
A rule that only a review checks is broken sooner or later. A reviewer is tired, or does not see an import at the top of a file. So the platform states the rules as ArchUnit rules. ArchUnit is a library that reads the compiled classes and checks their dependencies. Each service calls the rules from one plain JUnit test:
class ArchitectureTest {
private static final String BASE = "com.acme.orderservice";
/** The features in the full style, whose application layer must use ports only. */
private static final String ORDER = BASE + ".order";
private static final JavaClasses CLASSES = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages(BASE);
@Test
void domainIsPlainJava() {
ArchitectureRules.domainIsPlainJava().check(CLASSES);
}
@Test
void applicationOfFullStyleFeaturesUsesNoAdapter() {
ArchitectureRules.applicationDoesNotUseAdapters(ORDER).check(CLASSES);
}
// ...
@Test
void featuresUseEachOtherOnlyThroughApplication() {
ArchitectureRules.featuresUseEachOtherOnlyThroughApplication(BASE).check(CLASSES);
}new ClassFileImporter()The importer reads the compiled
.classfiles, not the source. So it sees every dependency, including one that the source writes with a full class name and no import..withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)The rules check the production code only. A test may use any class it needs.
ArchitectureRules.domainIsPlainJava().check(CLASSES);Each rule comes from the platform's class
ArchitectureRules.checkthrows anAssertionErrorthat names every class that breaks the rule. So the test fails, and the build fails with it.
The test has ten rules. Each one guards a rule from the section above:
| Test method | What it checks | Rule |
|---|---|---|
| domainIsPlainJava | no domain package uses Spring, MongoDB, Jackson, AWS or another layer | 4 |
| applicationOfFullStyleFeaturesUsesNoAdapter | the order feature's application uses ports, never an adapter, HTTP or MongoDB | 6 |
| apiDoesNotUsePersistenceOrClients | an api package uses no persistence, client or messaging class | 8 |
| controllersUseNoRepository | a controller calls a service, never a repository | 6 |
| everyEndpointHasAPermission | every endpoint carries a permission annotation | security |
| documentsOnlyInPersistenceOrModel | a stored document lives in persistence, or in a light feature's model | 8 |
| httpClientsOnlyInClient | an HTTP client interface lives in a client package | 8 |
| entitiesHaveNoPublicSetters | an entity changes only through methods that apply its rules | 5 |
| featuresUseEachOtherOnlyThroughApplication | a feature uses another feature only through its application package, and never through its ports | 7 |
| servicesUseNoRepositoryOrEntityOfAnotherFeature | a service never takes another feature's repository or entity | 7 |
The rule for rule 4 lists what a domain package must not use:
/** A {@code domain} package uses no framework, no adapter and no other layer. */
public static ArchRule domainIsPlainJava() {
return noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage(
"org.springframework..", "com.mongodb..", "org.bson..", "jakarta..",
"tools.jackson..", "com.fasterxml..", "io.awspring..",
"..application..", "..api..", "..persistence..", "..client..", "..messaging..",
"com.acme.platform.core..", "com.acme.platform.mongo..")
.as("A domain package is plain Java");
}noClasses().that().resideInAPackage("..domain..")Two dots stand for any number of package levels. So
..domain..matchescom.acme.orderservice.order.domain.modeland every other domain package."jakarta..",The list forbids Jakarta too. So a domain class cannot use Bean Validation annotations, such as
@NotBlank. A value object checks its own values in its constructor instead."com.acme.platform.core..", "com.acme.platform.mongo..")The domain may use the platform's
domainpackage, which is plain Java. It may not use the platform's other packages.
The rule matches packages named domain. Only the order feature has one. The light
note feature names its package model, because its entity carries Spring Data
annotations. So the rule does not touch it, on purpose.
Rule 7 needs a condition of its own. ArchUnit has no ready rule for "a feature uses another feature only through its application package". So the platform writes it:
for (Dependency dependency : origin.getDirectDependenciesFromSelf()) {
String target = dependency.getTargetClass().getBaseComponentType().getPackageName();
String targetFeature = featureOf(basePackage, target);
if (targetFeature == null || targetFeature.equals(feature) || targetFeature.equals("shared")) {
continue;
}
String inFeature = target.substring((basePackage + "." + targetFeature).length());
boolean allowed = inFeature.startsWith(".application") && !inFeature.startsWith(".application.port");
if (!allowed) {
events.add(SimpleConditionEvent.violated(dependency, dependency.getDescription()));
}
}origin.getDirectDependenciesFromSelf()This method lists every class that the class uses directly. Examples are field types, parameters and method calls.
getBaseComponentType()For an array type, such as
OrderView[], this method returns the element type. So an array of another feature's class counts as a use of that class.targetFeature.equals("shared")The
sharedpackage is open to every feature.inFeature.startsWith(".application") && !inFeature.startsWith(".application.port")A use is allowed only when it ends in the other feature's application package. The other feature's ports are excluded, because they belong to that feature's own use cases.
So the note feature may call OrderService.requireExists. It may not import
Order, OrderRepository or OrderDocument. The order feature
may pass a NewNote to the note feature, because NewNote is in the note
feature's application package:
/**
* A note to add, in plain values. The order feature passes it when it places an order with its
* first notes, so no note type crosses into the order feature.
*/
public record NewNote(String text, String category, boolean actionRequired) {
}NewNote holds plain values, so no type of the note's model reaches the order
feature. A rule that never fails proves nothing. So the service also has tests that feed each rule a
class that breaks it, and expect the rule to fail. For those tests, for the freezing of old
violations, and for the other ArchUnit rules, see the chapter Testing in
depth.
Package-private classes hide the adapterstwist#
In the order service, the adapters, the stored documents, the controllers and the
configuration classes have no access modifier. Here is the stored document of an order, beside an
internal class in C#:
internal sealed class OrderDocument
{
public string? CustomerName { get; set; }
}/**
* How an order is stored: one document per aggregate, with its lines and last payment embedded.
*
* <p>The lines are embedded because they are few (at most 50) and every order rule needs them.
* Notes are not: they have no limit, and embedding them would rewrite the whole order for each
* new note. A document must stay under 16 MB.
*
* <p>Only plain types are stored. Enums and codes are strings, so renaming a Java class never
* breaks stored data. Money is DECIMAL128, so it is exact. The class and its fields are
* package-private: nothing outside this package knows the storage shape.
*/
@Document("orders")
class OrderDocument extends BaseDocument {
String customerName;
String customerEmail;A class with no access modifier is package-private in Java. Only classes in the same package can use it. For the access levels, see the section The mapping.
The C# class is internal, so the whole assembly can use it. The Java class is
narrower. Only the package com.acme.orderservice.order.persistence can use it. Even
the package com.acme.orderservice.order, which looks like its parent, cannot. For
that trap, see the section A package inside a package.
The service makes these classes package-private:
| Class | Package | Who uses it |
|---|---|---|
| OrderDocument, OrderDocumentMapper, OrderMongoRepository | order.persistence | only OrderPersistenceAdapter, in the same package |
| OrderPersistenceAdapter | order.persistence | Spring, which injects it where OrderRepository is asked for |
| StockAdapter, StockHttpClient, StockClientConfig | order.client | Spring, which injects StockAdapter where StockChecker is asked for |
| OrderController, OrderApiMapper | order.api | Spring MVC, which calls the controller |
| PaymentReceivedListener, OrderIntegrationEventMapper | order.messaging | Spring and the platform's messaging |
| OrderConfig, ReferenceConfig | the feature's root | Spring, which reads their @Bean methods |
Spring creates beans of package-private classes. The adapter's class and its constructor are both package-private:
@Component
class OrderPersistenceAdapter extends MongoAggregateRepository<Order, OrderId, OrderDocument> implements OrderRepository {
// ...
OrderPersistenceAdapter(OrderMongoRepository orderDocuments, OrderDocumentMapper mapper) {The use case asks for an OrderRepository. Spring finds the one bean that implements
it and injects it. The use case never names the adapter, and the compiler would stop it if it
tried. The companion's ApplicationWiringTest starts the whole service, so it shows
that Spring builds these beans.
So package-private does a second job. It is a rule that the compiler checks. No class outside
order.persistence can depend on the storage shape, because it cannot see it.
Some classes must stay public, because another package uses them:
- The domain classes, because the application, api, persistence and client packages use them.
- The ports,
OrderRepositoryandStockChecker, because their adapters are in other packages. - The application services and views, because the controllers and other features call them.
- The DTO records in
api.dto, because the controller inapiuses them. A sub-package is a different package. - In the light note feature,
NoteandNoteRepository, becauseNoteServicein the application package uses them directly. - In the reference feature,
ReferenceItemDocumentandReferenceItemRepository, becauseReferenceDataServiceuses them directly.
The last two items are part of what the lighter levels give up. Without a port, the application uses the storage types, so they cannot hide.
Documenting a packagetwist#
A new developer opens the reference package and wonders why it has no
domain. The answer is in a file in the package itself:
/// <summary>The building blocks of a domain model.</summary>
namespace Shop.Platform.Domain
{
/// <summary>A marker for value objects.</summary>
public interface IValueObject { }
}/**
* Lookup values, such as delivery methods and note categories, for drop-down lists and for
* checking codes. The data changes only with a deployment, through a seed migration.
*
* <p>This feature uses the simple level: no aggregate, no domain model and no events. A
* document record, a Spring Data repository, and the logic in the application service. Read-only
* lookup data has no business behaviour, so DDD's building blocks would only add files.
*/
package com.acme.orderservice.reference;The C# compiler does not accept an XML comment on a namespace. It warns with CS1587, "XML
comment is not placed on a valid language element". In Java, a file named
package-info.java holds the package declaration and one documentation comment for
the whole package. The javadoc tool shows that comment on the package's page.
The service has a package-info.java in each feature and in shared
and migration. The platform has one in its domain package. Each says
what the package is for and what it must not hold. A rule such as "this package is plain Java"
is then written beside the code it governs.
A documentation comment in Java is called a Javadoc comment. It starts with /**.
The comments in the companion use four tags:
| Tag | What javadoc shows | C# XML comment |
|---|---|---|
| {@code text} | the text in code font, with no HTML read inside it | <c>text</c> |
| {@link Order} | a link to the class Order, and {@link #save} links to a method | <see cref="Order"/> |
| {@value #MAX_SIZE} | the value of the constant, such as 100 | no equivalent |
| <pre>{@code ... }</pre> | a block of code, kept as it is written | <code>...</code> |
Here {@value} keeps a comment true when the constant changes:
/**
* Which page of a list to return, without any Spring type. Pages count from zero.
*
* @param page the page number, from 0
* @param size the number of items on a page, from 1 to {@value #MAX_SIZE}
*/
public record PageQuery(int page, int size) implements ValueObject {
public static final int MAX_SIZE = 100;javadoc writes "from 1 to 100" on the page of PageQuery. If someone changes
MAX_SIZE, the page changes with it. @param documents one parameter. On a
record, it documents a component.
A block of example code uses <pre>{@code ... }</pre>:
* <p>A service calls them from a plain JUnit test:
*
* <pre>{@code
* ArchitectureRules.domainIsPlainJava().check(classes);
* }</pre>{@code} stops javadoc from reading the angle brackets as HTML. The tag
<pre> keeps the line breaks. <p> starts a new paragraph,
because javadoc joins the lines of a comment into one paragraph otherwise.
Why not adapter/in and adapter/outnew#
Hexagonal architecture is also called ports and adapters. It is a well-known layout for services like this one. A careful hexagonal service has packages such as these:
order/
├── domain/
├── application/
│ └── port/
│ ├── in/ ← the use-case interfaces that the outside calls
│ └── out/ ← the interfaces that the use cases need
└── adapter/
├── in/
│ ├── web/ ← the controllers
│ └── messaging/
└── out/
├── persistence/
└── stock/This layout gets a lot right. It names the direction of every port. It makes the adapters look alike, so a reader quickly sees that the web and the queue both drive the use cases. Each use case also gets an interface of its own, so a controller depends on an interface, not on a class.
The shop has the same ports and adapters, with fewer folders. api and
messaging are the driving adapters. persistence and
client are the driven adapters. The shop leaves out the extra folders for three
reasons:
- The protection comes from the direction of the dependencies. A folder named
adapter/outstops nothing. A test that fails when the domain imports MongoDB stops the mistake. The shop has that test, so the folder names add little. - An interface for every use case has a cost and little benefit here. The
order service has one implementation of each use case. A controller test mocks the class
OrderServiceas easily as an interface. Soport/inwould double the files and protect nothing. - The names match what developers say. A developer says "the API" and "the persistence". The packages use those words.
The hexagonal layout is the better choice in two cases. One is a team that already knows it
well, across many services. Then the familiar names are worth more than the shorter tree. The
other is a use case with several implementations, such as a product sold to customers who each
add their own storage. Then port/in and port/out carry real
meaning.
Why not one folder per use casenew#
Vertical slice architecture is another popular layout, and many .NET teams use it. Each use case gets a folder with everything it needs. A careful version looks like this:
orders/
├── placeorder/ ← the endpoint, the request, the handler, the validator
├── shiporder/ ← the same parts for shipping
├── cancelorder/
└── getorder/The folder placeorder/ holds every class of one use case. Its best argument is strong. A change to one use case touches one folder, which is even
narrower than one feature. Each slice can choose its own way to store and to read. A simple read
can query the database directly, and a complex write can use a rich model. There is no shared
layer that every change must pass through.
The difficulty is the aggregate. Placing, shipping and cancelling all change an order, and the rules of an order must hold in all three. In a strict slice layout, each slice changes the order in its own code. The rule "a shipped order cannot change" is then written in two slices, and the third slice can forget it. The shop's rule 5 says each business rule has one home. So the shop keeps one domain model per aggregate, shared by all its use cases.
A careful slice team solves this too. It keeps a shared domain folder, and the slices call the aggregate's methods. That is close to the shop's layout, with the application service split into one class per use case. The shop keeps one application service per feature. The steps of every use case are the same, and one class shows them side by side. For when to split that class, see the section Splitting a large application service.
Slices are the better choice for a feature with many independent use cases and few shared
rules. Reports and searches are good examples. Each one reads differently, and no rule ties them
together. A feature of that kind can use slices inside the shop's layout, under its
application package.
One model for reads and writes: CQRS or notnew#
Command Query Responsibility Segregation (CQRS) splits a feature into two sides. The write side changes the aggregate and checks its rules. The read side answers queries from a separate model, which is shaped for the screens that read it. An event from the write side usually keeps the read model up to date.
The order service uses one model for both. A read loads the aggregate and turns it into a view. Here are the two read use cases of the order feature:
public OrderView get(OrderId orderId) {
return OrderView.from(orders.get(orderId));
}
public PageResult<OrderView> list(OrderStatus status, PageQuery page) {
PageResult<Order> result = status == null ? orders.findAll(page) : orders.findByStatus(status, page);
return result.map(OrderView::from);
}get loads one order and returns a view. list loads a page of orders,
and map turns each one into a view. The same collection and the same documents serve
reads and writes.
A careful CQRS design gets several things right. The read model has exactly the shape a screen needs, so a read is one fast query. Reads and writes can scale apart. And the write model never grows fields that only a screen uses. When those benefits are real, CQRS is worth its cost.
The cost is also real. The read model is a second copy of the data, kept up to date by events. So it is briefly out of date after every change, and every screen must allow for that. The team writes and tests the code that keeps the copy right. A replay is needed when the read model's shape changes. For a feature whose reads look like its aggregate, the team does all of that work and gains nothing.
The strict rule. Commands and queries have different needs, so a design that takes DDD seriously gives each its own model.
What we do. Each feature has one application service. Reads load the aggregate and return a view, from the same collection that the writes use.
Why. The order service's screens show an order, a page of orders, and the notes of an order. Each of those is close to the shape of one aggregate. A second model would add a copy of the data and a delay. No read of today would become faster.
When to stop. Stop when a read needs data from several aggregates or features at once, such as a search across orders and notes. Stop also when reads are many times more frequent than writes, and the aggregate is slow to load. A third sign is a screen that needs a shape the aggregate must bend to give. Then add a read model for that query, and only for that query.
CQRS has a second use, which matters here. The order feature and the note feature call each
other. The order service asks the note service how many notes need action. The note service asks the
order service whether an order exists. Both services take the other one in their
constructors, and the companion breaks the cycle with @Lazy. For the cycle and its first three ways out, see the
section Two features that call each other.
A query side gives a fourth way out. The count of notes that need action is a query. A small query class in the note feature could answer it, with no dependency on the order feature. The order feature would call the query class, and the note service would call the order service. Each dependency would then point one way. The companion does not do this, because it has no CQRS. Its README calls the cycle a warning sign, and it lists the other three ways out.
Splitting a large application servicenew#
An application service grows with its feature. OrderService has eight public
methods today. A year later it may have fifteen, and its constructor may take twelve
dependencies. The class is then hard to read, and every test builds all its dependencies. The
class says how to split it:
* <p>The class has no permission annotation. Permissions are checked on the controllers, where
* users enter. Every method here can therefore be called by another feature or by a message
* listener without a second, unprotected copy of the method.
*
* <p>With about ten use cases, split the class by area, such as {@code OrderFulfilmentService},
* not into a read side and a write side.
*/
@Service
public class OrderService {The name OrderFulfilmentService is an example of a class for one area. An area is a part of the business that the users recognise. The order feature has three.
Placing an order is one. Fulfilment, which means picking, shipping and cancelling, is another.
Payment is the third. Each area becomes one class in the application package, with
the same steps in each method. The companion does not split the class yet, because it has only eight public methods.
A split into a read side and a write side looks tidy, but it is a weaker cut. Every change to fulfilment would touch both classes. It also makes the read class look like a read model, when it is not one. If a real read model is needed, see the section One model for reads and writes: CQRS or not.
Three points keep a split safe:
- The private helper
saveAndPublishsaves, publishes the events and records the audit. After a split, each class needs it. Move it to a package-private class in the same package, so no other feature can call it. - Other features call the new class that owns the use case. The rule
featuresUseEachOtherOnlyThroughApplicationstill holds, because every new class is in the application package. - A split is not a reason to add a rule to the application service. The rules stay in the domain, whatever the number of application classes.
A split is a sign to check the aggregate too. Fifteen use cases on one aggregate can mean that the aggregate holds two things. For those signs, see the section Signs of an aggregate that is too big.
The shared package, and what stays out of itnew#
Every feature needs the permission annotations @OrdersRead and
@OrdersWrite. Every feature writes the same names to the audit log. Those classes
belong to no single feature. They are in shared:
/**
* Small helpers that every feature of this service uses: the permission annotations, the audit
* names and the OpenAPI description. It holds no business logic. Code that several services
* need belongs in the platform instead.
*/
package com.acme.orderservice.shared;The comment names the three kinds of helper and says that shared holds no
business logic. Here are the audit names:
/** The names this service writes to the audit log, kept in one place so they stay consistent and searchable. */
public final class AuditActions {
public static final String ORDER = "order";
public static final String NOTE = "note";
public static final String ORDER_PLACED = "ORDER_PLACED";The class is final, so no class can extend it. It holds only constants. A search
for ORDER_PLACED then finds every place that writes the action, and the audit log
always uses the same spelling.
The package has three parts:
| Package | What it holds | Why it is shared |
|---|---|---|
| shared | AuditActions | every feature records audit actions with the same names |
| shared.security | OrdersRead, OrdersWrite | every controller names one of the two scopes |
| shared.api | OpenApiConfig | the description of the whole API, written once |
A shared package often grows until it holds anything. So it has two limits. It holds no business logic, because a rule belongs in a feature's domain. And it holds nothing that other services need, because that belongs in the platform. For the permission annotations, see the section Permission annotations: each scope string written once.
The strict rule. Features share nothing except their application packages, so each feature can move to its own service.
What we do. The shared package holds the permission annotations,
the audit names and the OpenAPI description. The architecture rule lets every feature use it.
Why. These classes are the same for every feature of this service. A copy in each feature would drift. None of them holds a rule, so sharing them couples nothing that matters.
When to stop. Stop when a class in shared starts to make a
business decision, or when one feature needs a change that the others do not. Then move it into
that feature.
The three repositories in real lifenew#
The companion is one folder with three sub-folders. Its root pom.xml builds the
platform and the service together:
<!--
Builds the platform and the order service together, for the book only.
In a real company, shop-platform, order-service and local-environment are three separate
repositories. This file lets one command build and test everything:
mvn verify unit, slice, wiring and architecture tests (no Docker needed)
mvn verify -Pit also the integration tests (*IT), which start containers in Docker
-->
<!-- ... -->
<modules>
<module>shop-platform</module>
<module>order-service</module>
</modules>The two <module> lines name the two folders that Maven builds. mvn verify -Pit also runs the tests that need Docker. In real life, nothing builds the platform and a service together. The platform team releases
a version of the library. Each service names that version and builds alone. The local environment
is a third repository, with no Java at all. For the repositories and who uses each one, see the
section Three repositories: platform, service and local environment.
For the POMs, see the chapter The build: a parent POM and a platform
library.
The separation has a reason. If the order service built against the platform's source, a change in the platform would reach every service at once, untested. With a released version, each service chooses when to upgrade. For the rules of that upgrade, see the section Versions, and the rules for upgrading.
Rule 12 is kept by the local environment. WireMock answers for each HTTP service, with one folder of stubs per service. Floci emulates the AWS resources. So every dependency runs on a laptop:
# Imitates the HTTP services that shop services call. The stubs are in wiremock/mappings/<service>/.
wiremock:
image: wiremock/wiremock:3.13.0
command: ["--global-response-templating", "--verbose"]
volumes: ["./wiremock:/home/wiremock"]
ports: ["8089:8080"]
# Local SNS and SQS: Floci, a free emulator with LocalStack's API, on port 4566.
floci:
image: floci/floci:2.3.0
ports: ["4566:4566"]The container wiremock serves the stubs, and the container floci answers
the calls to Amazon Simple Notification Service (SNS) and Amazon Simple Queue Service (SQS). The folder wiremock/mappings/stock-service holds the stubs of
the stock service.
For the local environment in full, see the chapter Running locally and
shipping.
A reader new to the companion can read it in this order. Each step builds on the one before it:
- The order service's
README.md, for the rules and the decisions. - The package
order/domain/model, withOrder, and its testOrderTest. OrderService, for the steps of every use case.- The adapters,
OrderPersistenceAdapterandStockAdapter. - The note feature, to compare the light style with the full style.
- The platform library, for everything the service gets without writing it.
The service's READMEnew#
An engineer on call is woken at night. The order service answers 503 for new orders. They need to know what the service depends on, and what happens when each dependency is down. That fact must be in a known place in every service. So every shop service has a README with the same headings:
| Heading | What it holds |
|---|---|
| the first paragraph | the purpose, in the words of the business |
| Rules | the business rules, one line each |
| Three styles in one service | the level of each feature, a section that only the companion needs |
| Endpoints | every path, its scope and its success status |
| Events | each event sent or consumed, with its topic or queue and its schema |
| Dependencies | each dependency, what the service uses it for, and what happens when it is down |
| Configuration | each environment variable and what it means |
| Run locally | the commands, and how to call the service |
| Tests | the kinds of test, with examples and the command that runs them |
| Decisions | each design choice, with its reason and the sign to change it |
Here is the section that the engineer on call needs:
| Dependency | Used for | When it is down |
|---|---|---|
| DocumentDB | all data | readiness is DOWN, so no traffic arrives; liveness stays UP, so instances are not restarted |
| stock service | placing an order | only `POST /orders` answers 503; everything else works |
| SNS | publishing events | events wait in the outbox and are resent |
| SQS | payments | payments wait in the queue |
| token issuer | checking tokens, and the stock call's own token | requests get 401; placing an order gets 503 |The answer is in the second row. Only POST /orders depends on the stock service. Every
other endpoint works, so the incident is smaller than it looks.
The section "Decisions" is the most useful one for a reviewer. Each entry states a choice, such as "Permissions are checked on the controllers", with its reason and its limits. A team that writes architecture decision records can link them from here. The README keeps the short version beside the code.
1An expert asksBoth StockChecker and OrderRepository are ports. Why
is the first one in application/port and the second one in the domain?
StockChecker and OrderRepository are ports. Why
is the first one in application/port and the second one in the domain?2An expert asksA team says that folders named adapter/in and
adapter/out protect the domain better. What does the book answer?
adapter/in and
adapter/out protect the domain better. What does the book answer?3The note feature wants to read an order's status. Which classes of the order feature may it
use, and which rule checks that?
OrderService
and OrderView, and not the application's ports. The rule
featuresUseEachOtherOnlyThroughApplication fails the build for any other use.
Order, OrderRepository and OrderDocument stay private to
the order feature.4When should the order feature get a separate read model?
5TranslateWrite this C# class in Java, so that only its own package can use it. It
stores an order in the collection orders:
internal sealed class OrderDocument
{
public string? Status { get; set; }
}
orders:
internal sealed class OrderDocument
{
public string? Status { get; set; }
}@Document("orders")
class OrderDocument extends BaseDocument {
String customerName;
String customerEmail;
String deliveryMethod;
String status;A class and fields with no access modifier are package-private. Only
com.acme.orderservice.order.persistence can use them. That is narrower than
internal, which opens a type to the whole assembly. @Document("orders")
names the collection.
Quick reference#
Where each concept of the shop's design is, in the companion:
| Concept | File in the companion |
|---|---|
| the application class | order-service/src/main/java/com/acme/orderservice/OrderServiceApplication.java |
| an aggregate root | order/domain/model/Order.java |
| an entity inside the aggregate | order/domain/model/OrderLine.java |
| a typed ID | order/domain/model/OrderId.java |
| a value object | order/domain/model/Money.java |
| a domain event | order/domain/event/OrderShipped.java |
| a rule violation | order/domain/exception/OrderNotPaidException.java |
| a repository port | order/domain/repository/OrderRepository.java |
| a domain service | order/domain/service/ShippingPolicy.java |
| an application service | order/application/OrderService.java |
| a view | order/application/OrderView.java |
| a port to another service | order/application/port/StockChecker.java |
| a controller | order/api/OrderController.java |
| a request DTO | order/api/dto/PlaceOrderRequest.java |
| a stored document | order/persistence/OrderDocument.java |
| a persistence adapter | order/persistence/OrderPersistenceAdapter.java |
| an HTTP client | order/client/StockHttpClient.java |
| an anti-corruption layer | order/client/StockAdapter.java |
| an integration event | order/messaging/event/OrderPlacedV1.java |
| a message listener | order/messaging/PaymentReceivedListener.java |
| the feature's wiring | order/OrderConfig.java |
| a light-style entity | note/model/Note.java |
| a simple feature | reference/application/ReferenceDataService.java |
| a migration | migration/V001CreateIndexes.java |
| the permission annotations | shared/security/OrdersRead.java |
| the architecture rules | shop-platform/shop-platform/src/main/java/com/acme/platform/test/ArchitectureRules.java |
The build: a parent POM and a platform library#
Every shop service builds the same way. A parent Project Object Model (POM) sets the Java release, the plugins and the version of every library. A platform library brings everything cross-cutting. So a service's own build file names the parent, the library and almost nothing else. This chapter walks through the build files and each Maven setting in them. It shows how one change upgrades Boot everywhere.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | no equivalent | five POMs: two aggregators, the parent, the library and the service | an aggregator only lists folders to build, and a parent passes its settings on to its children Why |
| twist | Directory.Build.props, found in a folder above the project | shop-platform-parent, a published POM that a service names | the service fetches the parent by its coordinates, so it works in a repository of its own Why |
| twist | <TargetFramework>net10.0</TargetFramework> in Directory.Build.props | <java.version>25</java.version> in the parent | Boot's parent turns java.version into maven.compiler.release, so one property sets the release Why |
| twist | <PackageVersion Include="..." Version="..." /> in Directory.Packages.props | dependencyManagement, with BOMs imported | one BOM sets the versions of a whole family of libraries, and the first import wins a clash Why |
| trap | a version in Directory.Packages.props is enough | a library that no BOM covers needs its own managed entry | without the entry, a child that leaves out the version fails to build Why |
| new | a test project with its own PackageReference list | test libraries in the parent's dependencies, with the test scope | every child gets them, and they never reach a production jar Why |
| trap | dotnet test runs every [Fact], whatever the class is called | surefire runs only classes named like *Test, *Tests, Test* or *TestCase | a test class named OrderCheck never runs, and nothing warns you Why |
| new | dotnet test --filter | *IT classes in failsafe, switched on by the it profile | mvn verify runs the fast tests, and mvn verify -Pit adds the tests that need Docker Why |
| twist | no equivalent | pluginManagement in the parent | it configures a plugin for every child, but adds the plugin only where a POM names it Why |
| new | <PackageReference Include="Shop.Platform" /> | the library's dependencies, which every service gets | one dependency brings web, security, tracing, clients, DocumentDB and messaging Why |
| twist | spring-boot-starter-web | spring-boot-starter-webmvc and the other Boot 4 names | Boot 4 named its modules after their technology, and the old names of some starters are deprecated Why |
| twist | PrivateAssets="all" on a PackageReference | <optional>true</optional> | the library compiles against the test libraries, but a service that uses it does not get them Why |
| trap | no equivalent | spring-boot-configuration-processor in the compiler's annotationProcessorPaths | javac 26 does not run a processor that is only on the class path, so an optional dependency alone writes no metadata Why |
| twist | a csproj with one PackageReference and no version | the service POM: a parent, two dependencies and one plugin | every version and setting comes from the parent Why |
| twist | dotnet publish | spring-boot-maven-plugin | it repackages the jar into an executable jar, with layers for the Docker image Why |
| twist | dotnet build Shop.sln | mvn verify at the root, or mvn -pl order-service -am package | the commands that each part of the companion uses Why |
| new | update the SDK version in global.json and every package | change the parent's Boot version and release a new platform version | each service then moves to the new platform version when it is ready Why |
| twist | Directory.Build.props, Directory.Packages.props and a NuGet package | the parent POM and the platform library | the parent is versioned and fetched like a package, and it may set every build setting of its children Why |
Five build files, five jobsnew#
A developer opens the companion and finds five files named pom.xml. Some of them
build code, and some only list other folders. Each POM has one job:
| File | Packaging | Its job |
|---|---|---|
| pom.xml, at the root | pom | builds the platform and the order service together, for the book only |
| shop-platform/pom.xml | pom | builds the parent and the library together, and is the root of the platform's repository |
| shop-platform/shop-platform-parent/pom.xml | pom | the parent of every service and of the library, which sets Java, the plugins and the versions |
| shop-platform/shop-platform/pom.xml | jar | the library that every service adds |
| order-service/pom.xml | jar | the service itself |
Two kinds of POM appear here. They are easy to mix up, because both have
<packaging>pom</packaging>:
- An aggregator lists folders in
<modules>. Maven builds each folder when the aggregator builds. The children do not inherit anything from the aggregator. - A parent is named in a child's
<parent>element. The child inherits the parent's properties, dependencies, managed versions and plugins. The parent does not know its children.
The platform's aggregator lists the parent and the library:
<!--
Builds the parent POM and the platform library together. It is never published.
In the platform's own repository, this file is the root pom.xml.
-->
<!-- ... -->
<groupId>com.acme.platform</groupId>
<artifactId>shop-platform-build</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>shop-platform-parent</module>
<module>shop-platform</module>
</modules>The file is never published, because no project depends on shop-platform-build.
It only lets one command build both modules. The parent and the library are published, because
services name them. For aggregators in general, see the section Multi-module:
your solution file.
The root pom.xml of the companion is an aggregator of the same kind, with the
modules shop-platform and order-service. It exists only so that one
command builds and tests the book's code. For why real services never build together, see the
section The three repositories in real life.
The parent POM, on top of Spring Boot's parenttwist#
The shop's parent has a parent of its own. It is the parent POM of Spring Boot:
<!--
The parent POM of every shop service and of the platform library.
It sets the Java release, the build plugins and the version of every dependency.
A service never writes a version number itself. To upgrade Spring Boot for every service,
change the parent below and release a new platform version.
-->
<!-- ... -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<groupId>com.acme.platform</groupId>
<artifactId>shop-platform-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<properties>
<java.version>25</java.version>
<shop-platform.version>1.0.0-SNAPSHOT</shop-platform.version><artifactId>spring-boot-starter-parent</artifactId>The parent of Spring Boot manages the versions of the libraries that Boot supports. It also configures plugins, such as the compiler. So the shop's parent gets all of that, and every service gets it from the shop's parent. For the basics, see the section A Spring Boot pom.
<relativePath/>By default, Maven first looks for a parent in the folder above, at
../pom.xml. An emptyrelativePathswitches that search off. Maven then always fetches the parent from a repository. The folder above holds the platform's aggregator, which is not this parent's parent, so the search must be off.<java.version>25</java.version>Boot's parent sets
maven.compiler.releasefromjava.version. So this one line makes every service compile for Java 25. Boot 4.1.2's own default is 17.<shop-platform.version>1.0.0-SNAPSHOT</shop-platform.version>The version of the platform library, used further down. It is the same as the parent's version, because the parent and the library are released together.
The chain has three levels. A service inherits from shop-platform-parent, which
inherits from spring-boot-starter-parent. A setting in a lower level replaces the
same setting in a higher level. So a service could set java.version itself. It should leave the
setting alone, so that every service runs on the same Java release.
The closest match in .NET is a Directory.Build.props file. MSBuild finds it in a
folder above the project and applies it. A run for this book put the target framework in such a
file, and the project built with it:
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>12</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project><properties>
<java.version>25</java.version>The C# pane sets TargetFramework for every project below the file. The Java pane
sets java.version for every child. The two files differ in how a project finds them. Directory.Build.props is found by
its folder. So it works only when the project is in a folder below it, in the same repository. The shop's
parent is found by its coordinates, a group, an artifact and a version. So a service in a
repository of its own still gets it from the company's artifact repository.
One place for every version: managed versions and BOMstwist#
The shop uses libraries that Spring Boot does not manage, such as Spring Modulith and
Resilience4j. Their versions are properties in the parent. Each property is used once, in the
section dependencyManagement:
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Shop.Platform" Version="1.0.0" />
</ItemGroup>
</Project> <!-- Libraries that Spring Boot does not manage -->
<spring-modulith.version>2.1.2</spring-modulith.version>
<spring-cloud-aws.version>4.1.0</spring-cloud-aws.version>
<resilience4j.version>2.4.0</resilience4j.version>
<shedlock.version>7.10.1</shedlock.version>
<springdoc.version>3.2.0</springdoc.version>
<archunit.version>1.5.1</archunit.version>
// ...
<dependencyManagement>
<dependencies>
<!-- Each BOM fixes the versions of a family of artifacts, like Directory.Packages.props -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-bom</artifactId>
<version>${spring-modulith.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>The versions are the ones the companion builds with, as of October 2026. Upgrading Resilience4j means changing one property.
A bill of materials (BOM) is a POM that only lists managed
versions. The element <scope>import</scope>, on a dependency of type
pom, copies the BOM's managed versions into this POM. Spring Modulith publishes a BOM for all its modules. So
the platform library can add spring-modulith-events-core and
spring-modulith-events-mongodb with no version, and both get 2.1.2. For the basics of
BOMs, see the section Profiles and BOMs.
The parent imports four BOMs. They are the BOMs of Spring Modulith, Spring Cloud AWS, Resilience4j and ShedLock. If
two imported BOMs manage the same artifact, the BOM declared first wins. An entry written directly
in dependencyManagement wins over every import. So the order of the imports matters,
and a direct entry is the way to settle a clash.
Both sides of the comparison manage versions in one place. In the run for this book, the
project then referenced Shop.Platform with no version, and the build used 1.0.0. A
POM's dependencyManagement has the same effect. It adds no dependency by itself. It
only gives a version to a dependency that a POM declares without one.
Some artifacts are in no imported BOM. Each of them needs an entry of its own:
<!-- Artifacts that no imported BOM covers, so they are pinned one by one -->
<dependency>
<groupId>io.awspring.cloud</groupId>
<artifactId>spring-cloud-aws-modulith-events-sns</artifactId>
<version>${spring-cloud-aws.version}</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit</artifactId>
<version>${archunit.version}</version>
</dependency>
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<version>${json-schema-validator.version}</version>
</dependency>
<!-- The platform library itself -->
<dependency>
<groupId>com.acme.platform</groupId>
<artifactId>shop-platform</artifactId>
<version>${shop-platform.version}</version>
</dependency>spring-cloud-aws-modulith-events-sns belongs to Spring Cloud AWS, but the
spring-cloud-aws-dependencies BOM does not list it. So the parent pins it with the
same version property. springdoc-openapi, ArchUnit and the JSON Schema validator have no BOM that
the parent imports. The
last entry is the platform library. Because of it, a service names
shop-platform with no version.
A dependency without a version needs a managed version from somewhere. If a
service adds archunit with no version, and no parent or BOM manages it, the build
stops. The POM is invalid, because the dependency's version is missing. Add the library to the
parent's dependencyManagement first, and then use it without a version.
Test libraries for every service, from the parentnew#
Every shop service tests with the same libraries. A service that forgets one cannot use the platform's test support. So the parent declares the test libraries as real dependencies, not only as managed versions:
<!--
Test libraries for every service. Each child inherits them with the test scope,
so they never reach a production jar, and a service declares only shop-platform.
-->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
// ...
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-mongodb</artifactId>
<scope>test</scope>
</dependency>A <dependencies> section in a parent is inherited by every child. So each
service has these libraries, though its own POM does not name them. The scope
test puts a library on the test classpath only. The run for this book listed the
order service's dependency tree:
[INFO] com.acme:order-service:jar:1.0.0-SNAPSHOT
[INFO] +- com.acme.platform:shop-platform:jar:1.0.0-SNAPSHOT:compile
[INFO] +- org.springframework.boot:spring-boot-starter-cache:jar:4.1.2:compile
[INFO] +- org.springframework.boot:spring-boot-starter-test:jar:4.1.2:test
[INFO] +- org.springframework.boot:spring-boot-starter-webmvc-test:jar:4.1.2:test
[INFO] +- org.springframework.boot:spring-boot-starter-data-mongodb-test:jar:4.1.2:test
[INFO] +- org.springframework.boot:spring-boot-starter-security-test:jar:4.1.2:test
[INFO] +- org.springframework.boot:spring-boot-testcontainers:jar:4.1.2:test
[INFO] +- org.testcontainers:testcontainers-mongodb:jar:2.0.5:test
[INFO] +- com.tngtech.archunit:archunit:jar:1.5.1:test
[INFO] +- org.awaitility:awaitility:jar:4.3.0:test
[INFO] \- com.networknt:json-schema-validator:jar:3.0.8:testThe service's own POM names only the first two. Every line after them ends in
test, so none of these libraries is in the executable jar. The
same run listed the jar's contents. It held 196 libraries, and none of them was a test
library.
The parent has nine test dependencies:
| Artifact | What a test gets from it |
|---|---|
| spring-boot-starter-test | JUnit, AssertJ, Mockito and Spring's test support |
| spring-boot-starter-webmvc-test | @WebMvcTest and MockMvc for the controller slices |
| spring-boot-starter-data-mongodb-test | @DataMongoTest for the persistence slices |
| spring-boot-starter-security-test | jwt() and the other helpers that put a caller into a test request |
| spring-boot-testcontainers | @ServiceConnection, which points Spring Boot at a container |
| testcontainers-mongodb | the MongoDB container, in the Testcontainers 2 artifact |
| archunit | the architecture rules |
| awaitility | a wait for a result that arrives later, such as a message that a listener handles |
| json-schema-validator | a check of each published event against its JSON schema, version 3.0.8 as of October 2026 |
The MongoDB artifact has a Testcontainers 2 name, testcontainers-mongodb. Its
class is org.testcontainers.mongodb.MongoDBContainer. The old class in
org.testcontainers.containers still exists and is deprecated. The new class starts
a replica set only after withReplicaSet(), and transactions need a replica set. For
the trap in that change, see the chapter Testing in depth.
The name of a test class decides whether it runstrap#
A developer adds a test class for the shipping rules. The name OrderCheck seems
clear. The test fails on purpose, to see it fail. The developer runs the build:
class OrderCheck {
@Test
void anOrderShipsOnlyWhenPaid() {
fail("this should fail the build");
}
}Show the answer
Surefire runs only classes whose names match its patterns. There are four
default patterns. A class name starts with Test, or it ends in one of three words.
They are Test, Tests and TestCase. A run for this book had four
test classes. Three were named OrderTest, OrderTests and
TestOrder. The fourth was named OrderCheck. The build ran the first three, and never the fourth.
In .NET, dotnet test runs every [Fact], whatever the class is
called. A run with xUnit ran a test in a class named OrderChecks.
Name every test class ...Test, or ...IT when it needs Docker.
The shop names a test by what it tests, plus Test or IT.
OrderTest tests Order, and OrderPersistenceAdapterIT
tests the adapter against a database. A test class that matches no pattern is the more dangerous
mistake, because a green build hides it.
Fast tests and integration testsnew#
The order service has two kinds of test. Most of them need nothing but the Java Virtual Machine (JVM), and they run in seconds. A few start MongoDB or an AWS emulator in Docker, and Docker is not always running. So the build runs the two kinds apart:
| Command | Plugin | Runs | Needs Docker |
|---|---|---|---|
| mvn verify | maven-surefire-plugin | every *Test class | no |
| mvn verify -Pit | surefire, then maven-failsafe-plugin | every *Test class, then every *IT class | yes |
Surefire is the plugin that runs unit tests in the phase test. Failsafe is the
plugin that runs integration tests in the phases integration-test and
verify. Failsafe's default patterns are IT*, *IT and
*ITCase. For the phases, see the section The
lifecycle.
The parent configures both plugins in pluginManagement:
<pluginManagement>
<plugins>
<!-- ... -->
<!-- mvn verify runs the fast tests only: *Test.java. The *IT.java tests need Docker. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar}</argLine>
<excludes>
<exclude>**/*IT.java</exclude>
</excludes>
</configuration>
</plugin><pluginManagement>This section configures a plugin for this POM and every child. It does not add the plugin to the build. A plugin runs only when a POM names it in
<plugins>, or when the lifecycle runs it by default. Surefire runs by default in the phasetest, so this configuration always applies.<exclude>**/*IT.java</exclude>Surefire's defaults already leave out
*IT. The line states the rule anyway, so it still holds if someone adds an include pattern later.<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar}</argLine>The test JVM starts with Mockito as a Java agent. The property
org.mockito:mockito-core:jarholds the path of Mockito's jar. The plugin that sets it is shown below.@{argLine}keeps any argument that another plugin, such as JaCoCo, has set.
The parent's <plugins> section adds the plugin that sets the property. Its
comment gives the reason for the agent:
<!--
Sets the property org.mockito:mockito-core:jar to the jar's path, so the test JVMs
below can load Mockito as an agent at startup. Loading it later, on first use, prints
a warning on current JDKs, which will block it in a future release.
-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>properties</goal>
</goals>
</execution>
</executions>
</plugin>The goal properties of maven-dependency-plugin sets one property for
each dependency, named by its coordinates, with the path of its jar. This plugin is in
<plugins>, not in pluginManagement, so it runs in every child.
A run for this book removed the agent from the test JVM and ran OrderServiceTest
on Java Development Kit (JDK) 26.0.1. The tests passed, and the output warned twice:
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK.
WARNING: A Java agent has been loaded dynamically (/Users/kodebot/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.15/byte-buddy-agent-1.18.15.jar)
WARNING: Dynamic loading of agents will be disallowed by default in a future releaseSo without -javaagent, Mockito loads its agent itself while the tests run, and the
JDK warns that a future release will block that. With the agent named at startup, no warning appears.
Failsafe runs only in the profile it:
<profiles>
<!-- mvn verify -Pit also runs the integration tests (*IT.java) with Testcontainers. -->
<profile>
<id>it</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>A Maven profile is a named block of the build that
-P switches on. This one names failsafe in <plugins> and binds its
two goals. The goal integration-test runs the *IT classes. The goal
verify fails the build if one of them failed.
The parent has a second profile, contract-first. It runs
openapi-generator-maven-plugin 7.26.0, which generates Java interfaces and models from a service's
contracts/openapi.yaml before the code compiles. The profile is switched on by a
property, not by -P. It needs two conditions. The build must set
-Dcontract-first, and the module must have the file
contracts/openapi.yaml. Maven 3.9 switches a profile on only when all its conditions
hold. So the platform's modules, which have no contract, skip it. A run of
mvn verify -Dcontract-first on the companion generated the code, compiled it with the
service, and passed. For what the profile is for, and when to choose it, see the section
Contract first: openapi-generator.
Boot's parent has failsafe in its own pluginManagement, with the same two goals.
That alone runs nothing. The run for this book confirmed it. A plain mvn verify on
Boot's parent did not run OrderIT. It ran only in a profile that names the
plugin.
The choice of a profile has a reason. A developer without Docker still gets a full, green
mvn verify. The build server runs mvn verify -Pit, where Docker is
always there. For what each kind of test covers, see the chapter Testing in
depth.
What the library brings to every servicenew#
A service adds one dependency, shop-platform. Through it, the service gets every
library that the platform uses. The library's POM groups them by concern:
<dependencies>
<!-- Web, validation and health -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
// ...
<!-- Security: each service checks JWTs (resource server) and gets tokens for its own calls (client) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>The first starter, spring-boot-starter-webmvc, brings Spring model-view-controller (MVC) and the embedded
server. Each dependency is a compile dependency with no version. Its version comes from the parent.
Every project that uses the library also gets the library's compile dependencies. So each of
these reaches the service's classpath, and its auto-configuration runs there:
| Concern | Artifacts |
|---|---|
| web, validation and health | spring-boot-starter-webmvc, spring-boot-starter-validation, spring-boot-starter-actuator |
| the OpenAPI document | springdoc-openapi-starter-webmvc-ui |
| security | spring-boot-starter-security-oauth2-resource-server, spring-boot-starter-security-oauth2-client |
| tracing | spring-boot-starter-opentelemetry |
| calling other services | spring-boot-starter-restclient, resilience4j-circuitbreaker, resilience4j-retry |
| DocumentDB | spring-boot-starter-data-mongodb |
| the outbox and the inbox | spring-modulith-events-api, -core and -mongodb, spring-cloud-aws-modulith-events-sns, spring-cloud-aws-starter-sns, spring-cloud-aws-starter-sqs |
| a lock for the resender | shedlock-core, shedlock-provider-mongo |
So the order service's POM names none of these. It needs only one extra starter, for its cache. For what each concern adds at run time, see the chapter Writing the platform library.
One library for everything has a cost. A service that sends no events still has the messaging libraries on its classpath. The platform answers with a switch that turns messaging off. For the switch, and for why the shop chose one library over one per concern, see the section One library, not one per concern.
The Boot 4 starter namestwist#
A tutorial for Boot 3 adds spring-boot-starter-web. The shop's library adds
spring-boot-starter-webmvc. Spring Boot 4 named its modules after their technology,
in the form spring-boot-<technology>. These are the starters that the shop uses:
| Boot 4 starter | Note |
|---|---|
| spring-boot-starter-webmvc | it was spring-boot-starter-web until Boot 4.0, and this rename is the first one a Boot 3 tutorial meets |
| spring-boot-starter-security-oauth2-resource-server | the old name spring-boot-starter-oauth2-resource-server still exists in 4.1.2, and is deprecated |
| spring-boot-starter-security-oauth2-client | the old name spring-boot-starter-oauth2-client still exists in 4.1.2, and is deprecated |
| spring-boot-starter-restclient | without it, Boot creates no RestClient.Builder bean, and the platform's HTTP clients need one |
| spring-boot-starter-opentelemetry | it sends traces and metrics with OpenTelemetry, and it has no Boot 3 starter |
| spring-boot-starter-webmvc-test | it holds @WebMvcTest and MockMvc, in the package org.springframework.boot.webmvc.test |
| spring-boot-starter-data-mongodb-test | it holds @DataMongoTest, in the package org.springframework.boot.data.mongodb.test |
| spring-boot-starter-security-test | it holds jwt() and the other helpers of spring-security-test |
The library adds the two security starters under their Boot 4 names:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-client</artifactId>
</dependency>The second <artifactId> is the Boot 4 name of the OAuth2 client starter. With the old name spring-boot-starter-oauth2-client, the build would still work in
4.1.2, but the starter is deprecated. For the general renames, see the section Starters. For a whole
upgrade from Boot 3, see the chapter Migrating Spring Boot 3 to 4.
Optional dependencies: test support without test librariestwist#
The platform ships test support, such as the annotation @PlatformWebMvcTest and
ArchitectureRules. That code is in the library's main source, so it must compile
against JUnit, Spring's test support and ArchUnit. But a service's production jar must never
contain those libraries:
<ItemGroup>
<PackageReference Include="xunit.assert" Version="2.9.3" PrivateAssets="all" />
</ItemGroup><!--
Needed only by com.acme.platform.test. They are optional, so they never reach a
service's production classpath. Services get them, test-scoped, from the parent POM.
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<optional>true</optional>
</dependency>
// ...
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit</artifactId>
<optional>true</optional>
</dependency><optional>true</optional> marks a dependency that this project needs,
but a project that uses it does not. The library compiles against ArchUnit. A service that
depends on the library does not get ArchUnit from it. The run for this book showed both sides.
In the library, ArchUnit is compile (optional). In the service, ArchUnit is
test, because it comes from the parent's test dependencies.
PrivateAssets="all" has the same purpose in .NET. A run for this book packed a
library with that attribute on xunit.assert. The dependency list of the packed file held its
other reference, and not xunit.assert.
So the two parts work together. The library compiles its test code with optional dependencies.
The parent gives each service the same libraries with the test scope. A service's test code can
then use @PlatformWebMvcTest, and its production jar holds none of it.
The configuration processor, and the file it did not writetrap#
A developer types platform. in application.yml and expects the IDE to
complete the platform's settings. An IDE does that from a file named
META-INF/spring-configuration-metadata.json in the library's jar. The
annotation processor
spring-boot-configuration-processor writes the file while the library compiles. It
reads every @ConfigurationProperties
class.
The usual advice is to add the processor to the library as an optional dependency. The companion's library did that first, and the file was missing.
On JDK 26, the optional dependency alone wrote no metadata. A build of the
companion's library on JDK 26.0.1, with maven-compiler-plugin 3.15.0, produced no
spring-configuration-metadata.json. The build printed no warning about it. The same
build with -Dmaven.compiler.proc=full wrote the file, with every
platform.* key.
A run of javac 26.0.1 for this book showed the cause. With the processor only on the
class path, javac did not run it. With the processor on the processor path, or with the option
-proc:full, javac ran it and wrote the file. The research run for this book named the
processor in the compiler's annotationProcessorPaths, which is the processor path,
and the file appeared.
So the parent names the processor in the compiler's configuration, in its
pluginManagement:
<!--
Runs Spring Boot's configuration processor while the code compiles. It writes
META-INF/spring-configuration-metadata.json from each @ConfigurationProperties
class, so an IDE completes the keys. The processor must be named here: on JDK 26,
a processor that is only a dependency did not run, and the file was missing.
Boot manages the processor's version.
-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin><annotationProcessorPaths>This list names the annotation processors that the compiler runs, and where Maven finds them. A processor in this list is not a dependency of the project. So it never reaches a service, and the library needs no optional dependency for it.
<path>Each processor is one
path, with a group and an artifact. The path has no version, because Boot's parent manages the version of the processor. The build of the companion took that version.
The setting is in pluginManagement, so it applies to every child of the parent.
After the change, the build wrote the file into the library's jar. It held 16 keys in 9 groups,
from platform.logging to platform.security. The order service's jar got
a file too, with the four keys of clients.stock. Without the file, a service still
starts and binds every setting. Only the completion in the IDE is missing. Check the library's
jar for the file after a change to the build.
The service POM: almost nothingtwist#
With the parent and the library in place, a service's POM is short. Here it is in full, without its header:
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Shop.Platform" />
</ItemGroup>
</Project><!--
The platform parent sets Java, the plugins and every dependency version.
In this service's own repository, remove <relativePath>: the parent then comes from the
company's artifact repository.
-->
<parent>
<groupId>com.acme.platform</groupId>
<artifactId>shop-platform-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../shop-platform/shop-platform-parent/pom.xml</relativePath>
</parent>
<groupId>com.acme</groupId>
<artifactId>order-service</artifactId>
<version>1.0.0-SNAPSHOT</version>
<dependencies>
<!-- Everything cross-cutting. No version: the parent manages it. -->
<dependency>
<groupId>com.acme.platform</groupId>
<artifactId>shop-platform</artifactId>
</dependency>
<!-- Only what this service needs on top. Test libraries come from the parent. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Builds the executable jar, with layers for the Docker image -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build><relativePath>../shop-platform/shop-platform-parent/pom.xml</relativePath>In the companion, the parent is in a sibling folder, so this path points to it. In the service's own repository, the folder does not exist. So the line is removed, and Maven fetches the parent from the company's artifact repository.
<artifactId>shop-platform</artifactId>The one dependency that brings everything cross-cutting. Its version is managed in the parent.
<artifactId>spring-boot-starter-cache</artifactId>The only extra starter. The reference feature caches its lookup lists, and no other service needs a cache yet. So the cache is in the service, not in the platform.
<artifactId>spring-boot-maven-plugin</artifactId>This plugin repackages the jar in the phase
package. The result is a Java archive (JAR) thatjava -jarstarts. It holds the service's classes and every library. The library has no such plugin, because a library is not started on its own.
The C# pane is the project file from the run for this book. Its target framework came from a
Directory.Build.props above it, and its version of Shop.Platform from a
Directory.Packages.props. The Java pane needs more lines, because Maven names a
parent by its coordinates, and the service must declare its own coordinates too.
The executable jar has layers. Libraries change rarely, so they are in their own layers, and
Docker reuses them from its cache. The Dockerfile extracts the layers with
java -Djarmode=tools -jar app.jar extract --layers --launcher. For the image, see the
chapter Running locally and shipping. For executable jars in general, see the
section The fat JAR.
The commandstwist#
The companion builds with Maven 3.9.16, and all its tests pass, the unit tests and the integration tests. These are the commands that it uses:
| Command | Where | What it does |
|---|---|---|
| mvn verify | the companion's root | builds the platform and the service, and runs every *Test |
| mvn verify -Pit | the companion's root | also runs every *IT, with MongoDB and an AWS emulator in Docker |
| mvn package | order-service | builds the executable jar that the Dockerfile copies |
| mvn -pl order-service -am package | the companion's root | builds the service and the modules it depends on, here the parent and the library |
| mvn install | shop-platform | puts the parent and the library into the local repository, so a service in another folder finds them |
The Dockerfile states the build command in its first line:
# Build the jar first: mvn package (from the companion root: mvn -pl order-service -am package)-pl order-service selects one module, and -am also builds the modules
it needs. For both flags, see the section Command mapping.
The first build downloads every library and plugin. So it needs Maven Central, or a company
repository that mirrors it. After that, the libraries are in ~/.m2/repository, and
mvn -o builds offline. For a company repository, see the section
Where artifacts come from.
Upgrading Spring Boot for every servicenew#
A new patch release of Spring Boot comes out. The shop has ten services. Without a shared parent, ten teams would each change a version, and some would forget. With the parent, the upgrade has one place to start. The parent's comment says so:
It sets the Java release, the build plugins and the version of every dependency.
A service never writes a version number itself. To upgrade Spring Boot for every service,
change the parent below and release a new platform version.The words change the parent below mean the element that names spring-boot-starter-parent, version
4.1.2. The steps are these:
- The platform team changes the version of
spring-boot-starter-parentinshop-platform-parent. It checks the versions of the libraries that Boot does not manage, such as Spring Modulith and springdoc, against the new Boot release. - It builds the platform with
mvn verify -Pit. The platform's own tests, and the wiring test of at least one service, show whether an auto-configuration changed. - It releases a new platform version, such as 1.4.0, with upgrade notes.
- Each service changes the version in its
<parent>element to 1.4.0. The library's version follows, because the parent manages it. - Each service builds and runs its own tests, then ships.
The parent's version and the library's version are always the same. So a service changes one number, and it gets a matching parent and library. The rule of the shop is that a service stays at most one minor version behind. For the versioning rules, see the section Versions, and the rules for upgrading.
For a patch release of Boot, the change is usually the one version line. A minor release can rename a property or deprecate a class. A major release, such as Boot 3 to Boot 4, renames starters and packages. For that kind of upgrade, see the chapter Migrating Spring Boot 3 to 4.
Compared with Directory.Build.props and NuGettwist#
Five .NET mechanisms each have a counterpart in the parent or the library. The match is close, but not exact:
| .NET | The shop's build | What differs |
|---|---|---|
| Directory.Build.props | the parent's properties and plugins | the parent is published and named by coordinates, so it works across repositories |
| Directory.Packages.props | the parent's dependencyManagement | a Maven BOM imports a whole family of versions in one entry |
| a NuGet package | the platform library | a library can bring auto-configuration, which runs without a line of startup code |
| a test project's PackageReference list | the parent's test dependencies | every child inherits them with the test scope |
| PrivateAssets="all" | <optional>true</optional> | both keep a dependency to the project itself |
The largest difference is the parent. A shared Directory.Build.props is found by
its folder. A Maven parent is found by its version. A NuGet package can also add build settings to
the projects that use it. Its files build/<package_id>.props and
build/<package_id>.targets are imported into each such project. But NuGet's
documentation says that such files must not set the properties and items that affect restore, such
as TargetFramework and PackageVersion. A Maven parent may set the Java
release and every managed version. So each service chooses the version of its
build settings, as it chooses the version of a library. That is why an upgrade of Spring Boot is
one release of the platform, and a change of one number in each service.
1An expert asksThe parent declares the test libraries in <dependencies>
with the test scope, and the library declares the same libraries as optional. Why both?
<dependencies>
with the test scope, and the library declares the same libraries as optional. Why both?@PlatformWebMvcTest, so
it must compile against the test libraries. The optional flag keeps them from reaching a service through
the library. The parent then gives every service the same libraries with the test scope. So a
service's tests can use the platform's test support, and its production jar holds no test
library.2Two imported BOMs in dependencyManagement both manage the same artifact. Which
version does a child get, and how do you choose another one?
dependencyManagement both manage the same artifact. Which
version does a child get, and how do you choose another one?dependencyManagement wins over every import, so write one to settle the clash.3An expert asksWhy does the parent set <relativePath/> for Spring Boot's
parent, when the service sets a real path for the shop's parent?
<relativePath/> for Spring Boot's
parent, when the service sets a real path for the shop's parent?../pom.xml. The shop's parent is
in the platform's folder, whose pom.xml is the aggregator. That file is not the
parent of Spring Boot, so the empty element switches the search off. In the companion, the service's parent is in a sibling folder, so the service names
that path. In the service's own repository, the line is removed.4A developer has no Docker on their laptop. Which command gives them a full, green build, and
which tests does it leave out?
mvn verify. It runs every *Test class with surefire. It leaves out
the *IT classes, which run only with mvn verify -Pit, because the profile
it adds failsafe.5TranslateWrite this central version as an entry of the shop's parent POM, so that a
service can add the platform library with no version:
<ItemGroup>
<PackageVersion Include="Shop.Platform" Version="1.0.0" />
</ItemGroup>
<ItemGroup>
<PackageVersion Include="Shop.Platform" Version="1.0.0" />
</ItemGroup><!-- The platform library itself -->
<dependency>
<groupId>com.acme.platform</groupId>
<artifactId>shop-platform</artifactId>
<version>${shop-platform.version}</version>
</dependency>The entry is inside dependencyManagement, so it adds no dependency. It only gives
a version to a shop-platform dependency that a child declares without one. The
version is the property shop-platform.version.
Quick reference#
The managed versions of the shop's parent, as of October 2026:
| Library | Version | Managed by |
|---|---|---|
| Spring Boot | 4.1.2 | the parent's parent, spring-boot-starter-parent |
| Java release | 25 | the property java.version |
| Spring Modulith | 2.1.2 | spring-modulith-bom |
| Spring Cloud AWS | 4.1.0 | spring-cloud-aws-dependencies, plus one pinned artifact |
| Resilience4j | 2.4.0 | resilience4j-bom |
| ShedLock | 7.10.1 | shedlock-bom |
| springdoc-openapi | 3.2.0 | a pinned entry |
| ArchUnit | 1.5.1 | a pinned entry |
| json-schema-validator | 3.0.8 | a pinned entry |
| openapi-generator-maven-plugin | 7.26.0 | the property openapi-generator.version, used in the profile contract-first |
| Testcontainers | 2.0.5 | Spring Boot |
| the platform library | the same as the parent | a pinned entry, with the property shop-platform.version |
Maven in depth: resolution and the lifecycle#
This chapter follows mvn verify from the shell to the jar, a
Java archive (JAR). It shows how
Maven turns the command into plugin goals. It shows how Maven
builds the Project Object Model (POM) it really uses, and how
it chooses one version of each library. Then one added dependency breaks the order service at run time.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | dotnet build runs a fixed MSBuild sequence | mvn verify runs the phases of the default lifecycle, in order, up to verify | each phase runs the plugin goals bound to it, and a phase with no goals does nothing Why |
| twist | a target in the project file, or in Directory.Build.targets | a plugin execution bound to a phase | the goal runs in that phase of every build, after the goals that the packaging binds Why |
| twist | the project after the SDK and Directory.Build.props are applied | the effective POM, printed by help:effective-pom | the parent chain, the imported bills of materials and the active profiles are folded in, and nothing else counts Why |
| trap | NuGet takes the lowest version that satisfies every reference | Maven takes the nearest declaration, and the first one at equal depth | a direct dependency on an old version wins over a library's need for a newer one, with no warning Why |
| twist | NuGet warns of a downgrade with NU1605 | nothing warns, unless an enforcer rule runs | the build is green, the tests may be green, and the mismatch shows at run time Why |
| twist | Directory.Packages.props, with versions managed centrally | dependencyManagement in the parent POM | a managed version beats mediation, and a direct version beats the managed one Why |
| twist | the NuGet global-packages folder and NuGet.Config | ~/.m2/repository and settings.xml | the local repository caches every download, and a mirror in settings.xml sends every request to the team's repository manager Why |
| new | no equivalent | NoSuchMethodError, at the first call | the compiler checked one version of a class, and the runtime linked another Why |
| twist | a .dll in bin/ with its dependencies beside it | BOOT-INF/lib inside the repackaged jar | the Spring Boot plugin's repackage goal puts every dependency jar inside the jar, with an index and layers Why |
| twist | packages.lock.json, checked with restore --locked-mode | project.build.outputTimestamp, and exact versions | with the property set, two builds of one commit give the same bytes, in Maven 3.9 Why |
Scene 1: the command becomes goalstwist#
For the basics of a POM against a .csproj, see the
chapter Maven vs csproj. That chapter also gives the first look
at two versions of one library. The shop's services share a
platform library. For the
parent POM of the shop, see the chapter
The build: a parent POM and a platform library. This chapter starts where
those two stop, and it follows a real build. The numbers and the output lines come from one run
on 26 October 2026, with Maven 3.9.16, on the author's laptop. Maven ran on Java Development Kit (JDK) 26.0.1.
The run script is companion/runs/maven/run.sh.
The service is built with one command. In .NET, three commands do the same work:
dotnet restore
dotnet build --configuration Release
dotnet test --no-buildmvn verifymvn verify names a phase, not a task. Maven has a list of phases called the
default lifecycle, and the command runs every phase from the first one up to the named one.
The phases that matter for the service are these, in order:
| Phase | What it means, in Maven's words | What ran in the service's build |
|---|---|---|
| validate | validate the project is correct and all necessary information is available | nothing bound |
| compile | compile the source code of the project | compiler:compile |
| test | run tests using a suitable unit testing framework | surefire:test, the fast tests |
| package | take the compiled code and package it in its distributable format, such as a JAR | jar:jar, then spring-boot:repackage |
| integration-test | process and deploy the package if necessary into an environment where integration tests can be run | nothing, unless the profile it is active |
| verify | run any checks to verify the package is valid and meets quality criteria | failsafe:verify, with the profile it |
| install | install the package into the local repository, for use as a dependency in other projects locally | not reached |
A phase does nothing by itself. Each phase runs the plugin goals bound to it, and a phase
with no goals does not execute. The packaging decides the first set of bindings. For the
packaging jar, Maven 3.9 binds the resources, compiler, surefire, jar,
install and deploy plugins to their phases. The POM adds more. The run ran mvn package and printed one line for each goal,
and this is the list for the order service, in order:
dependency:3.10.0:properties (default)
resources:3.5.0:resources (default-resources)
compiler:3.15.0:compile (default-compile)
resources:3.5.0:testResources (default-testResources)
compiler:3.15.0:testCompile (default-testCompile)
surefire:3.5.6:test (default-test)
jar:3.5.1:jar (default-jar)
spring-boot:4.1.2:repackage (repackage)Each line names the plugin, its version, the goal, and the execution id in brackets. An id
of the form default-compile means the packaging bound the goal. The first line,
dependency:3.10.0:properties, is a goal that the parent POM bound. The last line is
the Spring Boot plugin's repackage goal, which the starter parent binds with the
execution id repackage. The failsafe plugin does not appear. It runs the integration
tests, and the parent binds it only inside a profile:
<!-- mvn verify runs the fast tests only: *Test.java. The *IT.java tests need Docker. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar}</argLine>
<excludes>
<exclude>**/*IT.java</exclude>
</excludes>
</configuration>
</plugin>maven-surefire-pluginSurefire is the plugin that runs the unit tests, in the phase
test. By default it includes every class whose name starts withTest. It also includes the classes whose names end withTest,TestsorTestCase. The parent excludes the*IT.javaclasses, which are the integration tests.${org.mockito:mockito-core:jar}A property that the
dependency:propertiesgoal sets. The goal runs before the other goals, as the list above shows. Its value is the path of the jar of Mockito, the library the tests use for their mocks. The test JVM, the Java Virtual Machine that runs the tests, loads Mockito as an agent from that path. This is why that goal is the first line of the list above.
In .NET, the sequence is also fixed, and it is MSBuild's. A target in the project file, or in
Directory.Build.targets, adds work to it. In Maven, the matching idea is a plugin
execution bound to a phase. The parent binds one this way:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>properties</goal>
</goals>
</execution>
</executions>
</plugin>The execution names a goal, properties, and no phase. When the phase is left
out, the goal's own default phase is used. The goals of the packaging run first in a phase, and
the goals of the POM run after them, in the order of the POM.
What you can see here#
The --- plugin:goal (id) @ module --- lines of any build, which say what ran and
in which order. mvn help:describe -Dcmd=verify says which goals a phase would run.
And mvn -X, the debug output, prints the configuration of each goal as Maven saw it.
It is long. Pipe it to a file.
What you can hook here#
A plugin execution, with a goal and optionally a phase, in the parent POM for every service
or in one service's POM. Give every execution an id, because a child POM overrides a parent's
execution with the same id and merges one with a different id. And give every plugin a version,
in pluginManagement of the parent, so that two machines run the same plugin code.
The Spring Boot starter parent already does this for the plugins it knows.
Scene 2: the POM that Maven really usestwist#
Before any goal runs, Maven builds one model of the project. It is called the effective POM. It starts from the Super POM, which Maven ships, and layers each parent on top, then the project's own POM, then the active profiles. Properties are replaced with their values. For the order service the chain has five layers:
| Layer | What it adds |
|---|---|
| the Super POM, inside Maven | the repository Maven Central, and the standard directories such as src/main/java and target |
| spring-boot-dependencies 4.1.2, the parent of the starter parent, a bill of materials (BOM) | the version of every library that Spring Boot manages, such as commons-lang3 3.20.0 |
| spring-boot-starter-parent 4.1.2 | the compiler settings, the resource filtering, and the repackage execution |
| shop-platform-parent | the test libraries, the Mockito agent, the surefire excludes, and the profile it |
| order-service/pom.xml | the platform, the cache starter and the Spring Boot plugin |
The effective POM is what help:effective-pom prints. The run wrote it to a
file and searched it for commons. The property and the managed version both came
from the bill of materials (BOM) of Spring Boot, three layers up:
<commons-lang3.version>3.20.0</commons-lang3.version>
...
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.20.0</version>
</dependency>The first line is the property commons-lang3.version, and the
dependency block uses it. The second block is inside dependencyManagement. A managed version is not a
dependency. It is the version to use if that library appears, from any direction. The next
scene shows how far that reaches.
In .NET, the SDK and Directory.Build.props play the same role as the Super POM
and the parent. MSBuild can write its evaluated model to one file, with
dotnet msbuild -preprocess:out.xml. Maven writes the whole model on request too. The chapter on the parent POM reads
one in the section
Compared with Directory.Build.props and NuGet.
What you can see here#
mvn help:effective-pom -Doutput=effective.xml writes the model to a file, and
-Dverbose=true adds a comment to each element that says which file it came from.
mvn help:active-profiles lists the profiles that are active and where each was
defined.
What you can hook here#
A property, which any layer can set and a lower layer can override. The Spring Boot parent
exposes most of its versions as properties. So <commons-lang3.version> in the
service's POM would change the managed version for the whole tree. And a profile, which adds
plugins, dependencies or properties when it is active. The parent's it profile is
the model.
Scene 3: the version that got intrap#
Now the story. A developer adds Apache Commons Text to the order service, to escape a string for a log line. The library is not managed by Spring Boot, so the version is written in the POM. They also add Commons Lang, because a colleague once said that Commons Text needs it, and they take the version that another project uses. Two dependencies, both with versions:
<!-- the conflict: commons-text needs a newer commons-lang3 than the one declared below -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-text</artifactId>
<version>1.12.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.9</version>
</dependency>The first dependency block asks for Commons Text 1.12.0, and the second asks for
Commons Lang 3.9. The run added these two blocks to the service's POM on a branch made for the run, and the comment
already states the ending. Commons Text 1.12.0 was built against Commons Lang 3.14.0. The service
now declares Commons Lang 3.9. Maven must choose one version, because one jar of a library goes
on the classpath. Its rule is called dependency mediation, and the guide states it in one
sentence. Maven picks the nearest definition, the version closest to the project in the tree of
dependencies, and at equal depth the first declaration wins.
The dependency tree shows the choice. The run printed it with -Dverbose=true,
which keeps the versions that lost:
[INFO] +- org.apache.commons:commons-text:jar:1.12.0:compile
[INFO] | \- (org.apache.commons:commons-lang3:jar:3.20.0:compile - version managed from 3.14.0; omitted for conflict with 3.9)
[INFO] +- org.apache.commons:commons-lang3:jar:3.9:compile (scope not updated to compile)The first line is the commons-text node. Read the middle line slowly, because
three rules act in it. Commons Text asked for 3.14.0.
The BOM of Spring Boot manages Commons Lang at 3.20.0, and a managed version beats mediation, so
the request became 3.20.0. Then the service's own declaration of 3.9 beat the managed version. A direct declaration is
nearer than anything, and a direct version beats the managed one. The line is in brackets because the node lost. The last line is the version on the
classpath, 3.9.
Here are the three rules, in the order Maven applies them:
| Rule | What it says | In the run |
|---|---|---|
| mediation | the nearest definition wins, and at equal depth the first declaration | 3.9, declared in the service, is at depth one, and 3.14.0 is at depth two |
| dependency management | a managed version replaces the version a transitive dependency asked for | the BOM turned 3.14.0 into 3.20.0, which is still at depth two |
| direct declaration | the current POM's own version beats its parent's managed version | 3.9 in the service beats 3.20.0 from the BOM |
NuGet solves the same problem with other rules, and a C# developer's instinct comes from them. NuGet takes the lowest applicable version, the lowest that satisfies every reference. A direct reference wins over a transitive one too, and NuGet calls that rule direct-dependency-wins. But when that rule lowers a version that a library asked for, NuGet logs the warning NU1605 and says that a version was downgraded. Maven printed no warning about the version, and the compile step was green. One test failed later, in scene 5.
Nothing in the build knew that Commons Text would ask Commons Lang for a method that 3.9 does not have. The compiler checked the service's own code against 3.9, and the service's own code calls nothing that is missing. Commons Text was compiled long ago, against 3.14.0, and Maven does not compile a dependency again. The mismatch is inside a jar that nobody compiled today.
What you can see here#
mvn dependency:tree -Dverbose=true, and the words in brackets. The phrase
"omitted for conflict with" names a loser, and the version after it is the winner. The phrase
"version managed from" names a request that the BOM changed. A second goal,
mvn dependency:analyze, reports the libraries the code uses without declaring them
and the ones it declares without using them. On the service it listed 45 used but undeclared
libraries, such as spring-web and spring-data-mongodb, because the
platform brings them in. That is the design, not a fault, so the goal is for reading and not for
failing the build.
What you can hook here#
Three hooks, in order of preference. A version in dependencyManagement of the
parent, which is how Spring Boot's own BOM works. The service then declares no version at all,
and every service of the shop gets the same one. An <exclusions> block on a
dependency, which removes one transitive dependency from under it, when a library drags in a jar
you have replaced. And the enforcer plugin, which turns the silent choice into a failed build.
Its rule requireUpperBoundDeps fails when the chosen version is lower than another
version in the tree, which is exactly this scene. Its rule dependencyConvergence
fails when two paths reach different versions at all. The parent POM of the shop has no enforcer
rule yet. Add one, and the branch of this chapter fails in the phase validate, before
a class is compiled.
Scene 4: where the jars come fromtwist#
When Commons Lang 3.9 is not on the machine yet, Maven downloads it once and keeps it. Every download is saved in the local repository,
~/.m2/repository, in a folder per group, artifact and version. The run listed the laptop's folder
for Commons Lang, and it held twelve versions:
~/.m2/repository/org/apache/commons/commons-lang3/
3.7 3.8.1 3.9 3.10 3.11 3.12.0 3.14.0 3.16.0 3.17.0 3.18.0 3.20.0 3.21.0The path is the group org.apache.commons and the artifact
commons-lang3, then one folder for each version. The local repository is a cache of
downloads, and it also holds what mvn install
built on this machine. Maven downloads an artifact when the local copy is missing. For a
release version, that happens once. A release version never changes, so a second download is
never needed. For a SNAPSHOT version, Maven also checks the remote repository for a
newer copy, by default once a day. The shop's own modules are 1.0.0-SNAPSHOT, which
means the moving development version.
Where does a download come from? The Super POM declares one repository, Maven Central, at
https://repo.maven.apache.org/maven2. A POM can add repositories, and so can
settings.xml. Maven queries them in a fixed order. The settings files come first,
then the POM and its parents, then the POMs of the dependencies on the path to the artifact. A
team with a repository manager sends every request to it with one mirror in
~/.m2/settings.xml:
<settings>
<mirrors>
<mirror>
<id>shop-repository-manager</id>
<mirrorOf>*</mirrorOf>
<url>https://repo.shop.example/maven2</url>
</mirror>
</mirrors>
</settings>The mirrorOf value * matches every repository id, so the manager
sees every request and forwards each one to Maven Central. The Maven documentation calls a
repository manager an essential best practice for any significant use of Maven. The
<servers> block of the same file holds the credentials, keyed by the mirror's
id.
Beside each cached file, Maven's resolver keeps a small file named
_remote.repositories. It says which repository the file came from. If a later
build does not know that repository, the resolver treats the cached file as absent and downloads
it again. This can cause a download that seems not to be needed.
NuGet has the same shape with other names. The global-packages folder,
~/.nuget/packages, is the cache, with one folder per library and version.
NuGet.Config holds the sources, and the files are layered from the machine to the
solution folder. A difference is in the project file. A NuGet source list is in the config
files only. A Maven repository can be declared in a POM, and then every project that depends on
that POM queries that repository too.
What you can see here#
The folder ~/.m2/repository, and the versions in it. mvn -o builds
offline and fails if something is missing, which is the quickest test of whether the cache is
complete. mvn help:effective-settings prints the merged settings, with the mirrors and
the servers.
What you can hook here#
A mirror, as above. A repository policy on a repository, which sets the update policy for
snapshots to always, daily, never or an interval in
minutes. And the flags -U, which forces a check for updated snapshots, and
-nsu, which forbids one.
Scene 5: the method that is not therenew#
The branch compiles. Then the tests run, and one test fails. It is not a test of the log
line, and not a test of anything that names Commons Text. It is the contract test of the
OpenAPI document, which starts the web layer and fetches /v3/api-docs:
[ERROR] com.acme.orderservice.contracts.OpenApiContractTest.theCommittedContractMatchesTheCode -- Time elapsed: 0.584 s <<< FAILURE!
[ERROR] Tests run: 112, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILUREThe console names the failed test, theCommittedContractMatchesTheCode. The surefire report of the test has the line that matters,
java.lang.AssertionError: Status expected:<200> but was:<500>. The
test asked for the document and got a 500. Nothing in the test names the two new libraries.
The run built the jar without the tests, started the service, and requested the document
with curl. The service's error handler logged the cause:
ERROR [nio-8080-exec-2] c.a.p.core.error.GlobalExceptionHandler : Unexpected error
jakarta.servlet.ServletException: Handler dispatch failed: java.lang.NoClassDefFoundError: org/apache/commons/lang3/Strings
Caused by: java.lang.NoClassDefFoundError: org/apache/commons/lang3/Strings
at org.springdoc.api.AbstractOpenApiResource.calculatePath(AbstractOpenApiResource.java:697) ~[springdoc-openapi-starter-common-3.2.0.jar:3.2.0]The frame below the Caused by line is inside springdoc, the library that writes
the OpenAPI document. Springdoc uses a class called Strings from Commons Lang. The jar of Commons
Lang 3.9 has no such class. Springdoc asked for 3.20.0, as the tree shows. The Javadoc of
NoClassDefFoundError says that the JVM tried to load the definition of a class and
no definition could be found. The developer added a dependency for a log line, and broke a
library they never touched. Then the run placed one order. The order was accepted:
POST /orders -> 201The 201 says that the order was created. The request succeeded because the new line of code is not on the request's path. It is in
OrderMetrics, the listener that counts placed orders, and that listener runs after
the transaction commits, on another thread. Less than a second later the log held this:
ERROR [task-1] .a.i.SimpleAsyncUncaughtExceptionHandler : Unexpected exception occurred invoking async method: void com.acme.orderservice.order.application.OrderMetrics.on(com.acme.orderservice.order.domain.event.OrderPlaced)
java.lang.NoSuchMethodError: 'org.apache.commons.lang3.Range org.apache.commons.lang3.Range.of(java.lang.Comparable, java.lang.Comparable)'
at org.apache.commons.text.translate.NumericEntityEscaper.<init>(NumericEntityEscaper.java:97) ~[commons-text-1.12.0.jar:1.12.0]
at org.apache.commons.text.translate.NumericEntityEscaper.between(NumericEntityEscaper.java:59) ~[commons-text-1.12.0.jar:1.12.0]
at org.apache.commons.text.StringEscapeUtils.<clinit>(StringEscapeUtils.java:270) ~[commons-text-1.12.0.jar:1.12.0]
at com.acme.orderservice.order.application.OrderMetrics.on(OrderMetrics.java:45) ~[classes/:na]Read the stack from the bottom. The listener called StringEscapeUtils of Commons
Text for the first time. The JVM initialised that class, which is the <clinit>
frame, and the class built its escapers. One escaper called Range.of in Commons
Lang. The JVM looked for that method in the Commons Lang on the classpath, version 3.9. It found
no method with that name and those parameters. So it threw
NoSuchMethodError. The Javadoc of that error says it plainly. The error is normally
caught by the compiler, and it occurs at run time only if the definition of a class has
incompatibly changed. Here the definition changed backwards, from 3.14.0 to 3.9.
javap proves it. The run disassembled the Range class from both
jars, with -p so that every member shows, and kept the static factory methods:
== commons-lang3 3.9
public static <T extends java.lang.Comparable<T>> org.apache.commons.lang3.Range<T> between(T, T);
public static <T> org.apache.commons.lang3.Range<T> between(T, T, java.util.Comparator<T>);
== commons-lang3 3.20.0
public static <T extends java.lang.Comparable<? super T>> org.apache.commons.lang3.Range<T> between(T, T);
public static <T> org.apache.commons.lang3.Range<T> between(T, T, java.util.Comparator<T>);
public static <T extends java.lang.Comparable<? super T>> org.apache.commons.lang3.Range<T> of(T, T);
public static <T> org.apache.commons.lang3.Range<T> of(T, T, java.util.Comparator<T>);Version 3.9 has between. Version 3.20.0 has between and
of. Commons Text 1.12.0 calls of, so it needs a Commons Lang from the
release that added it. Commons Text 1.12.0 asks for 3.14.0, so 3.14.0 or a newer version should
have the method.
Two things make this failure worse than a crash. The order was placed, so nobody saw an
error. And the listener that failed was a Spring Modulith listener, part of the
outbox that the chapter
Publishing events: the outbox explains. Spring Modulith writes down each
delivery, and a delivery that fails stays incomplete in the registry. It is retried only if something
resubmits it. The run
read the registry in MongoDB afterwards, and the publication was still there, with
completionAttempts: 1. The counter is never incremented, the registry grows, and
the log of a background thread is the only place where the cause is written.
The same conflict, with one class and a main method of about twenty lines, is in the lab
project of this chapter's run. Its POM declares the same two dependencies, and its one class
calls five methods of Commons Text in turn:
substitute: ok -> the order service
wrap: ok -> a sentence that is\nlong enough to be\nwrapped
json: java.lang.NoSuchMethodError: 'org.apache.commons.lang3.Range org.apache.commons.lang3.Range.of(java.lang.Comparable, java.lang.Comparable)'
camel: ok -> orderPlaced
levenshtein: ok -> 2The line that starts with json is the call to StringEscapeUtils.
Four of the five calls work. Only the one that touches StringEscapeUtils fails.
A test suite that happens to use the other four is green. That is why the rule is not "run the
tests". The rule is "read the tree".
What you can see here#
A jar, or JAR, is one file that holds many classes. The
first NoSuchMethodError or NoClassDefFoundError in a log is the place
to start. Spring Boot adds the jar names in square brackets at the end of each stack frame. The
two jars that disagree are usually both in the first ten frames. Then
javap -p on the class, from each jar, and jar tf to list what a jar
holds. The chapter Two JARs, one class covers the case where two jars
hold the same class.
What you can hook here#
Nothing at run time. The fix is in the build, in scene 3. Remove the direct declaration of Commons Lang, so that the managed version 3.20.0 wins, and let the enforcer refuse the next one.
Scene 6: the jartwist#
The last goal of the build was spring-boot:repackage. It takes the jar that
jar:jar made, which holds only the service's classes, and builds the jar that
java -jar runs. The original is kept beside it with the suffix
.original. The run listed the Commons jars inside the result:
BOOT-INF/lib/commons-text-1.12.0.jar
BOOT-INF/lib/commons-lang3-3.9.jarEvery dependency jar is inside the jar, under BOOT-INF/lib. The entries are
stored and not compressed, so that the launcher can read a class without unpacking the file. The service's own
classes are under BOOT-INF/classes. The manifest names Spring Boot's
JarLauncher as the main class, and the service's application class as the start
class. Two index files are beside them. classpath.idx gives the order of the jars
on the classpath. layers.idx groups the content into layers, so that a container
image can keep the dependency layer from one build to the next. The chapter
The Dockerfile: a layered jar uses the layers. The chapter
Running with -cp or -jar explains the plain classpath that the launcher
replaces.
In .NET, dotnet publish writes the assembly and every dependency as separate
files in one folder. The Java jar holds the same set in one file, and the version that scene 3
chose is the file in it. There is no second copy of Commons Lang that could serve instead.
What you can see here#
jar tf order-service-1.0.0-SNAPSHOT.jar lists every entry, and
java -Djarmode=tools -jar order-service-1.0.0-SNAPSHOT.jar list-layers lists the
layers. The jar on the classpath at run time is the one in BOOT-INF/lib. When the tree
and the jar disagree, the jar is what ran.
What you can hook here#
The layers configuration of the plugin, which can split the application's own
modules into a layer of their own. And the mainClass parameter, when a jar holds
two classes with a main method and the plugin's guess is wrong.
Scene 7: the same bytes twicetwist#
A build is reproducible when the same source, the same build environment and the same instructions give the same bytes, every time, for everyone. Maven 3.9 can do this, but it must be asked. One property in the parent POM switches the plugins into reproducible mode:
<properties>
<project.build.outputTimestamp>2026-10-26T00:00:00Z</project.build.outputTimestamp>
</properties>The property is project.build.outputTimestamp, and its value is a date and time
in UTC. With the property set, the jar plugin and the Spring Boot plugin write that timestamp into
every entry instead of the clock. The Spring Boot plugin also sorts the classpath index. Maven
3.10.0 turns this mode on by default, with no property. The guide adds a second condition that
belongs to this chapter. A reproducible build uses no version ranges, because a range resolves
to whatever is newest on the day.
The test is two builds. mvn clean install, then
mvn clean verify artifact:compare, which compares the second build with the first
and writes a .buildcompare file. The guide warns that this local test does not yet prove that a third party gets the same
bytes. The environment can leak into the build.
It proves that the build does not depend on the clock.
NuGet's answer to the version half of this question is packages.lock.json. With
the lock file committed and restore --locked-mode, a restore either gets the exact
versions in the file or fails. Maven has no lock file. Its exact versions, its managed versions
and its ban on ranges do the same work.
The strict rule. A build that ships to production is reproducible, so that a jar can be rebuilt from its commit and compared.
What we do. The shop's parent POM does not set
project.build.outputTimestamp, and the enforcer plugin is not in it.
Why. The parent was written for the chapters on the service, and each chapter added only what its story needed. This chapter is the first that needs them.
When to stop. Before the first release that a team must be able to rebuild from a tag. Add the property and the two enforcer rules of scene 3 in one change. Keep the branch of this chapter as the test that the rules work.
What you can see here#
The .buildcompare file after artifact:compare, which lists each
artifact as ok or different. And mvn artifact:check-buildplan, which names any plugin
in the build that is too old to be reproducible.
What you can hook here#
The property above, in the parent, bumped by the release process. The enforcer rules of
scene 3, for the versions. And pluginManagement with a version for every plugin, so
that the build plan itself does not drift.
1A library you add asks for version 3.14.0 of another library. Your POM declares version
3.9 of that library directly. Which version goes on the classpath, and does Maven warn?
dependencyManagement sets. Maven prints no warning. The
dependency:tree -Dverbose=true output shows the loser in brackets with "omitted for
conflict with 3.9".2An expert asksWhy can a build compile and pass its tests, and still throw
NoSuchMethodError at run time?
NoSuchMethodError at run time?3TranslateTranslate this central version and its reference. The version is set once
for the repository, and the project names the library without a version.
<!-- Directory.Packages.props -->
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<!-- the project file -->
<PackageReference Include="Newtonsoft.Json" />
<!-- Directory.Packages.props -->
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<!-- the project file -->
<PackageReference Include="Newtonsoft.Json" /><!-- the parent POM -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.21</version>
</dependency>
</dependencies>
</dependencyManagement>
<!-- the service's POM -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>Directory.Packages.props does only with transitive-pinning switched on.4Which goal turns the plain jar of the service into the jar that java -jar
runs, and where do the dependency jars go?
java -jar
runs, and where do the dependency jars go?repackage goal, which the starter parent binds with
the execution id repackage. It runs after jar:jar in the phase
package. The dependency jars go inside the jar, under BOOT-INF/lib,
stored without compression, and BOOT-INF/classpath.idx gives their order.The domain model in plain Java#
The order feature keeps its rules in plain Java classes that import nothing from Spring. A few
base classes from the platform library give each aggregate its identity, its version and its
list of events. This chapter reads the Order aggregate line by line. It covers value
objects, typed IDs, placing and loading, the injected clock, exceptions by kind, and the Java
features that the code uses.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | no equivalent | the package com.acme.platform.domain, with no Spring import | the base classes hold no business concept, and an architecture rule keeps every domain package plain Java Why |
| twist | an abstract Entity<TId> class that you write yourself | Entity<ID extends Identifier> | equals and hashCode are final, so no subclass can change what identity means Why |
| new | no equivalent | AggregateRoot, with raise and pullEvents | the root collects its events, and pullEvents returns a copy and clears the list Why |
| twist | readonly record struct OrderId(Guid Value) | final class OrderId extends Identifier | a record cannot extend a class, so a typed ID is a small final class with two factories Why |
| trap | Guid.TryParse refuses "1-2-3-4-5" | UUID.fromString accepts "1-2-3-4-5" | so Identifier checks the text against a pattern, and its error names the field Why |
| twist | Guid.TryParse accepts upper and lower case | Identifier stores the UUID in lower case | an upper-case ID from a client is accepted, and two spellings of one UUID are equal Why |
| twist | a record whose constructor throws | a record whose compact constructor calls Guard | each check names the field, and the platform answers the error with 400 Why |
| twist | a record's ToString prints every property | toString() returns CustomerContact[masked] | both default forms print personal data, so a value object that holds it overrides the method Why |
| twist | Math.Round(12.345m, 2) gives 12.34 | Money.of("12.345", "GBP") throws | rounding would change an amount from another service without a word, so the value object refuses it Why |
| twist | 10m == 10.00m is true | amount.setScale(2) in the compact constructor | BigDecimal equality compares the scale too, so Money stores two places and 10 equals 10.00 Why |
| new | no equivalent | Guard.notBlank(value, "field") | one-line checks that return the value, or throw an error with a fixed message that names the field Why |
| twist | [Required] on the request class only | Bean Validation on the request, and Guard in the value object | the request lists every bad field at once, and the domain's check is the one that counts Why |
| new | public setters on the entity | private fields that only the rule methods change | the state changes only through methods that apply the rules, and an architecture rule checks it Why |
| new | a public constructor | Order.place and Order.rehydrate | placing applies the rules and raises an event, but loading applies no rule and raises nothing Why |
| twist | a TimeProvider passed to the method | a Clock passed to each method that reads the time | tests pass Clock.fixed, and no domain code calls Instant.now() Why |
| new | no equivalent | ship, cancel and pickLine, each with requireNotFinished | each method checks its rule, changes the state and raises its event in one place Why |
| new | no equivalent | boolean recordPayment(Payment payment) | a repeated or late payment changes nothing, so the caller saves only when the method returns true Why |
| twist | public LineStatus Status => ... | LineStatus status(), computed from pickedAt | a derived status cannot disagree with the time it comes from Why |
| trap | public IReadOnlyList<OrderLine> Lines => lines | return List.copyOf(lines) | Java has no read-only list interface, so returning the field would let callers change it Why |
| new | no equivalent | record OrderShipped(...) implements DomainEvent | each event is a record with a time, raised in the method that changed the state Why |
| twist | an IOrderRepository that you write yourself | Repository<A extends AggregateRoot<ID>, ID extends Identifier> | two type parameters with bounds tie each repository to one aggregate and its ID type Why |
| twist | a default interface member, since C# 8 | default Order get(OrderId id) | the port loads the order or throws the 404 exception in one line, for every adapter Why |
| twist | Task<Order?> FindByIdAsync(OrderId id) | Optional<Order> findById(OrderId id) | the return type says that the order may be missing Why |
| new | no equivalent | Order saved = orders.save(order), with a final version | the domain never changes its own version, so the use case and its tests continue with the returned instance Why |
| twist | Skip and Take, or a page class that you write | the records PageQuery and PageResult<T> | paging without Spring types, counted from zero, with at most 100 items on a page Why |
| new | an abstract exception with a private protected constructor | sealed DomainException, which permits three non-sealed kinds | the kind decides the HTTP status, and no feature can add a fourth kind Why |
| new | no equivalent | one exception class for each broken rule, with a code such as ORDER_NOT_PAID | clients may rely on the code, so it never changes, and the message holds no personal data Why |
| twist | no equivalent | Note, an entity in the light style | the note keeps its rules with String IDs and no base class, and the chapter on levels compares the styles in full Why |
| new | no equivalent | plain JUnit tests with a fixed clock | the tests of the model need no Spring and no mocks, so each one runs in milliseconds Why |
The domain package: plain Java, and the base classesnew#
A rule such as "an order ships only when it is paid" should be easy to test. It should also stay the same when the service changes its database or its framework version. So the order feature keeps its rules in classes that depend on no framework at all. This is the full style of Domain-Driven Design (DDD) in the order service. This chapter starts from the code. For why the order is an aggregate, see the chapter Finding the aggregates. For why only the order feature uses this full style, see the chapter How much DDD: three levels in one service.
The domain of the order feature is one package with five sub-packages:
order/domain/
model/ Order, OrderLine, the typed IDs, the value objects, the two status enums
event/ OrderPlaced, LinePicked, OrderShipped, OrderCancelled, PaymentRecorded
exception/ one exception class for each broken rule
repository/ OrderRepository, the port for storing orders
service/ ShippingPolicy, the one rule that needs two aggregatesThe model package holds the aggregate and its values. Every class in these packages uses only the Java standard library and the platform's own
domain package. The platform library describes that package in its
package-info.java file. A file of this name holds the documentation of a whole
package:
/**
* The building blocks of a domain model, in plain Java.
*
* <p>Nothing in this package imports Spring, MongoDB or Jackson. A service's {@code domain}
* packages may use these classes and still stay plain Java. The classes hold no business
* concepts: they remove code that every aggregate would otherwise repeat, such as equality by
* identity, the version for optimistic locking, the list of raised events and simple checks.
*/
package com.acme.platform.domain;The comment of com.acme.platform.domain names the contract. The base classes remove code that every aggregate would
otherwise repeat. They hold no business concept, such as an order or a payment. An architecture
rule of the platform makes the build fail when a domain class imports a framework:
/** A {@code domain} package uses no framework, no adapter and no other layer. */
public static ArchRule domainIsPlainJava() {
return noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage(
"org.springframework..", "com.mongodb..", "org.bson..", "jakarta..",
"tools.jackson..", "com.fasterxml..", "io.awspring..",
"..application..", "..api..", "..persistence..", "..client..", "..messaging..",
"com.acme.platform.core..", "com.acme.platform.mongo..")
.as("A domain package is plain Java");
}noClasses().that().resideInAPackage("..domain..")This is ArchUnit, a test library that reads compiled classes. The pattern
..domain..matches every package withdomainin its name, in every feature."org.springframework..", "com.mongodb..", "org.bson..", "jakarta..",A domain class may not use Spring, the MongoDB driver or any Jakarta API. So a domain class cannot carry
@Document,@Componentor a validation annotation. No domain class is a Spring bean."..application..", "..api..", "..persistence..", "..client..", "..messaging..",A domain class may not use the layers around it either. The application layer uses the domain, and never the other way round.
"com.acme.platform.core..", "com.acme.platform.mongo..")The platform's other packages hold Spring code, so the domain may not use them. Only
com.acme.platform.domainis allowed.
The test ArchitectureTest.domainIsPlainJava calls this rule. For the other rules
and how a service runs them, see the chapter Testing in depth.
The platform's domain package holds these building blocks. The sections below show each one where the order uses it:
| Building block | Java type | What it gives each aggregate |
|---|---|---|
| AggregateRoot<ID> | abstract class | the version for optimistic locking, and the list of raised events |
| Entity<ID> | abstract class | equality by class and ID |
| Identifier | abstract class | the UUID check, equality and toString of a typed ID |
| ValueObject | interface with no methods | no code, because it only marks the intent for readers and for architecture tests |
| DomainEvent | interface | a time on every event |
| DomainEventPublisher | interface, a port | the way events leave the use case, which the outbox implements |
| Repository<A, ID> | interface, a port | findById, save and exists, typed for one aggregate |
| Guard | final class with static methods | checks for null, blank text, length, format, range and size |
| PageQuery and PageResult<T> | records | paging without the Page and Pageable types of Spring Data |
| DomainException and its three kinds | a sealed exception class | the choice of the HTTP status |
An interface that the domain declares and an outer layer implements is called a port. The class that implements a port is called an adapter. A design built from ports and adapters is called hexagonal architecture. For ports and adapters, see the chapter How much DDD: three levels in one service.
The strict rule. The domain depends on nothing outside itself. Each service writes its own base classes, or none.
What we do. Every full-style domain extends the classes of
com.acme.platform.domain. That package is plain Java, and an architecture rule
keeps it plain.
Why. Equality by ID, the version, the event list and the value checks are the same in every service. Written once, they are tested once, and every service behaves the same way.
When to stop. Stop when the shared package gains a business concept, such as
a Money class, or a framework type. Stop also when a change to it forces every
service to upgrade at once. Then move the concept into the service that owns it.
Entity: identity, written oncetwist#
public abstract class Entity<TId> where TId : Identifier
{
protected Entity(TId id)
{
ArgumentNullException.ThrowIfNull(id);
Id = id;
}
public TId Id { get; }
}public abstract class Entity<ID extends Identifier> {
private final ID id;
protected Entity(ID id) {
this.id = Objects.requireNonNull(id, "id");
}
public ID id() {
return id;
}public abstract class Entity<ID extends Identifier> {The type parameter
IDmust be anIdentifieror a subclass of it. The C# form writes this aswhere TId : Identifier. Java writes the bound inside the angle brackets, withextends. For bounds, see the section Declaration.protected Entity(ID id) {Only a subclass can call a
protectedconstructor. Code in the same package can call it too, becauseprotectedin Java includes the package. For the access levels, see the section The mapping.this.id = Objects.requireNonNull(id, "id");Objects.requireNonNullis injava.util.Objects. It returns the value, or throwsNullPointerExceptionwith the messageid. So an entity always has an ID.public ID id() {The accessor has no
getprefix, as in a record. The whole domain uses this style. For the naming rules, see the section The names are the contract.
The class also defines equals and hashCode by class and ID. Each one is a
final method, so no subclass can override it. In C#, a method can be
overridden only when it is virtual. In Java, every method can be overridden unless
it is final. For this rule, see the section
Overriding needs no keyword. For the code of equals, see
the section Inside the aggregate: entities and value objects.
AggregateRoot: the version and the eventsnew#
Every aggregate needs two things that have nothing to do with its rules. Storage needs the version for optimistic locking. The use case needs the domain events that a change raised. If each aggregate kept these itself, each one could get them slightly wrong. So the platform keeps them in one base class:
/**
* The base class of an aggregate root: the only object of an aggregate that outside code may
* hold. Every change to the aggregate goes through the root, which checks the rules.
*
* <p>The root carries two things for the layers around it. The first is the version, which
* optimistic locking needs: the version comes from storage and goes back to storage unchanged,
* and no rule ever reads it. The second is the list of domain events the root raised. The
* application service pulls the events after saving and publishes them in the same transaction.
*
* @param <ID> the typed identifier of the aggregate
*/
public abstract class AggregateRoot<ID extends Identifier> extends Entity<ID> {
private final Long version;
private final List<DomainEvent> events = new ArrayList<>();
/**
* Creates the root.
*
* @param id the identity
* @param version the stored version, or {@code null} for an aggregate that was never saved
*/
protected AggregateRoot(ID id, Long version) {
super(id);
this.version = version;
}
/** Records an event. A subclass calls this in the method that changed its state. */
protected final void raise(DomainEvent event) {
events.add(event);
}
/** Returns the raised events and clears the list, so a second call returns an empty list. */
public final List<DomainEvent> pullEvents() {
List<DomainEvent> raised = List.copyOf(events);
events.clear();
return raised;
}
/**
* Returns the stored version. Only persistence uses it.
*
* <p>The version is final. Saving creates a new instance with the new version (see
* {@link Repository#save}), so the instance you saved keeps the old one.
*/
public final Long version() {
return version;
}
}public abstract class AggregateRoot<ID extends Identifier> extends Entity<ID> {A root is an entity, so it inherits equality by ID. The bound on
IDis repeated, becauseEntityaccepts only anIdentifier.private final Long version;The type is
Long, the object form oflong, so it can benull. A null version means that the aggregate was never saved. The field isfinal, so the aggregate never changes its own version.private final List<DomainEvent> events = new ArrayList<>();The empty angle brackets are called the diamond. The compiler takes the type argument from the declared type,
List<DomainEvent>.protected final void raise(DomainEvent event) {protectedlets only subclasses record an event.finalstops a subclass from replacing the method. So a subclass can add an event, but it cannot hide one.List<DomainEvent> raised = List.copyOf(events);List.copyOfreturns a copy that nobody can change. The method then clears its own list. So each event leaves the aggregate once.public final Long version() {Only the persistence adapter reads the version. No rule reads it.
The test of the base class shows both parts:
@Test
void pullEventsReturnsTheRaisedEventsAndClearsThem() {
Thing thing = new Thing();
thing.doSomething();
assertThat(thing.pullEvents()).containsExactly(new ThingHappened(Instant.EPOCH));
assertThat(thing.pullEvents()).isEmpty();
}
@Test
void aNewAggregateHasNoVersion() {
assertThat(new Thing().version()).isNull();
}The first test raises one event. The first call of pullEvents returns it. The
second call returns an empty list. The second test shows that a new aggregate has no version.
Optimistic locking needs the version that was loaded. The adapter sends that version back with the save, and storage refuses the save if the stored version has moved on. So the version goes from storage, through the aggregate, back to storage, and nothing in between reads it. For how storage checks it, see the chapter Storing aggregates in DocumentDB. For what the use case does with the events, see the section Domain events: collected by the aggregate.
Typed IDs: Identifier and its subclassesnew#
An order ID and a line ID are both universally unique identifiers (UUIDs). A method that takes two strings accepts them in the wrong order, and nothing warns you. A typed ID makes that mistake a compiler error. For the two compiler errors, in C# and in Java, see the section Typed IDs inside the aggregate. This section shows the base class that every typed ID extends:
public abstract class Identifier implements ValueObject {
private static final Pattern UUID_FORMAT =
Pattern.compile("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");
private final String value;
/**
* Checks and stores the value.
*
* @param value the UUID as text
* @param fieldName the name a client sees in the error, for example {@code orderId}
*/
protected Identifier(String value, String fieldName) {
String normalised = value == null ? null : value.toLowerCase(Locale.ROOT);
if (normalised == null || !UUID_FORMAT.matcher(normalised).matches()) {
throw new InvalidValueException(fieldName, "must be a valid identifier");
}
this.value = normalised;
}
/** Returns a new random value, for the {@code newId()} factory of a subclass. */
protected static String newValue() {
return UUID.randomUUID().toString();
}
/** Returns the UUID as lower-case text. */
public String value() {
return value;
}
@Override
public final boolean equals(Object other) {
if (this == other) {
return true;
}
if (other == null || getClass() != other.getClass()) {
return false;
}
return value.equals(((Identifier) other).value);
}
@Override
public final int hashCode() {
return Objects.hash(getClass(), value);
}
@Override
public String toString() {
return value;
}
}Pattern.compile("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");Patternis injava.util.regex. The pattern is compiled once, when the class loads, because the field isstatic final. It accepts only the standard form of a UUID, in lower case.String normalised = value == null ? null : value.toLowerCase(Locale.ROOT);The text is turned into lower case first. So an upper-case UUID from a client is accepted, and it is stored in lower case.
Locale.ROOTapplies no language rules. With a Turkish locale, a run for this book turned"I"into the dotless"ı".throw new InvalidValueException(fieldName, "must be a valid identifier");The error names the field, such as
orderId. The platform answers it with 400. So the client learns which value to correct.protected static String newValue() {A subclass uses this method in its factory for a new ID.
UUIDis injava.util, andrandomUUIDcreates a random UUID.if (other == null || getClass() != other.getClass()) {Two identifiers are equal only when they have the same class. So an
OrderIdis never equal to aLineId, even with the same UUID. A run for this book confirmed it.return Objects.hash(getClass(), value);Objects.hashcombines several values into one hash code. The class is part of the hash, as it is part ofequals.public String toString() {The text form is the UUID itself. So a message such as
"Order " + idprints the ID, with no class name around it.
UUID.fromString accepts text that is not a UUID in the standard form. A C#
developer expects it to check the text, as Guid.TryParse does. In a run for this
book, UUID.fromString("1-2-3-4-5") returned
00000001-0002-0003-0004-000000000005. On .NET 10, Guid.TryParse
returned false for the same text. So Identifier checks the text against a pattern,
and it never calls fromString.
The test IdentifierTest records the rule about case:
@Test
void acceptsUpperCaseAndStoresLowerCase() {
String value = ParcelId.newId().value();
ParcelId upper = new ParcelId(value.toUpperCase());
assertThat(upper.value()).isEqualTo(value);
assertThat(upper).isEqualTo(new ParcelId(value)).hasToString(value);
}The test class ParcelId is a typed ID made only for the test. The test makes a new ID, and builds a second ID from its upper-case text. The second ID holds
the lower-case text, and it is equal to the first one. In C#, Guid.TryParse accepts
both cases too. A run for this book printed a Guid parsed from upper-case text in
lower case.
Each typed ID of the order feature is a small class. Here is the ID of a line:
/** The identity of a line within an order. */
public final class LineId extends Identifier {
private LineId(String value) {
super(value, "lineId");
}
public static LineId of(String value) {
return new LineId(value);
}
public static LineId newId() {
return new LineId(newValue());
}
}public final class LineId extends Identifier {finalmeans that no class can extendLineId. So nobody can write a subclass that changes how the ID is checked.private LineId(String value) {The constructor is private. Code outside the class uses the two factories.
super(value, "lineId");The second argument is the field name that a client sees in a 400 error.
public static LineId of(String value) {ofwraps text that already exists, such as a path variable or a stored value. A static method that creates an object is called a static factory. Its name says what it does. A constructor cannot have such a name.public static LineId newId() {newIdcreates a new random ID. Only the code that creates a line or an order calls it.
A typed ID is a class and not a record, because a record cannot extend a class. The compiler
refuses record RecId(String value) extends Identifier with error: '{'
expected. For what a record can and cannot do, see the section
Where records fit.
Typed IDs stay inside the feature. Between features, an ID travels as plain text. The note
feature stores the order ID as a String, and the order feature checks it with
OrderId.of. So the features share no type, and the owner of the ID checks it. For
this choice and its callout, see the section A type for a foreign
ID, or plain text.
The ID holds the UUID as text, not as a java.util.UUID. The JSON body, the path
and the stored document all carry the ID as text. So text needs no conversion at any of those
borders, and the pattern still checks the format.
Value objects as recordstwist#
public record CustomerContact
{
public CustomerContact(string name, string email)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("must not be blank", nameof(name));
(Name, Email) = (name, email);
}
public string Name { get; }
public string Email { get; }
public override string ToString() => "CustomerContact[masked]";
}public record CustomerContact(String name, String email) implements ValueObject {
public CustomerContact {
Guard.notBlank(name, "customer.name");
Guard.maxLength(name, 200, "customer.name");
Guard.maxLength(email, 254, "customer.email");
Guard.matches(email, "[^@\\s]+@[^@\\s]+\\.[^@\\s]+", "customer.email");
}
@Override
public String toString() {
return "CustomerContact[masked]";
}
}public record CustomerContact(String name, String email) implements ValueObject {The record gets a constructor and the accessors
name()andemail(). It also getsequalsandhashCode, which compare the values.implements ValueObjectadds no code. It marks the class for readers and for architecture tests.public CustomerContact {A constructor with no parameter list is called a compact constructor. It runs before the fields are set, and it needs no assignments. For the rules, see the section Checking values: the compact constructor.
Guard.notBlank(name, "customer.name");The field name is
customer.name, the path of the value in the JSON body. So the 400 error points at the right place in the request.Guard.matches(email, "[^@\\s]+@[^@\\s]+\\.[^@\\s]+", "customer.email");This is a simple format check. It expects some text, then
@, then text with a dot. The section Guard: checks that name the field shows the method.return "CustomerContact[masked]";The text form of the contact hides the name and the e-mail address.
Both panes check the value when it is created, and both hide it in the text form. The Java
record needs no assignments and no properties. The overridden toString matters
because a log line or an exception message often prints an object. In runs for this book, the
default text forms printed every value:
| Form | What it printed |
|---|---|
| the C# record without the override | CustomerContact { Name = Jo Bloggs, Email = jo@example.com } |
| a Java record without the override, named Plain in the run | Plain[name=Jo Bloggs, email=jo@example.com] |
| CustomerContact in the order service | CustomerContact[masked] |
So a value object that holds personal data overrides toString. The test
ValueObjectTest.customerContactHidesItsPersonalDataInToString checks the masked
text.
The other value objects of the order are smaller. A delivery method is a lookup code:
/**
* How the order reaches the customer, as a lookup code such as {@code STANDARD}.
*
* <p>Only the format is checked here. Whether the code is an active lookup value is checked by
* the reference feature, which owns the list.
*/
public record DeliveryMethod(String code) implements ValueObject {
public DeliveryMethod {
Guard.matches(code, "[A-Z_]{1,50}", "deliveryMethod");
}
}The record checks only the format, such as STANDARD. It cannot know whether the
code is an active lookup value, because that list is stored data of the reference feature. The
use case asks the reference feature. For why that check is in the application service, see the
section The lookup rule in an application service.
The stock service's answer is a value object too. It holds a list, so its compact constructor copies the list:
public record StockAvailability(boolean available, List<ProductCode> unavailableProducts) implements ValueObject {
public StockAvailability {
unavailableProducts = List.copyOf(unavailableProducts);
}
public static StockAvailability allAvailable() {
return new StockAvailability(true, List.of());
}
public static StockAvailability unavailable(List<ProductCode> products) {
return new StockAvailability(false, products);
}
}unavailableProducts = List.copyOf(unavailableProducts);A compact constructor may assign a parameter. The record then stores the new value. Here the record stores a copy that nobody can change. For why a record that holds a list needs this, see the section A record that holds a list.
public static StockAvailability allAvailable() {Two static factories name the two answers. A caller reads
StockAvailability.allAvailable()more easily thannew StockAvailability(true, List.of()).
The other value objects of the order feature follow the same pattern:
| Value object | What it checks | Why |
|---|---|---|
| ProductCode | the format [A-Z0-9-]{1,30} | whether the product is in stock is the stock service's answer |
| OrderItem | a product, and a quantity from 1 to 999 | an item becomes an order line when the order is placed |
| PaymentReference | not blank, at most 50 characters | the limit matches the schema of the event that brings it |
| Payment | a reference, an amount and a time, none of them null | a value object made of value objects, compared as a whole |
| OrderStatus | an enum with NEW, PAID, SHIPPED and CANCELLED | stored as its name, so a value is never renamed |
| LineStatus | an enum with WAITING and PICKED | computed from the pick time, and never stored |
For why a payment is a value object and a line is an entity, see the section Inside the aggregate: entities and value objects. For why an enum is stored by its name, see the section Storing an enum as a number.
Money: a currency, and no silent roundingtwist#
public record Money(decimal Amount, string Currency)
{
public decimal Amount { get; } = Math.Round(Amount, 2);
}/**
* An amount of money in one currency, such as 12.50 GBP.
*
* <p>The amount may have at most two decimal places. A value such as 12.345 is refused, not
* rounded: money from another service that has more precision is a mistake to report, and
* rounding it would silently change the amount. The amount is stored with exactly two places, so
* {@code 10} and {@code 10.00} are equal.
*
* @param amount zero or more, with at most two decimal places
* @param currency an ISO 4217 code, such as {@code GBP}
*/
public record Money(BigDecimal amount, String currency) implements ValueObject {
public static final int DECIMAL_PLACES = 2;
public Money {
Guard.notNegative(amount, "amount");
if (amount.stripTrailingZeros().scale() > DECIMAL_PLACES) {
throw new InvalidValueException("amount", "must have at most " + DECIMAL_PLACES + " decimal places");
}
amount = amount.setScale(DECIMAL_PLACES);
Guard.matches(currency, "[A-Z]{3}", "currency");
if (Currency.getAvailableCurrencies().stream().noneMatch(known -> known.getCurrencyCode().equals(currency))) {
throw new InvalidValueException("currency", "is not a known currency");
}
}
public static Money of(String amount, String currency) {
return new Money(new BigDecimal(amount), currency);
}
}Guard.notNegative(amount, "amount");The check refuses null and a negative amount. It uses
signum(), which returns -1, 0 or 1 for the sign of a BigDecimal.if (amount.stripTrailingZeros().scale() > DECIMAL_PLACES) {The scale of a
BigDecimalis the number of digits after the point.stripTrailingZeros()removes zeros at the end first. So12.340has two real decimal places and is accepted, but12.345has three and is refused.amount = amount.setScale(DECIMAL_PLACES);The compact constructor replaces the parameter with a value that has exactly two places. The record stores that value.
setScalewithout a rounding mode throwsArithmeticException: Rounding necessarywhen it would have to round. It never has to here, because of the check before it.if (Currency.getAvailableCurrencies().stream().noneMatch(known -> known.getCurrencyCode().equals(currency))) {Currencyis injava.util. The check refuses a code with the right form that is not a real currency, such asXYZ.public static Money of(String amount, String currency) {The factory takes the amount as text.
new BigDecimal("12.50")keeps the exact value. Adoublecannot hold most decimal fractions exactly. For more, see the section Money is BigDecimal.
The C# record rounds the amount. A run for this book showed that
Math.Round(12.345m, 2) gives 12.34, with no error. The Java value object
refuses 12.345 with amount must have at most 2 decimal places. The test
ValueObjectTest.moneyRefusesMoreThanTwoDecimalPlacesInsteadOfRounding checks it.
Rounding has a careful version. RoundingMode.HALF_EVEN rounds a half to the even
neighbour, so many roundings do not drift upwards. C# Math.Round on a
decimal does the same by default. That is the right tool when the service computes
an amount itself, such as a share of a total. Then the rounding is a business step, and the code
names its mode. But a payment comes from another service. An amount with three decimal places
means that the other side made a mistake or uses another unit. Rounding it would change the
amount and hide the mistake. So the value object refuses it, and the mistake becomes visible
instead of hidden.
The two places also decide equality. A record compares its components with
equals, and BigDecimal.equals compares the scale. In a run for this
book, new BigDecimal("10").equals(new BigDecimal("10.00")) returned false. In C#,
10m == 10.00m is true. Because Money stores exactly two places,
Money.of("10", "GBP") equals Money.of("10.00", "GBP"). The test
ValueObjectTest.moneyIsEqualWhateverTheNumberOfTrailingZeros checks it. For the trap
in full, see the section BigDecimal inside equals.
The amount always travels with its currency. Money.of("1", "GBP") is not equal to
Money.of("1", "EUR"), and the test
ValueObjectTest.moneyNeedsAKnownCurrencyAndNoNegativeAmount checks that too. Without a
currency, every reader of an amount would have to know its unit from somewhere else.
Guard: checks that name the fieldnew#
Every value object checks its values. Without a helper, each one writes its own
if and throw, each with its own wording. A client then gets
"name is empty" from one field and "missing value" from another. So the platform has one class
of checks, each with one fixed message:
public final class Guard {
private Guard() {
}
public static <T> T notNull(T value, String field) {
if (value == null) {
throw new InvalidValueException(field, "is required");
}
return value;
}
public static String notBlank(String value, String field) {
if (value == null || value.isBlank()) {
throw new InvalidValueException(field, "must not be blank");
}
return value;
}
public static String maxLength(String value, int max, String field) {
if (value != null && value.length() > max) {
throw new InvalidValueException(field, "must be at most " + max + " characters");
}
return value;
}
public static String matches(String value, String regex, String field) {
if (value == null || !value.matches(regex)) {
throw new InvalidValueException(field, "has an invalid format");
}
return value;
}
// ...
public static <C extends Collection<?>> C notEmpty(C values, String field) {
if (values == null || values.isEmpty()) {
throw new InvalidValueException(field, "must have at least 1 item");
}
return values;
}
public static <C extends Collection<?>> C maxSize(C values, int max, String field) {
if (values != null && values.size() > max) {
throw new InvalidValueException(field, "must have at most " + max + " items");
}
return values;
}public final class Guard {The class only holds static methods.
finalstops anyone from extending it, and the private constructor stops anyone from creating it. Java has no static classes, so this pair does the same work. For more, see the section No static classes.public static <T> T notNull(T value, String field) {A generic method declares its type parameter before the return type. Here it returns the value with its own type, so the check fits in an assignment, such as
this.customer = Guard.notNull(customer, "customer").if (value == null || !value.matches(regex)) {String.matcheschecks the whole text against the pattern. So the patterns of the value objects need no^and$. For how this differs fromRegex.IsMatch, see the section IsMatch in Java.public static <C extends Collection<?>> C notEmpty(C values, String field) {The type parameter
Cis any collection.Collection<?>means a collection of any element type. The question mark is called a wildcard. The method returns the same type it received. So aList<OrderItem>comes back as aList<OrderItem>. For the question mark, see the section Variance is at the use site.
Each check builds its message from the field name and a fixed text:
| Check | Text after the field name | Example message |
|---|---|---|
| notNull | is required | paidAt is required |
| notBlank | must not be blank | customer.name must not be blank |
| maxLength | must be at most N characters | customer.name must be at most 200 characters |
| matches | has an invalid format | customer.email has an invalid format |
| between | must be between MIN and MAX | quantity must be between 1 and 999 |
| notNegative | must not be negative | amount must not be negative |
| notEmpty | must have at least 1 item | lines must have at least 1 item |
| maxSize | must have at most N items | lines must have at most 50 items |
The messages reach the client. The platform's exception handler puts the field and the
message into the errors list of a 400 response:
@ExceptionHandler(InvalidValueException.class)
ResponseEntity<ProblemDetail> invalidValue(InvalidValueException ex) {
ProblemDetail problem = domainProblem(HttpStatus.BAD_REQUEST, INVALID_REQUEST, ex);
problem.setProperty("errors", List.of(fieldError(ex.field(), ex.getMessage())));
return respond(problem);
}fieldError builds a small map with the keys field and
message. The tests GuardTest.namesTheFieldInTheError and
ValueObjectTest.codesAndQuantitiesAreChecked check the texts. For the whole problem
response, see the chapter The web layer in depth.
The request of the API checks most of these values too, with Bean Validation:
/**
* The JSON body that places an order. The validation annotations give a client a 400 that lists
* every bad field at once, and springdoc copies them into the OpenAPI document. The domain's
* value objects check the same values again, and the domain's check is the one that counts.
*/
@Schema(description = "An order to place, with its first notes")
public record PlaceOrderRequest(
@NotNull @Valid CustomerRequest customer,
@Schema(description = "A DELIVERY_METHOD code", example = "STANDARD") @NotBlank String deliveryMethod,
@NotEmpty @Size(max = 50) List<@Valid LineRequest> lines,
@Schema(description = "Notes saved with the order, in the same transaction")
@Size(max = 20) List<@Valid NoteRequest> notes) {
public record CustomerRequest(
@Schema(example = "Jo Bloggs") @NotBlank @Size(max = 200) String name,
@Schema(example = "jo@example.com") @NotBlank @Email String email) {
}The annotations @NotBlank, @Size and @Email check the
JSON body before the controller method runs. When several fields are wrong, the 400 lists them
all at once. CustomerContact checks the same values again when the controller
builds it. When the two checks disagree, the value object decides, because it is the only check
on every path in.
The strict rule. Each rule has one home. A format rule is in its value object.
What we do. The request's data transfer object (DTO) carries Bean Validation annotations. The value
object checks the same values again with Guard, and its check counts.
Why. The annotations give the client one 400 that names every bad field, and springdoc copies them into the OpenAPI document. But a message, a test, a migration or another feature builds the value object without the request. Only the value object's check covers those paths.
When to stop. Stop when the two lists drift, so that the request accepts a
value that the domain refuses. That is the sign that nobody keeps them in step. Then keep only
@NotNull and @Valid on the request, and let the domain answer.
The Order aggregate: its fields and its constructornew#
In an anaemic model, a service sets order.Status directly, and any class can do
the same. Such a service often adds an outbox
for its events. For that design and a fair comparison, see the section
An anaemic model with an outbox, compared fairly. The order
aggregate allows no such change. Its class comment lists its rules, and its fields can be
changed only from inside the class:
/**
* An order: the aggregate root of the order feature.
*
* <p>The aggregate holds what its rules need at every change: the status, the lines and the last
* payment. Notes are a separate aggregate, because no order rule needs every note, and they can
* grow without limit.
*
* <p>The rules on the order's own data:
* <ul>
* <li>An order is placed only if every product is available, with 1 to {@value #MAX_LINES} lines.</li>
* <li>A shipped or cancelled order cannot change, except that payments are still recorded.</li>
* <li>A line is picked once.</li>
* <li>An order ships only when it is paid and every line is picked.</li>
* <li>A payment is recorded only if it is later than the last one, so repeated and late
* messages change nothing. The first payment makes a new order paid.</li>
* </ul>
*
* <p>The rule that no note may need action is across two aggregates, so it lives in
* {@code ShippingPolicy}.
*
* <p>State changes only through these methods. There are no setters, and the time always comes
* from the {@link Clock} the caller passes.
*/
public class Order extends AggregateRoot<OrderId> {
public static final int MAX_LINES = 50;
private final CustomerContact customer;
private final DeliveryMethod deliveryMethod;
private final Instant placedAt;
private final List<OrderLine> lines;
private OrderStatus status;
private Payment lastPayment;
private Instant shippedAt;
private Instant cancelledAt;
private Order(OrderId id, Long version, CustomerContact customer, DeliveryMethod deliveryMethod, Instant placedAt,
List<OrderLine> lines, OrderStatus status, Payment lastPayment, Instant shippedAt, Instant cancelledAt) {
super(id, version);
this.customer = Guard.notNull(customer, "customer");
this.deliveryMethod = Guard.notNull(deliveryMethod, "deliveryMethod");
this.placedAt = placedAt;
this.lines = new ArrayList<>(lines);
this.status = status;
this.lastPayment = lastPayment;
this.shippedAt = shippedAt;
this.cancelledAt = cancelledAt;
}public class Order extends AggregateRoot<OrderId> {The type argument fixes the ID type. So
order.id()returns anOrderId, and no cast is needed.public static final int MAX_LINES = 50;This is a constant of the class. The comment shows its value with
{@value #MAX_LINES}, so the documentation never disagrees with the code.private final CustomerContact customer;A
finalfield is set once, in the constructor. The customer, the delivery method, the time of placing and the list itself never change.private OrderStatus status;The fields that change are private and not final. Only the methods of the class change them, after they check the rules.
private Order(OrderId id, Long version, CustomerContact customer, DeliveryMethod deliveryMethod, Instant placedAt,The constructor is private. Code outside the class must use
placeorrehydrate, which the next section shows.this.customer = Guard.notNull(customer, "customer");The constructor checks what must hold for every order, new or loaded. An order without a customer cannot exist.
this.lines = new ArrayList<>(lines);The order copies the list it receives into a list of its own. So a later change to the caller's list does not reach the order. The order can also add to its own list, even when the caller passed a list that cannot change.
The class has no setters. The accessors have no get prefix, as in a record, for
example status() and lastPayment(). An architecture rule makes sure that
no entity gets a public setter later:
public static ArchRule entitiesHaveNoPublicSetters() {
DescribedPredicate<JavaClass> entities = assignableTo(Entity.class)
.or(DescribedPredicate.describe("light-style entities",
(JavaClass type) -> type.isAnnotatedWith(Document.class)
&& !type.getPackageName().contains(".persistence")));
return methods().that().areDeclaredInClassesThat(entities)
.and().haveNameMatching("set[A-Z].*")
.should().notBePublic()
.allowEmptyShould(true)
.as("Entities have no public setter");
}DescribedPredicate<JavaClass> entities = assignableTo(Entity.class)The rule finds every subclass of the platform's
Entity, and so every aggregate and every inner entity.(JavaClass type) -> type.isAnnotatedWith(Document.class)It also finds the light-style entities, which are stored documents outside a persistence package, such as
Note..and().haveNameMatching("set[A-Z].*")A method whose name starts with
setand a capital letter is a setter. Such a method must not be public.
The test ArchitectureTest.entitiesHaveNoPublicSetters runs the rule. So a change
to the state always goes through a method that applies the rule, and nobody can skip it.
Placing versus rehydratingnew#
An order comes into memory in two ways. An agent places a new order, or the adapter loads a stored one. These are different actions. Placing checks the rules and tells other services that something happened. Loading only rebuilds what was already true. So the order has two static factories:
/**
* Places a new order. This is a business action: it applies the rules and raises
* {@link OrderPlaced}.
*
* <p>The caller chooses the ID first, because it already sent the ID to the stock service as
* the idempotency key of the reservation.
*
* @param stock the stock service's answer for these items
*/
public static Order place(OrderId id, CustomerContact customer, DeliveryMethod deliveryMethod,
List<OrderItem> items, StockAvailability stock, Clock clock) {
Guard.notEmpty(items, "lines");
Guard.maxSize(items, MAX_LINES, "lines");
if (!stock.available()) {
throw new ProductsUnavailableException(stock.unavailableProducts());
}
List<OrderLine> lines = items.stream().map(OrderLine::add).toList();
Order order = new Order(id, null, customer, deliveryMethod, clock.instant(), lines, OrderStatus.NEW,
null, null, null);
order.raise(new OrderPlaced(id, deliveryMethod, List.copyOf(items), order.placedAt));
return order;
}
/** Rebuilds a stored order. Loading is not a business action, so it applies no rules and raises no events. */
public static Order rehydrate(OrderId id, Long version, CustomerContact customer, DeliveryMethod deliveryMethod,
Instant placedAt, List<OrderLine> lines, OrderStatus status, Payment lastPayment,
Instant shippedAt, Instant cancelledAt) {
return new Order(id, version, customer, deliveryMethod, placedAt, lines, status, lastPayment,
shippedAt, cancelledAt);
}public static Order place(OrderId id, CustomerContact customer, DeliveryMethod deliveryMethod,The name of the business action is the name of the method. The caller passes the ID, the values, the stock service's answer and the clock.
Guard.maxSize(items, MAX_LINES, "lines");An order has 1 to 50 lines. Lines are added only when the order is placed, so the limit is checked here, and nowhere else.
if (!stock.available()) {The use case asked the stock service. The order decides what the answer means. An unavailable product means that no order is placed. For the reason, see the section A decision from another service, a rule in the domain.
List<OrderLine> lines = items.stream().map(OrderLine::add).toList();Each item becomes a line.
OrderLine::addis a method reference to the package-private factory of the line.toList()returns a list that cannot change, and the constructor copies it into the order's own list.Order order = new Order(id, null, customer, deliveryMethod, clock.instant(), lines, OrderStatus.NEW,The version is
null, because the order was never saved. The time comes from the clock that the caller passed.order.raise(new OrderPlaced(id, deliveryMethod, List.copyOf(items), order.placedAt));A static method of the class can call the protected
raiseon an instance of the same class. The event carries a copy of the items.public static Order rehydrate(OrderId id, Long version, CustomerContact customer, DeliveryMethod deliveryMethod,Rehydrating means rebuilding an object from stored data. The method takes every field, including the version and the status. It applies no rule and raises no event.
Loading must not apply the rules of placing. Suppose that rehydrate asked the
stock service again. A stored order whose mug is now sold out could then not be loaded, and it
could not be shipped or cancelled either. Suppose that it raised OrderPlaced. Every
load would then tell other services that a new order exists. The constructor still checks what
holds for every order, such as a customer, so a broken document fails on load.
The caller chooses the ID before placing, because the use case has already sent it to the stock service. The stock service uses it as the idempotency key of the reservation. For that call, see the chapter Calling other services.
Only the persistence mapper calls rehydrate. For the mapper, see the chapter
Storing aggregates in DocumentDB. The line has the same pair of factories,
and its add is package-private. See The root is the only way
in. The light-style note loads through a constructor instead. See
Creating and loading.
The time comes from an injected Clocktwist#
builder.Services.AddSingleton(TimeProvider.System);/** The clock that the domain reads the time from. Code never calls {@code Instant.now()}. */
@Bean
@ConditionalOnMissingBean
Clock clock() {
return Clock.systemUTC();
}@ConditionalOnMissingBeanSpring creates this bean only if the service has no
Clockbean of its own. So a test can declare a fixed clock, and the platform then creates none.return Clock.systemUTC();Clockis injava.time.systemUTC()reads the system time, in the UTC time zone.
Both panes register one object that tells the time, so tests can replace it. In Java, the
application service receives the Clock in its constructor. It passes the clock to
each domain method that needs the time, such as order.ship(clock). An aggregate is
not a bean, so nothing could inject the clock into it. The aggregate reads the time with
clock.instant(), which returns an Instant. No domain code calls
Instant.now().
The tests pass a clock that always returns the same time:
public static final Instant NOW = Instant.parse("2026-01-15T09:00:00Z");
public static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);Instant.parse reads an ISO 8601 text. Clock.fixed returns a clock
that always gives that instant, and ZoneOffset.UTC is its time zone. So a test can
expect shippedAt to be exactly NOW. In C#, a test passes its own
subclass of TimeProvider that overrides GetUtcNow. For the classes of
java.time, see the section java.time.
Changing an order: pick, ship and cancelnew#
Each change of an order is one method. Each method follows the same three steps. It checks the rule, it changes the state, and it raises the event. Here are shipping and cancelling, and the check that both use first:
public void ship(Clock clock) {
requireNotFinished();
if (status != OrderStatus.PAID) {
throw new OrderNotPaidException(id());
}
if (lines.stream().anyMatch(line -> !line.isPicked())) {
throw new UnpickedLinesException(id());
}
status = OrderStatus.SHIPPED;
shippedAt = clock.instant();
raise(new OrderShipped(id(), shippedAt));
}
public void cancel(Clock clock) {
requireNotFinished();
status = OrderStatus.CANCELLED;
cancelledAt = clock.instant();
raise(new OrderCancelled(id(), cancelledAt));
}
// ...
private void requireNotFinished() {
if (status == OrderStatus.SHIPPED || status == OrderStatus.CANCELLED) {
throw new OrderFinishedException(id(), status);
}
}requireNotFinished();This private method runs first in every change. A shipped or cancelled order cannot change. The method throws
OrderFinishedException, which the platform answers with 422.if (status != OrderStatus.PAID) {Each enum value is a single object, so
!=compares enum values safely. For enums, see the chapter Enums.if (lines.stream().anyMatch(line -> !line.isPicked())) {anyMatchreturns true when at least one element matches, likeAnyin LINQ. One line that is not picked stops the shipment.shippedAt = clock.instant();This line is the change. The state changes only after every check has passed.
raise(new OrderShipped(id(), shippedAt));The event comes last. If a check throws, the order has no event to publish, because nothing happened.
So one method holds the rule, the change and the event. A reader who asks "when can an order
ship?" reads ship and its tests. The test OrderTest.Shipping checks the
paid order, the unpaid one and the one with a line that is not picked. One shipping rule is
missing here. An order with a note that needs action cannot ship either. That rule needs two
aggregates, so it is in a domain service. For the reason, see the section
Worked example: no shipping while a note needs action.
pickLine follows the same steps. It finds the line by its ID, or throws
LineNotFoundException, which the platform answers with 404. Then it asks the line to
pick itself. For its code, see the section The root is the only way
in.
Recording a payment, once and in ordernew#
The payment service reports each payment with a message. A queue can deliver a message twice, and two messages can arrive in the wrong order. The order must stay correct in both cases. So the method that records a payment keeps the newest payment, and ignores any other:
/**
* Records a payment, unless it is not later than the last one.
*
* <p>SQS can deliver a message twice, and messages can arrive out of order. Both cases leave
* the order unchanged. A payment for a shipped or cancelled order is still recorded, on
* purpose: the money was received, and the payment service handles any refund.
*
* @return whether the order changed, so the caller knows whether to save it
*/
public boolean recordPayment(Payment payment) {
if (lastPayment != null && !payment.paidAt().isAfter(lastPayment.paidAt())) {
return false;
}
lastPayment = payment;
if (status == OrderStatus.NEW) {
status = OrderStatus.PAID;
}
raise(new PaymentRecorded(id(), payment.reference(), payment.amount(), payment.paidAt()));
return true;
}if (lastPayment != null && !payment.paidAt().isAfter(lastPayment.paidAt())) {isAftercompares twoInstantvalues. A payment at the same time as the last one counts as a repeat. So a second delivery of the same message changes nothing.return false;Nothing changed, and no event is raised. The return value tells the caller that there is nothing to save.
if (status == OrderStatus.NEW) {The first payment makes a new order paid. A later payment replaces the last one, and the status stays as it is.
The method does not call requireNotFinished. That is on purpose. The money was
received, even for a cancelled order, and the payment service handles any refund. The comment of
the method says so, the README of the service says so, and a test records it:
@Test
void aPaymentForACancelledOrderIsStillRecorded() {
Order order = newOrder();
order.cancel(CLOCK);
assertThatCode(() -> order.recordPayment(payment(NOW))).doesNotThrowAnyException();
assertThat(order.status()).isEqualTo(OrderStatus.CANCELLED);
assertThat(order.lastPayment()).isNotNull();
}The test cancels an order, then records a payment for it. The call throws nothing, the status
stays CANCELLED, and the payment is stored. An exception to a rule such as "a
cancelled order cannot change" must be written down in the code, and tested. Otherwise the next
developer adds the missing check, and payments start to fail.
The use case saves only when the method returns true:
@Transactional
public void recordPayment(OrderId orderId, Payment payment) {
Order order = orders.get(orderId);
if (order.recordPayment(payment)) {
saveAndPublish(order, AuditActions.PAYMENT_RECORDED);
}
}The if here is not a business rule. The domain already decided to ignore the
payment. The use case only skips the save. For how the message reaches this method, see the
chapter Consuming events: SQS and the inbox.
OrderLine: a status that is computedtwist#
public DateTimeOffset? PickedAt { get; private set; }
public bool IsPicked => PickedAt is not null;
public LineStatus Status => IsPicked ? LineStatus.Picked : LineStatus.Waiting;public boolean isPicked() {
return pickedAt != null;
}
public LineStatus status() {
return isPicked() ? LineStatus.PICKED : LineStatus.WAITING;
}Both panes compute the status from the pick time. The line stores only
pickedAt. So the status and the time can never disagree. A line with a pick time is
picked, and a line without one is waiting. The C# pane uses properties. The Java pane uses two
methods, because Java has no properties. For more, see the section
The property gap.
The line is an entity with its own LineId, because a picker names the line that
was picked. Its methods that change it are package-private, so only the order can call them. See
The root is the only way in.
Collections leave as copiestrap#
The order holds its lines in a list that it can change. Outside code must not change that
list. In C#, a class returns such a list as IReadOnlyList<T>. Java has no
read-only list interface, so the order returns a copy:
/** Returns a read-only copy, so outside code cannot add or remove lines. */
public List<OrderLine> lines() {
return List.copyOf(lines);
}Show the answer
UnsupportedOperationException, and the
order keeps its lines.Java has no read-only list interface. List has
add and clear, so every call compiles. List.copyOf returns
a list that throws when someone tries to change it.
In C#, IReadOnlyList<T> has no Clear. A run for this book gave
error CS1061. But a C# caller could cast it back to List<T> and clear the
order's own list. A run did that, and the order then had no lines.
In Java, return lines; would return the order's own list. Return a copy.
The test OrderTest records the rule:
@Test
void linesCannotBeChangedFromOutsideTheAggregate() {
Order order = newOrder();
assertThatThrownBy(() -> order.lines().clear()).isInstanceOf(UnsupportedOperationException.class);
}assertThatThrownBy runs the lambda and expects it to throw. So the test fails if
someone changes lines() to return the field.
Java has several ways to return a collection. They differ in what a caller can do:
| What the method returns | Can the caller change it? | Does it see later changes of the order? |
|---|---|---|
| the field itself | yes, and the order's own list changes | yes |
| Collections.unmodifiableList(lines) | no, it throws | yes, because it is a view of the order's list |
| List.copyOf(lines) | no, it throws | no, because it is a copy made at the call |
| lines.stream().toList() | no, it throws, from Java 16 | no, because it is a new list |
The order uses List.copyOf. The copy also refuses null elements. In a run for
this book, List.copyOf of a list that held a null threw
NullPointerException. For the other ways to create a list, see the section
Creating collections.
The copy holds the same line objects as the order. A run for this book kept the list from
order.lines(), then picked the first line through the order. The kept line then
reported isPicked() as true. So the copy protects the list, and package-private
access protects each line. A caller outside the package that calls pick on a line
gets a compiler error:
error: pick(Clock) is not public in OrderLine; cannot be accessed from outside package#order.pickLine(lineId, clock). The root checks the rules first.The use cases go one step further. They never return the aggregate at all. They return a view, a record of plain values:
public record OrderView(String orderId, String status, String deliveryMethod, Instant placedAt, Instant shippedAt,
Instant cancelledAt, List<Line> lines, LastPayment lastPayment) {
public record Line(String lineId, String productCode, int quantity, String status, Instant pickedAt) {
static Line from(OrderLine line) {
return new Line(line.id().value(), line.product().value(), line.quantity(), line.status().name(),
line.pickedAt());
}
}Line is a record declared inside the record OrderView. A record
inside another type is always static, so it needs no instance of the outer record. Its full name
is OrderView.Line. A caller who holds a view cannot call ship and skip
the steps of the use case. For views, see the chapter Use cases: the
application service.
Domain events: collected by the aggregatenew#
When an order ships, the shop must tell the warehouse and the customer. The order does not send anything itself. It records what happened, and the use case publishes the records after it saves. A record of something that happened in the domain is called a domain event. Every domain event implements one small interface:
/**
* Something that happened inside the service, named in the past tense, such as
* {@code OrderShipped}.
*
* <p>A domain event is internal. It may change whenever the model changes. Only the events that
* an integration-event mapper translates leave the service, as versioned integration events.
* Write domain events as records.
*/
public interface DomainEvent {
/** Returns when the event happened, taken from the injected clock. */
Instant occurredAt();
}The interface has one method, occurredAt(). An event is named in the past tense, because it already happened. Its time comes from the
clock that the method received. The order's events are records:
/** An order was shipped. It leaves the service as {@code OrderShippedV1}. */
public record OrderShipped(OrderId orderId, Instant occurredAt) implements DomainEvent {
}A record implements occurredAt() through its component of the same name. So
OrderShipped needs no body. The order feature has five events:
| Domain event | Raised by | Leaves the service as |
|---|---|---|
| OrderPlaced | Order.place | OrderPlacedV1, on the topic order-events |
| LinePicked | Order.pickLine | nothing, because no other service needs it |
| OrderShipped | Order.ship | OrderShippedV1 |
| OrderCancelled | Order.cancel | OrderCancelledV1 |
| PaymentRecorded | Order.recordPayment | nothing, because the payment service already knows |
A domain event belongs to the service, and it may change whenever the model changes. Only a mapper turns some of them into versioned integration events for other services. For that step, see the chapter Publishing events: the outbox.
The use case publishes the events through a port:
/**
* The port that application services use to publish the events an aggregate raised.
*
* <p>Call it inside the use case's transaction, after saving. The platform's implementation
* writes the events to the outbox in that transaction, so an event is stored only with its
* change.
*/
public interface DomainEventPublisher {
void publish(List<DomainEvent> events);
}publish takes the whole list of events of one change. The use case calls it after
the save, inside the same transaction.
Every use case of the order feature ends with the same private method:
private Order saveAndPublish(Order order, String auditAction) {
List<DomainEvent> raised = order.pullEvents();
Order saved = orders.save(order);
events.publish(raised);
audit.record(auditAction, AuditActions.ORDER, saved.id().value());
return saved;
}List<DomainEvent> raised = order.pullEvents();The events are pulled before the save. The repository returns a copy rebuilt from the database, and a rebuilt copy has no events.
events.publish(raised);The platform's implementation writes the events to the outbox, in the same transaction as the save. So an event is stored only together with its change.
So no use case can save a change and forget its event. The order raised the event in the method that changed it, and this one method publishes whatever was raised. For the steps of a use case, see the chapter Use cases: the application service.
The repository porttwist#
public interface IOrderRepository
{
Task<Order?> FindByIdAsync(OrderId id);
async Task<Order> GetAsync(OrderId id) =>
await FindByIdAsync(id) ?? throw new OrderNotFoundException(id);
}/**
* The port for storing orders, in the domain's own types. The persistence package implements it.
* Lists are sorted newest first.
*/
public interface OrderRepository extends Repository<Order, OrderId> {
/** Loads the order, or throws the 404 exception, so a use case loads in one line. */
default Order get(OrderId id) {
return findById(id).orElseThrow(() -> new OrderNotFoundException(id));
}
PageResult<Order> findAll(PageQuery page);
PageResult<Order> findByStatus(OrderStatus status, PageQuery page);
}public interface OrderRepository extends Repository<Order, OrderId> {The port extends the platform's base port. It names the aggregate and its ID type once.
default Order get(OrderId id) {A
defaultmethod has a body in the interface, like a default interface member in C#. Every adapter gets it with no code. For more, see the section Interfaces.return findById(id).orElseThrow(() -> new OrderNotFoundException(id));findByIdreturns an Optional.orElseThrowreturns the order, or throws the exception that the lambda creates. So a use case loads an order, or answers 404, in one line.PageResult<Order> findByStatus(OrderStatus status, PageQuery page);The queries take and return the domain's own types. The comment says that lists are sorted newest first. How the adapter does that is not the domain's concern.
Both ports load or fail in one line. The Java port returns Optional<Order>.
The C# port returns Order?. For when to use Optional, see the
section Optional is for return values. The base port is short:
public interface Repository<A extends AggregateRoot<ID>, ID extends Identifier> {
Optional<A> findById(ID id);
/**
* Saves the aggregate and returns the stored copy, which holds the new version.
*
* <p>Continue with the returned instance. The instance you passed in keeps the old version,
* so saving it a second time fails. For a new aggregate it fails as a duplicate insert, with
* {@code DuplicateKeyException}; for a loaded one, as a concurrent update. Saving also fails
* with Spring's {@code OptimisticLockingFailureException} when someone else saved the
* aggregate after you loaded it; that exception is unchecked, so this plain-Java interface
* can only name it here.
* Inside a transaction, MongoDB usually reports the same conflict as a write conflict, which
* Spring translates to {@code DataIntegrityViolationException}. The platform answers both with 409.
*
* <p>Never retry a failed save with the same instance. Load the aggregate again, apply the
* change again, and save that.
*/
A save(A aggregate);
boolean exists(ID id);The interface has two type parameters. A extends AggregateRoot<ID> says that
only an aggregate root can be stored. ID extends Identifier says that its ID is a
typed ID. The bound on A uses ID, so the aggregate and its ID type must fit
together. A
repository of Order with a LineId does not compile.
The comment of save names Spring's
OptimisticLockingFailureException. The interface cannot import it, because the
domain is plain Java. It does not need to declare it either, because the exception is unchecked.
So the comment is the only place where a reader learns about it. For what the comment says about
the returned instance, see the section The version, and the save that
returns a new instance.
The base port is for aggregates only. The reference feature has no aggregate, so its repository extends Spring Data directly:
public interface ReferenceItemRepository extends MongoRepository<ReferenceItemDocument, String> {
@Cacheable("reference-data")
List<ReferenceItemDocument> findByTypeOrderByCodeAsc(String type);
}MongoRepository is the Spring Data interface for MongoDB, and
@Cacheable keeps each list in a cache. A lookup value has no version and no events,
so the base port would give it nothing.
The note feature, in the light style, also injects a Spring Data repository directly. For both styles, see the chapter How much DDD: three levels in one service.
The version, and the save that returns a new instancenew#
The version of an order is final, and no rule reads it. So the save cannot write
the new version into the order that the use case passes in. The repository returns a new
instance instead, and the use case continues with that one. Placing an order saves it, and then
uses the saved order's ID for its notes:
return transaction.execute(status -> {
Order order = Order.place(orderId, customer, deliveryMethod, items, availability, clock);
Order saved = saveAndPublish(order, AuditActions.ORDER_PLACED); // aggregate 1
List<String> noteIds = notes.addNotes(saved.id().value(), firstNotes); // aggregate 2, same transaction
return new PlacedOrder(saved.id().value(), noteIds);
});saveAndPublish returns the saved instance. The lambda keeps it as
saved, and passes saved.id() to the note feature. The instance
order is not used after the save. What goes wrong when a use case saves the old
instance again is a trap of its own. For the run that shows it, see the section
The order that was passed to save.
A unit test of the use case must behave like the real repository. A mock that returns the instance it received would hide a use case that keeps the old one. So the test's mock builds a new instance with the next version. The mock comes from Mockito:
/** Makes the mocked repository behave like the real one: save returns a stored copy with the next version. */
private void saveReturnsTheStoredCopy() {
when(orders.save(any())).thenAnswer(call -> {
Order order = call.getArgument(0);
long next = order.version() == null ? 0 : order.version() + 1;
return Order.rehydrate(order.id(), next, order.customer(), order.deliveryMethod(), order.placedAt(),
order.lines(), order.status(), order.lastPayment(), order.shippedAt(), order.cancelledAt());
});
}thenAnswer runs the lambda for each call, and getArgument(0) is the
order that the use case passed. The lambda rebuilds it with Order.rehydrate and the
next version, as the real adapter does. A first save gives version 0, because a new order has no
version. For more about mocks, see the chapter Testing in depth.
A failed save is never retried with the same instance. The comment of
Repository.save says so. The use case loads the order again, applies the change
again, and saves that. The light-style note works differently, because Spring Data writes the new
version into the same object. See Versions after a save. For the
stored version and the conflicts, see the chapter Storing aggregates in
DocumentDB.
Paging without Spring: PageQuery and PageResulttwist#
/**
* Which page of a list to return, without any Spring type. Pages count from zero.
*
* @param page the page number, from 0
* @param size the number of items on a page, from 1 to {@value #MAX_SIZE}
*/
public record PageQuery(int page, int size) implements ValueObject {
public static final int MAX_SIZE = 100;
public static final int DEFAULT_SIZE = 20;
public PageQuery {
if (page < 0) {
throw new InvalidValueException("page", "must not be negative");
}
if (size < 1 || size > MAX_SIZE) {
throw new InvalidValueException("size", "must be between 1 and " + MAX_SIZE);
}
}
/** Returns the first page with the default size. */
public static PageQuery first() {
return new PageQuery(0, DEFAULT_SIZE);
}
}A list query takes a PageQuery instead of Spring Data's Pageable, so
the port stays plain Java. The compact constructor refuses a negative page and a size outside 1
to 100. {@value #MAX_SIZE} puts the value of the constant into the documentation.
first() is a static factory for the first page with 20 items. Pages count from
zero, as in Spring Data.
The answer is a PageResult, with the numbers that a client needs to ask for the
next page:
public record PageResult<T>(List<T> items, int page, int size, long totalItems) {
public PageResult {
items = List.copyOf(items);
}
public int totalPages() {
return size == 0 ? 0 : (int) Math.ceil((double) totalItems / size);
}
/** Converts each item and keeps the paging numbers, for example from aggregates to views. */
public <R> PageResult<R> map(Function<? super T, ? extends R> mapper) {
return new PageResult<>(items.stream().<R>map(mapper).toList(), page, size, totalItems);
}
}items = List.copyOf(items);The record stores a copy that cannot change, as
StockAvailabilitydoes.return size == 0 ? 0 : (int) Math.ceil((double) totalItems / size);41 items in pages of 20 make 3 pages. The test
GuardTest.pageQueryLimitsTheSizechecks that number.public <R> PageResult<R> map(Function<? super T, ? extends R> mapper) {This generic method converts each item and keeps the paging numbers. The function may take any supertype of
Tand return any subtype ofR. This rule is called PECS, "producer extends, consumer super". See Variance is at the use site.items.stream().<R>map(mapper).toList().<R>mapgives the type argument ofmapexplicitly. Without it, the compiler infers a stream of "some subtype of R". A run for this book then failed witherror: incompatible types: cannot infer type arguments for PR<>.
The use case turns a page of orders into a page of views in one call,
result.map(OrderView::from). The controller turns the result into the JSON of a page.
For that step, see the chapter The web layer in depth.
Exceptions by kind, with stable codesnew#
A use case throws "this order is not paid". The client must get the same status for it in every service, and a code that its program can test. If each feature wrote its own exception handler, the statuses would drift. So the platform defines exactly three kinds of domain exception. The kind decides the status, and a feature only chooses the kind:
public abstract class DomainException : Exception
{
private protected DomainException(string code, string message) : base(message) => Code = code;
public string Code { get; }
}
public abstract class NotFoundException : DomainException
{
protected NotFoundException(string code, string message) : base(code, message) { }
}/**
* The base of every exception the domain throws. It has exactly three kinds, and the kind
* decides the HTTP status, so a feature never writes an exception handler.
*
* <ul>
* <li>{@link NotFoundException}: the thing does not exist (404).</li>
* <li>{@link InvalidValueException}: a value has the wrong format (400).</li>
* <li>{@link BusinessRuleViolationException}: the request breaks a business rule (422).</li>
* </ul>
*
* <p>The class is sealed, so no other direct subclass can exist. Extend one of the three kinds.
* The message is returned to the caller, so it must never hold personal data. IDs are fine.
*/
public abstract sealed class DomainException extends RuntimeException
permits NotFoundException, InvalidValueException, BusinessRuleViolationException {
private final String code;
protected DomainException(String code, String message) {
super(message);
this.code = code;
}
/** Returns a stable, machine-readable code, such as {@code ORDER_NOT_PAID}. */
public String code() {
return code;
}
}public abstract sealed class DomainException extends RuntimeExceptionA sealed class lists the classes that may extend it. It extends
RuntimeException, so it is an unchecked exception, and no method has to declare it. For checked and unchecked exceptions, see the section Checked exceptions.permits NotFoundException, InvalidValueException, BusinessRuleViolationException {The keyword permits names the classes that may extend
DomainExceptiondirectly. Only these three may. A run for this book compiledclass Oops extends DomainExceptionin another package. The compiler refused it.public String code() {Every domain exception carries a code, such as
ORDER_NOT_PAID. The platform puts it into the problem response.
The compiler's message names the rule:
error: class is not allowed to extend sealed class: DomainException (as it is not listed in its 'permits' clause)#DomainException directly, but the sealed class allows only
its three kinds.NotFoundException or
BusinessRuleViolationException.The C# pane has no list of permitted classes. The closest match in C# is a
private protected constructor. Only a derived class in the same assembly can call it.
In a run for this book, a class in another assembly that derived from the C#
DomainException failed with error CS1729. A class that derived from
NotFoundException compiled. The Java rule works by name, across packages and
libraries.
Each kind is non-sealed, so features may extend it. Here is one of them:
/**
* The thing a request names does not exist. The platform answers 404.
*
* <p>Write one subclass per kind of thing, such as {@code OrderNotFoundException}. The class is
* non-sealed and its constructor is protected, so features can extend it but not throw it bare.
*/
public non-sealed class NotFoundException extends DomainException {
protected NotFoundException(String code, String message) {
super(code, message);
}
}non-sealed opens this branch again. Any class may extend
NotFoundException. The constructor is protected, so a feature cannot
throw a bare NotFoundException. In a run for this book,
new NotFoundException("X", "y") in another package failed with
NotFoundException(String,String) has protected access in NotFoundException. For
sealed and non-sealed in general, see the section
Sealed types: Java's discriminated unions.
The three kinds differ in their status and in how a feature uses them:
| Kind | Status | Constructor | How a feature uses it |
|---|---|---|---|
| NotFoundException | 404 | protected, with a code and a message | one subclass for each kind of thing, such as OrderNotFoundException |
| InvalidValueException | 400 | public, with a field and a problem | thrown directly, mostly by Guard, and its code is always INVALID_VALUE |
| BusinessRuleViolationException | 422 | protected, with a code and a message | one subclass for each broken rule, such as OrderNotPaidException |
One broken rule of the order looks like this:
public class OrderNotPaidException extends BusinessRuleViolationException {
public OrderNotPaidException(OrderId id) {
super("ORDER_NOT_PAID", "Order " + id + " cannot ship before it is paid");
}
}The class name says which rule broke. The code ORDER_NOT_PAID is the same name in
capitals, and the message explains it for a person. The order feature has one exception class for each rule:
| Exception | Kind | Code | Thrown when |
|---|---|---|---|
| OrderNotFoundException | not found | ORDER_NOT_FOUND | no order has the ID |
| LineNotFoundException | not found | LINE_NOT_FOUND | the order has no line with the ID |
| ProductsUnavailableException | rule | PRODUCTS_UNAVAILABLE | the stock service cannot hold every product |
| LineAlreadyPickedException | rule | LINE_ALREADY_PICKED | a line is picked a second time |
| OrderNotPaidException | rule | ORDER_NOT_PAID | an order ships before it is paid |
| UnpickedLinesException | rule | UNPICKED_LINES | an order ships with a line that is not picked |
| OrderHasOpenActionsException | rule | ORDER_HAS_OPEN_ACTIONS | an order ships while a note on it needs action |
| OrderFinishedException | rule | ORDER_FINISHED | a shipped or cancelled order is changed |
Clients may rely on a code, so a code never changes. A client program tests
code == "ORDER_NOT_PAID", and it never parses the message. The message is returned to
the caller too, so it must never hold personal data. IDs and product codes are fine. A customer's
name is not.
The platform's exception handler maps each kind to its status:
@ExceptionHandler(NotFoundException.class)
ResponseEntity<ProblemDetail> notFound(NotFoundException ex) {
return respond(domainProblem(HttpStatus.NOT_FOUND, "Not found", ex));
}
// ...
@ExceptionHandler(BusinessRuleViolationException.class)
ResponseEntity<ProblemDetail> businessRule(BusinessRuleViolationException ex) {
return respond(domainProblem(HttpStatus.UNPROCESSABLE_CONTENT, "Business rule violated", ex));
}@ExceptionHandler names the exception class that the method handles. The method also
handles every subclass of that class. So OrderNotPaidException reaches businessRule and becomes
a 422. The test OrderControllerTest.aBrokenBusinessRuleIsA422WithItsCode checks the
status and the code in the body. HttpStatus.UNPROCESSABLE_CONTENT is the name of 422 in Spring Framework 7. No
feature writes a handler of its own. For the problem response and its code property,
see the chapter The web layer in depth.
The light-style Note, for comparisontwist#
/**
* Writes a new note on an order. The caller has checked that the order exists and that the
* category is an active lookup value. {@code createdBy} is the ID of the agent who writes it.
*/
public static Note create(String orderId, String text, String category, boolean actionRequired,
String createdBy, Clock clock) {
Guard.notBlank(orderId, "orderId");
Guard.notBlank(text, "text");
Guard.maxLength(text, MAX_TEXT_LENGTH, "text");
Guard.matches(category, "[A-Z_]{1,50}", "category");
Guard.notBlank(createdBy, "createdBy");
Note note = new Note(UUID.randomUUID().toString(), null, orderId, text, category, actionRequired,
clock.instant(), createdBy, null);
note.events.add(new NoteAdded(note.id, orderId, note.createdAt));
return note;
}The note keeps the same ideas as the order, with fewer types. create is a business
action that checks the values with Guard and records NoteAdded. It also
records the ID of the agent who writes the note, for the rule that another agent resolves it. Its IDs
are plain String values. It has no base class, so it keeps its own event list and its
own equals. The table compares the two styles where this chapter used them:
| Concern | Order, the full style | Note, the light style |
|---|---|---|
| the ID of the entity | OrderId, checked by Identifier | a String made from UUID.randomUUID() |
| a reference to another aggregate | none, because the order refers to no other aggregate | a String orderId, with no OrderRef type, checked by the order feature |
| values | value objects, such as CustomerContact | Strings checked in create, such as the category with Guard.matches |
| personal data in toString | CustomerContact[masked] | the text is left out, and only its length is printed |
| loading | Order.rehydrate, called by the mapper | a constructor marked @PersistenceCreator, called by Spring Data |
| events | AggregateRoot.raise and pullEvents | its own list, marked @Transient, and its own pullEvents |
| the repository | a port with an adapter | a Spring Data repository, injected directly |
The note has four rules of its own. Only a note that needs action can be resolved. It is
resolved once. The agent who wrote it may not resolve it. Its text never changes.
resolveAction checks the first three rules. It throws
NoteHasNoActionException, NoteActionAlreadyResolvedException or
NoteResolvedByItsAuthorException, which are all business rule violations. When it succeeds, it raises
NoteActionResolved.
requireBelongsTo answers a note under another order with
NoteNotFoundException, a 404. A 403 would tell the caller that the note exists. For
that choice, see the section Rules about one record: in the domain, with
the actor passed in.
A note's text may hold personal data, so its toString leaves the text out:
/** Leaves the text out: an agent may have written personal data in it. */
@Override
public String toString() {
return "Note[id=" + id + ", orderId=" + orderId + ", " + text.length() + " chars]";
}The text form prints text.length(), the number of characters, and never the text
itself. The test NoteTest.checksItsValuesAndHidesItsTextInToString writes a phone number
into a note. It checks that the text form does not contain it, and contains 36
chars.
The reference feature, at the simple level, has no value object at all. A lookup value is a
document record, ReferenceItemDocument, with a type, a code, a label and an
active flag. An inactive value stays stored, because old orders still use it. For
the three levels side by side, see the chapter How much DDD: three levels in one
service.
Testing the modelnew#
A rule of the domain is plain Java, so its test is plain Java too. The test of the order creates orders, calls their methods and checks the result. It starts no Spring and needs no mocks:
/**
* The order's rules, in plain JUnit: no Spring, no mocks, a few milliseconds. If a test here
* needed Spring, the code under test would be in the wrong place.
*/
class OrderTest {
@Nested
class Placing {
@Test
void placesANewOrderAndRaisesOrderPlaced() {
OrderId id = OrderId.newId();
Order order = Order.place(id, CUSTOMER, STANDARD, TWO_MUGS, StockAvailability.allAvailable(), CLOCK);
assertThat(order.status()).isEqualTo(OrderStatus.NEW);
assertThat(order.version()).isNull();
assertThat(order.lines()).singleElement().satisfies(line -> {
assertThat(line.product()).isEqualTo(new ProductCode("MUG-001"));
assertThat(line.status()).isEqualTo(LineStatus.WAITING);
});
assertThat(order.pullEvents()).containsExactly(new OrderPlaced(id, STANDARD, TWO_MUGS, NOW));
}@NestedJUnit runs the tests of an inner class as a group. The class
OrderTesthas five such groups, one for each method of the order, such asPlacing. The report then reads like the list of rules.Order order = Order.place(id, CUSTOMER, STANDARD, TWO_MUGS, StockAvailability.allAvailable(), CLOCK);The test data comes from
TestData, with its fixed clock. The stock answer is a value, so no stock service is needed.assertThat(order.version()).isNull();A placed order was never saved.
assertThat(order.pullEvents()).containsExactly(new OrderPlaced(id, STANDARD, TWO_MUGS, NOW));Events are records, so the test compares the whole event by value. The time is exactly
NOW, because the clock is fixed.
The comment of the class states a rule for the codebase. If a test here needed Spring, the code under test would be in the wrong place. Other tests start from a ready state with a helper:
/** A new order with no raised events, so each test starts clean. */
public static Order newOrder() {
Order order = Order.place(OrderId.newId(), CUSTOMER, STANDARD, TWO_MUGS, StockAvailability.allAvailable(), CLOCK);
order.pullEvents();
return order;
}
/** A paid order whose lines are all picked, with no raised events: ready to ship. */
public static Order readyToShip() {
Order order = newOrder();
order.recordPayment(payment(NOW));
order.lines().forEach(line -> order.pickLine(line.id(), CLOCK));
order.pullEvents();
return order;
}newOrder and readyToShip pull the events at the end. So each test sees
only the events of the method it tests. In the tests, order.lines().getFirst() returns
the first element of the list. getFirst was added in Java 21. See Sequenced
collections. For the test kinds of the whole service, see the chapter
Testing in depth.
1An expert asksWhy does Order have two static factories, place and
rehydrate, and a private constructor, instead of one public constructor?
Order have two static factories, place and
rehydrate, and a private constructor, instead of one public constructor?OrderPlaced. Loading only rebuilds what was already stored. If loading checked
the rules of placing, an order with a sold-out product could not be loaded. If it raised the
event, each load would announce a new order. The private constructor checks only what holds for
every order.2An expert asksMoney receives an amount of 12.345 GBP from the payment service. Why
does it refuse the amount, when it could round it to 12.34?
Money receives an amount of 12.345 GBP from the payment service. Why
does it refuse the amount, when it could round it to 12.34?3The unit test of OrderService mocks the repository. Why does the mocked
save return a rebuilt order with the next version, and not the order it
received?
OrderService mocks the repository. Why does the mocked
save return a rebuilt order with the next version, and not the order it
received?4A feature in the order service needs an exception for "this delivery slot is full". Which class
does it extend, and which status does the client get?
BusinessRuleViolationException, with its own stable code such as
DELIVERY_SLOT_FULL. The client gets 422 with that code. It cannot extend
DomainException directly, because that class is sealed and permits only its three
kinds.5TranslateWrite this C# value object in the style of the order service, as a Java
record that checks its values with the platform's Guard:
public record OrderItem(ProductCode Product, int Quantity)
{
public const int MaxQuantity = 999;
public ProductCode Product { get; } =
Product ?? throw new ArgumentNullException(nameof(Product));
public int Quantity { get; } = Quantity is >= 1 and <= MaxQuantity
? Quantity
: throw new ArgumentOutOfRangeException(nameof(Quantity));
}
Guard:
public record OrderItem(ProductCode Product, int Quantity)
{
public const int MaxQuantity = 999;
public ProductCode Product { get; } =
Product ?? throw new ArgumentNullException(nameof(Product));
public int Quantity { get; } = Quantity is >= 1 and <= MaxQuantity
? Quantity
: throw new ArgumentOutOfRangeException(nameof(Quantity));
}public record OrderItem(ProductCode product, int quantity) implements ValueObject {
public static final int MAX_QUANTITY = 999;
public OrderItem {
Guard.notNull(product, "productCode");
Guard.between(quantity, 1, MAX_QUANTITY, "quantity");
}
}The compact constructor runs before the fields are set. Guard.notNull and
Guard.between throw an error that names the field. So the client gets 400 with
quantity must be between 1 and 999.
Quick reference#
The Java features that the domain model uses, where each one appears, and where the book explains it:
- Records hold every value object and every event. Their accessors have no
getprefix, such asamount(). See The basics. - Compact constructors check values, and may replace them. Examples are
MoneyandPageResult. See Checking values: the compact constructor. - An overridden toString in a record hides personal data, in
CustomerContact. See Value objects as records. - Static factories name how an object is made, such as
of,newIdandplace. See Typed IDs: Identifier and its subclasses. - Nested records group related types, such as
OrderView.Line. Settings classes use static nested classes in the same way. See Collections leave as copies. - sealed, permits and non-sealed close the hierarchy of
DomainException, and open each of its three kinds. See Sealed types: Java's discriminated unions. - Generics with bounds tie the base classes to typed IDs, as in
AggregateRoot<ID extends Identifier>. Wildcards appear inPageResult.map. See Declaration and Variance is at the use site. - Generic static methods return the value they check, such as
Guard.notNull. See Guard: checks that name the field. - Abstract classes with protected constructors and final methods are the base
classes
Entity,AggregateRootandIdentifier. See Abstract classes. - A final class with a private constructor holds static helpers, in
GuardandTestData. See No static classes. - Package-private access keeps
OrderLine.addandOrderLine.pickinside the model package. See The mapping. - Default methods give a port shared code, such as
OrderRepository.get. An interface may also hold static methods, such asIntegrationEvent.newEventId(). It may hold constants too, such asCurrentActor.SYSTEM. See Interfaces. - A marker interface has no methods, such as
ValueObject. See Inside the aggregate: entities and value objects. - Streams find and convert the lines of the order, with
filter,anyMatchandtoList(). The outbox also usesflatMap(Optional::stream)andsorted. See Operator translation. - Optional is the return type of
findById, andorElseThrowturns it into a 404. See Optional is for return values. - Collections that cannot change come from
List.copyOfandList.of. Maps haveMap.ofandMap.copyOf. See Creating collections. - getFirst returns the first element of a list, from Java 21. The tests use it. See Sequenced collections.
- var declares a local variable with an inferred type, in some tests. See var.
- java.time gives
Instant,ClockandZoneOffset. Other features useLocalDateandDuration. See java.time. - BigDecimal holds an amount, with
setScaleandstripTrailingZerosinMoney. See Money is BigDecimal. - UUID and Pattern create and check the text of a typed ID, in
Identifier. See Typed IDs: Identifier and its subclasses. - Objects.requireNonNull and Objects.hash check for null and build hash codes,
in
EntityandIdentifier. See Writing them.
Where each rule goes#
Each rule of the order service has one home. A rule on the order's own data is a method of the aggregate. A rule that needs two aggregates is a domain service. The application service only runs the steps of a use case. This chapter gives five questions that choose the place, a test for rules that slip into services, and a worked example with the notes.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | an if in the controller or the service class | a method of the aggregate, a domain service, or a step of the application service | each place holds one kind of logic, and may use only certain things Why |
| new | no equivalent | five questions, asked in order | the first yes decides the place, so each rule ends in the most local place that can check it Why |
| new | no equivalent | the business-expert test | a line that a business expert would call a rule does not belong in the application service Why |
| new | if (notesNeedingAction > 0) throw ... in ShipAsync | ShippingPolicy.assertCanShip(order, notesNeedingAction) | the rule needs two aggregates, so a domain service receives a number from the note feature Why |
| new | no equivalent | StockChecker in application/port, and the answer judged in Order.place | the use case asks the other service, and the domain decides what the answer means Why |
| new | no equivalent | a table of the service's rules, each with its place | each rule has one home, which a reader finds without a search Why |
| new | no equivalent | ReferenceDataService.requireActive | the lookup check needs stored data, so it stays in the simple feature's service as a pragmatic choice Why |
| new | order.Status = OrderStatus.Shipped in a service | order.ship(clock) | with public setters, any caller can skip the rule, forget the event or read the system time Why |
| new | OrderDomainService | ShippingPolicy | a name that states the rule keeps unrelated code out of the class Why |
Three places for a rulenew#
An agent asks the service to ship an order. Three different kinds of logic decide what happens. The order checks that it is paid and picked. A rule across the order and its notes checks that no note needs action. And the use case loads, saves and publishes. The method that ships an order shows all three, one line each:
/** Ships the order. The rule across the order and its notes is checked by the domain service. */
@Transactional
public void ship(OrderId orderId) {
Order order = orders.get(orderId); // load
long notesNeedingAction = notes.countNeedingAction(orderId.value()); // ask the note feature
shippingPolicy.assertCanShip(order, notesNeedingAction); // rule across aggregates
order.ship(clock); // rule and change
saveAndPublish(order, AuditActions.ORDER_SHIPPED); // save, publish, audit
}Order order = orders.get(orderId);This is a step of the use case. It loads the aggregate, or answers 404.
long notesNeedingAction = notes.countNeedingAction(orderId.value());This is a step too. It asks the note feature for one number.
shippingPolicy.assertCanShip(order, notesNeedingAction);This is the rule that needs two aggregates. A domain service checks it.
order.ship(clock);Here the aggregate checks the rules on its own data, and then changes itself.
saveAndPublish(order, AuditActions.ORDER_SHIPPED);These are the last steps. The use case saves the order, publishes the events and writes the audit entry.
So the use case contains no rule of its own. It decides the order of the steps, and the domain
decides what is allowed. For that structure in full, see the chapter Use cases:
the application service. For the code of order.ship, see the section
Changing an order: pick, ship and cancel.
Each place holds one kind of logic. The table also lists a fourth place, the value object, which checks the form of a single value:
| Place | What it holds | What it may use | What it must not use | Does it change state? | How it is tested |
|---|---|---|---|---|---|
| the aggregate, such as Order | rules on its own data, such as "paid and every line picked" | its own fields, value objects, and the clock it receives | repositories, other aggregates, Spring, HTTP | yes, its own state | plain JUnit, in milliseconds |
| a domain service, such as ShippingPolicy | a rule that needs data from more than one aggregate | the aggregates and values that it receives | repositories, Spring, and state of its own | no | plain JUnit |
| the application service, such as OrderService | the steps of a use case | repositories, ports, other features' services, transactions | business rules | no, because it asks the domain to change | Mockito mocks of the ports |
| a value object, such as Money | the form of one value | Guard, and its own components | anything outside itself | no, because it never changes | plain JUnit |
A domain service receives what it needs, and it never loads anything. Because of that, its test needs no mock. The application service is the only place that touches storage, other services and transactions. Because of that, it is the only place whose test needs mocks.
The decision order: five questionsnew#
A new rule arrives. An order may hold at most 50 lines. A developer could check it in the controller, in the use case or in the order. Each choice compiles, and each passes a first test. But only one place covers every way into the service. The order service checks it here, in the factory that places an order:
public static Order place(OrderId id, CustomerContact customer, DeliveryMethod deliveryMethod,
List<OrderItem> items, StockAvailability stock, Clock clock) {
Guard.notEmpty(items, "lines");
Guard.maxSize(items, MAX_LINES, "lines");Guard.maxSize throws a 400 that names the field lines. Lines are added
only when an order is placed, so every order passes this check. To find such a place for any rule,
ask these questions in order, and stop at the first yes:
Each question, with an answer from the order service:
- The form of one value. A quantity from 1 to 999 needs nothing but the
quantity. So
OrderItem, a record, checks it in its compact constructor. Every path that builds an item meets the check. - One aggregate's data. "At most 50 lines" needs only the order's lines. So
Order.placechecks it. The rules on one aggregate are the most common kind, so most rules stop here. - Two or more aggregates. "No note needs action" needs the order and its notes. So a domain service checks it, and the use case gives it the data. The section Worked example: no shipping while a note needs action shows why.
- Another feature owns the data. "The delivery method is an active lookup value"
needs the lookup list of the reference feature. So the order feature calls
ReferenceDataService.requireActive, and it never reads the list itself. - Input, output, a transaction or a permission. "Call the stock service before
the transaction starts" is about how the work runs, not about what is allowed. So it is a step of
OrderService.place. A permission is checked where users enter. See Choosing the place by the shape of the service.
The order of the questions matters. The first questions choose the most local places. A check in a value object runs on every path, and a check in the aggregate runs on every change. A check in a use case runs only in that use case. So a rule that could stop at question 1 or 2, but is written at question 5, is skipped by every other use case.
Prefer the aggregate over a domain service whenever the aggregate's own data is enough. Each rule that moves out of the aggregate leaves the aggregate with less to do. When most rules have moved out, the aggregate only holds data. Such a model is called an anaemic domain model. Every caller must then know the rules again. For that outcome, see the section What an anaemic model looks like, and what goes wrong.
The five questions follow from one principle. The root of an aggregate guards the rules on its own data. For that principle and the others, see the chapter The principles.
The business-expert testnew#
The five questions need some thought. A quicker test works on a single line of a use case. Picture a person who knows the business but no code, such as the head of customer service. Read the line to them in plain words. If they say "yes, that is one of our rules", the line does not belong in the application service. The class comment of the order's use cases states the test:
* <p>Every change follows the same steps: load, let the domain apply its rules and change
* itself, save, publish the events, record the audit. The steps that call other services run
* before the transaction. This class decides the order of the steps and never contains a
* business rule: a line that a business expert would recognise as a rule belongs in the domain.The comment of OrderService lists the steps of a change. Its last sentence names
the test, with the words a business expert would recognise as a rule. Here are
lines of the order service, read to a business expert:
| Line, in plain words | Would an expert call it a rule? | Where it belongs |
|---|---|---|
| an order that is not paid cannot ship | yes | Order.ship |
| an order with a note that needs action cannot ship | yes | ShippingPolicy |
| a repeated payment changes nothing | yes | Order.recordPayment |
| save the order only if the payment changed it | no, this is about storage | OrderService.recordPayment |
| reserve the stock before the transaction starts | no, this is about how the work runs | OrderService.place |
| publish the events after the save | no | OrderService.saveAndPublish |
| the delivery method must be an active lookup value | yes, but it needs stored data | ReferenceDataService, as a pragmatic choice |
The fourth line is the one if in the use cases of the order. Its comment explains
why it is not a rule:
/**
* Records a payment from the payment service. A message starts this use case, so no user
* acts, and the audit log records the system actor.
*
* <p>It joins the transaction of the inbox that called it. The one {@code if} here is not a
* business rule: the domain decided to ignore the payment, so there is nothing to save.
*/
@Transactional
public void recordPayment(OrderId orderId, Payment payment) {
Order order = orders.get(orderId);
if (order.recordPayment(payment)) {
saveAndPublish(order, AuditActions.PAYMENT_RECORDED);
}
}order.recordPayment(payment) applies the rule and returns whether anything
changed. The if only skips a save when there is nothing to save. An expert would not
recognise it. So the test also tells you which if statements may stay in a use
case.
Worked example: no shipping while a note needs actionnew#
A customer-service agent writes a note on an order, "call the customer before shipping". The note needs action. Until an agent resolves it, the order must not ship. The rule needs the order, which knows whether it can ship, and the notes, which know whether any needs action. Order and note are two aggregates. For why they are separate, see the section Why order and note are separate aggregates.
The service checks the rule in a domain service:
public class ShippingPolicy {
public void assertCanShip(Order order, long notesNeedingAction) {
if (notesNeedingAction > 0) {
throw new OrderHasOpenActionsException(order.id(), notesNeedingAction);
}
}
}assertCanShip receives the order and a number. It throws
OrderHasOpenActionsException when the number is above zero, and the platform answers
422 with the code ORDER_HAS_OPEN_ACTIONS. The class has no field, no repository and
no Spring annotation.
Four other designs look possible. Each one is tempting, so each one deserves a fair look.
Why not inside Order, with the notes loaded. The order could hold its notes, and
ship could check them. But notes have no limit, and no other rule of the order needs
them. Every load of an order would then load every note, and every new note would change the
order's document. For the full reasons, see the section Why order and note
are separate aggregates.
Why not inside Order, with a repository. The order could ask a note repository
in ship. Then the aggregate would do input and output. The domain would no longer be
plain Java, and every test of ship would need a mock. The architecture rule
domainIsPlainJava forbids it anyway.
Why not inside Order, with the number passed in. This is the strongest
alternative. order.ship(clock, notesNeedingAction) keeps every shipping rule in one
method, and its test needs no mock either. It is a good design, and a small service may choose it.
The service still prefers the policy for three reasons. The number is a fact of the note feature,
not of the order. A class of its own shows a reader that this rule spans two aggregates. And a rule
across two aggregates holds only at the moment it is checked, so it helps to see such a rule
apart. Choose the parameter instead when the cross-aggregate rule is the only one,
and the team prefers fewer classes.
Why not in the application service. The use case could write
if (notesNeedingAction > 0) throw ... itself. A business expert would recognise that
line as a rule. And a rule in one use case is missing from the next one. Suppose that the
warehouse later ships orders from a message. Its listener would call another method, and that
method must copy the check or forget it. A test of the rule would also need the mocks of the whole
use case.
The guarantee holds only at the moment of shipping. An agent may add a note that needs action a second after the order shipped. The business owner accepted that, and the README writes the decision down. For the gap between two aggregates and the ways to close it, see the section What holds between two aggregates, and what does not.
The policy receives a number, not a list of notes. The note feature counts the notes, and gives the order feature only the count:
/** For the order feature's shipping rule: a plain number, so no note crosses the boundary. */
public long countNeedingAction(String orderId) {
return notes.countByOrderIdAndActionRequiredTrueAndActionResolvedAtIsNull(orderId);
}countNeedingAction returns a long. So no note object crosses from the
note feature into the order feature. The order feature may not even hold one. An architecture rule
makes the build fail when an application service takes another feature's entity or
repository:
/**
* A service never takes another feature's repository or entity. It asks that feature's
* service, which checks that feature's rules. This is a narrower, clearer form of the rule
* above for the most common mistake.
*/
public static ArchRule servicesDoNotUseOtherFeaturesRepositoriesOrEntities(String basePackage) {ArchitectureTest runs servicesDoNotUseOtherFeaturesRepositoriesOrEntities
for the order service. A note is a stored document, so it counts as an entity here. For the rules
between features, see the chapter Features, layers and the rules.
Because the policy receives plain values, its test is plain JUnit:
class ShippingPolicyTest {
private final ShippingPolicy policy = new ShippingPolicy();
private final Order order = readyToShip();
@Test
void allowsShippingWhenNoNoteNeedsAction() {
assertThatCode(() -> policy.assertCanShip(order, 0)).doesNotThrowAnyException();
}
@Test
void refusesShippingWhileANoteNeedsAction() {
assertThatThrownBy(() -> policy.assertCanShip(order, 2))
.isInstanceOf(OrderHasOpenActionsException.class)
.hasMessageContaining("2 note(s) need action");
}
}The test creates the policy with new, and passes 0 and then 2. No note, no
repository and no Spring are involved. The test of the use case only checks that the steps run in
order, and that nothing is saved when the rule fails:
@Test
void isRefusedWhileANoteNeedsAction() {
Order order = readyToShip();
when(orders.get(order.id())).thenReturn(order);
when(notes.countNeedingAction(order.id().value())).thenReturn(1L);
assertThatThrownBy(() -> service.ship(order.id())).isInstanceOf(OrderHasOpenActionsException.class);
verify(orders, never()).save(any());
verify(audit, never()).record(anyString(), anyString(), anyString());
}The mocked note feature returns 1L, the number of notes that need action. The test
expects the exception, and verify(orders, never()).save(any()) checks that nothing was
saved. The policy's own test covers the values of the rule. The use case's test covers only its
place in the steps.
The policy has no Spring annotation, because it is in the domain. The order feature creates it in a configuration class:
@Configuration(proxyBeanMethods = false)
class OrderConfig {
@Bean
ShippingPolicy shippingPolicy() {
return new ShippingPolicy();
}
}@Bean tells Spring to call the method and keep the result as a bean. Spring then
injects it into OrderService. For @Bean and
proxyBeanMethods, see the chapter Wiring the service: the container
in depth.
A decision from another service, a rule in the domainnew#
An order is placed only if the stock service can hold every product. The answer comes over HTTP, from another service. The rule "no stock, no order" belongs to this service. So the work is split in two. The use case asks, and the domain decides. The question goes through a port in the application layer:
/**
* The port to the stock service, in this service's types. The use case does not know that the
* answer comes over HTTP, and a unit test replaces it with a mock.
*
* <p>It lives in {@code application/port}, not in the domain: calling another service is a step
* of the use case, and the domain only judges the answer.
*/
public interface StockChecker {
/**
* Asks the stock service to confirm that the items are available, and to hold them for the
* order. The stock service keeps the hold until it sees {@code OrderPlacedV1}, or releases it
* after a timeout if the order is never placed.
*
* <p>Calling it twice for the same order holds nothing twice, because the order ID is the
* idempotency key.
*/
StockAvailability reserve(OrderId order, List<OrderItem> items);
}The comment of StockChecker says why the port is in application/port
and not in the domain. Calling another service is a step of the use case. The domain only judges
the answer. The answer is StockAvailability, a value object in this service's own
words. For that value object, see the section Value objects as
records.
The use case asks first, and then hands the answer to the order:
public PlacedOrder place(CustomerContact customer, DeliveryMethod deliveryMethod, List<OrderItem> items,
List<NewNote> firstNotes) {
referenceData.requireActive(ReferenceTypes.DELIVERY_METHOD, deliveryMethod.code(), "deliveryMethod");
OrderId orderId = OrderId.newId();
StockAvailability availability = stock.reserve(orderId, items); // outside the transaction
return transaction.execute(status -> {
Order order = Order.place(orderId, customer, deliveryMethod, items, availability, clock);stock.reserve(orderId, items) runs before the transaction starts, because a slow
HTTP call would hold the transaction open. Order.place then receives
availability, and throws ProductsUnavailableException when a product is
missing. For the code of place, see the section Placing versus
rehydrating.
Each half has its own test. OrderTest.refusesProductsTheStockServiceCannotHold
checks the rule with a value, and no stock service. The test of the use case checks the steps:
@Test
void savesNothingWhenTheStockIsNotAvailable() {
when(stock.reserve(any(), any())).thenReturn(StockAvailability.unavailable(List.of(new ProductCode("MUG-001"))));
assertThatThrownBy(() -> service.place(CUSTOMER, STANDARD, TWO_MUGS, firstNotes))
.isInstanceOf(ProductsUnavailableException.class);
verify(orders, never()).save(any());
verifyNoInteractions(notes, events, audit);
}The mocked port answers that the mug is not available. The test expects the domain's exception,
and verifyNoInteractions(notes, events, audit) checks that no note, no event and no
audit record was written. For the client behind the port, see the chapter Calling
other services.
The same split works for any decision from outside. Another service, a rules engine or a person gives an answer. The use case fetches it and turns it into a value object. The domain says what the answer means for the aggregate.
The rules of the order service, and where each one isnew#
A new developer asks "where is the rule that an order cannot ship unpaid?". The answer should take one look, not a search. This table lists the rules of the service, with the place of each one:
| Rule | Where it is | Why there |
|---|---|---|
| an order is placed only if the stock service holds every product | Order.place, with the answer from the StockChecker port | the answer comes from outside, but its meaning is a rule of the order |
| an order has 1 to 50 lines | Order.place, with Guard.notEmpty and Guard.maxSize | lines are added only when the order is placed |
| a quantity is from 1 to 999 | OrderItem, with Guard.between | the form of one value |
| a product code, a delivery method code and a currency have a fixed form | ProductCode, DeliveryMethod and Money | the form of one value |
| an amount has at most two decimal places | Money | the form of one value, refused rather than rounded |
| the delivery method is an active lookup value | ReferenceDataService.requireActive, called by OrderService.place | the list is stored data of another feature |
| a line is picked once | OrderLine.pick, called by Order.pickLine | the line's own data, changed only through the root |
| a shipped or cancelled order cannot change | Order.requireNotFinished | the order's own status |
| an order ships only when it is paid and every line is picked | Order.ship | the order's own data |
| an order cannot ship while a note needs action | ShippingPolicy.assertCanShip | the rule needs the order and its notes |
| a repeated or earlier payment changes nothing | Order.recordPayment | the order's own last payment |
| a payment for a finished order is still recorded | Order.recordPayment, on purpose and tested | the money was received, and refunds belong to the payment service |
| a note that needs action is resolved once | Note.resolveAction | the note's own data |
| the agent who wrote a note does not resolve its action | Note.resolveAction, with the actor's ID passed in | the note's own author, compared with a plain value |
| a note under another order is not found | Note.requireBelongsTo | the note's own data, answered with 404 |
| a note is added only to an order that exists | OrderService.requireExists, called by NoteService | the order feature owns its IDs |
| a caller needs a scope to change an order | the @OrdersWrite annotation on each controller method | a permission, checked where users enter |
Most rules are in the aggregate or in a value object. One is in a domain service. Two are in application services, and each of those has a reason in the last column. The check that an order exists is one of them. The order feature owns its IDs, so it checks them for the note feature:
/**
* For other features: checks that an order exists. IDs cross between features as text, and
* the owner of the ID checks it, so a bad ID gives the order feature's own 400 or 404.
*/
public void requireExists(String orderId) {
OrderId id = OrderId.of(orderId);
if (!orders.exists(id)) {
throw new OrderNotFoundException(id);
}
}OrderId.of(orderId) checks the format and answers 400 for a bad ID.
orders.exists(id) asks storage, which is why the check is in the application service.
So the note feature gets the order feature's own 400 or 404. The README of the service
lists the same rules in plain words, so the business can check them.
The lookup rule in an application servicenew#
A client sends an order with the delivery method DRONE. The format is valid, but the
shop has no such method. The list of delivery methods is stored data. A seed migration can add a method, or switch one
off. So the domain value object cannot know the list. The check is in the reference feature's
service:
/**
* Lists lookup values, and checks codes against them for the other features.
*
* <p>The rule "a code must be an active lookup value" is written once, here. Every feature asks
* this service instead of reading the collection. An unknown or inactive code is a 400 that names
* the field, because the client sent a value that is not allowed; it breaks no business rule
* about the order itself.
*/
@Service
public class ReferenceDataService {
// ...
/**
* Throws a 400 for the field unless the code is an active value of the list.
*
* @param field the request field the client must correct, such as {@code deliveryMethod}
*/
public void requireActive(String type, String code, String field) {
boolean known = items.findByTypeOrderByCodeAsc(type).stream()
.anyMatch(item -> item.code().equals(code) && item.active());
if (!known) {
throw new InvalidValueException(field, "is not a known value");
}
}.anyMatch(item -> item.code().equals(code) && item.active());The code must be in the list, and it must be active. An inactive code stays stored, because old orders still use it, but new data may not.
throw new InvalidValueException(field, "is not a known value");The answer is 400, and it names the field. The client sent a value that is not allowed. No rule about the order itself broke, so the answer is not 422.
The order feature calls the check as the first step of placing an order. A test checks that the stock service is never called for an unknown delivery method:
@Test
void checksTheDeliveryMethodBeforeCallingTheStockService() {
doThrow(new InvalidValueException("deliveryMethod", "is not a known value"))
.when(referenceData).requireActive("DELIVERY_METHOD", "STANDARD", "deliveryMethod");
assertThatThrownBy(() -> service.place(CUSTOMER, STANDARD, TWO_MUGS, firstNotes))
.isInstanceOf(InvalidValueException.class);
verifyNoInteractions(stock);
}doThrow makes the mocked reference feature refuse STANDARD.
verifyNoInteractions(stock) then checks that no reservation was made for an order that
can never be placed.
The strict rule. Business rules are in the domain. An application service has
no business if.
What we do. "A code must be an active lookup value" is an if in
ReferenceDataService.requireActive. It throws a domain exception kind,
InvalidValueException, from the application layer, so the client gets a 400 that
names the field. The order feature checks the delivery method, and the note feature checks the
category. Each one calls the method before it builds an aggregate.
Why. The rule checks a value of the input against stored data. It does not check the state of an aggregate. The reference feature is at the simple level, so it has no domain model to put the rule in. The rule is written once, and every feature asks the same method, so every code is checked in the same way. Passing the whole list into the aggregate would add a parameter to each factory, only to test one membership. The list is cached on the repository, so the check does not read the database for every order.
When to stop. Stop when the rule gains a condition about the order, such as
"express delivery only for orders under 10 kilograms". Stop also when a lookup value gets rules of
its own. Then the rule needs the order's data, so it belongs in the domain of the feature that uses
the value. The use case loads what the rule needs, and passes it in as a value, as it passes the
count to ShippingPolicy. The reference list then only supplies data.
The exception kind is allowed here, because the platform's exception kinds are plain Java. An application service may throw them, and the platform answers each kind with its status. For the three kinds, see the section Exceptions by kind, with stable codes. For the simple level of the reference feature, see the section The simple level: the reference feature.
What an anaemic model looks like, and what goes wrongnew#
A model whose entities hold data, with the rules in service classes, is called an anaemic model. For a fair comparison of that design with an outbox, see the section An anaemic model with an outbox, compared fairly. This section shows what the design does to the place of each rule:
public class OrderService(ShopDb db)
{
public async Task ShipAsync(Guid orderId)
{
var order = await db.Orders.FindAsync(orderId) ?? throw new OrderNotFound(orderId);
if (order.Status != OrderStatus.Paid)
throw new OrderNotPaid(orderId);
order.Status = OrderStatus.Shipped;
order.ShippedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
}public void ship(Clock clock) {
requireNotFinished();
if (status != OrderStatus.PAID) {
throw new OrderNotPaidException(id());
}
if (lines.stream().anyMatch(line -> !line.isPicked())) {
throw new UnpickedLinesException(id());
}
status = OrderStatus.SHIPPED;
shippedAt = clock.instant();
raise(new OrderShipped(id(), shippedAt));
}Both panes refuse an unpaid order. In the C# pane, the check is in a service, and the entity has public setters. In the Java pane, the check, the change and the event are one method of the order. Three things go wrong with the anaemic form, and each one grows with the number of callers:
- Anyone can skip the rule. A batch job, an import or a second endpoint can
write
order.Status = OrderStatus.Shipped. The compiler allows it, because the setter is public. In the order service, no setter exists, andArchitectureTest.entitiesHaveNoPublicSetterskeeps it that way. - The event is forgotten. Each service method that ships must also add the
event. A new method can forget it, and no test fails. In the order service,
shipraisesOrderShippeditself. - The time is hidden.
DateTime.UtcNowinside the service reads the system clock. A test cannot fix the time, so it cannot checkShippedAtexactly. In the order service,shipreceives aClock.
These signs in a pull request show that a model is turning anaemic:
- A new public setter appears, or a setter becomes public so that a service can set a value.
- An application service tests an entity's fields with an
if, and then changes that entity. - Code calls
Instant.now()orLocalDate.now()instead of aClock. - A test of a rule needs mocks of repositories.
- The same check appears in two use cases.
Each sign has the same fix. Move the check and the change into a method of the aggregate, and let the use case call that method.
Name a domain service after its rulenew#
A class called OrderDomainService sounds like the right place for any logic about
orders. So it collects all the logic that has no obvious home. After a year, it holds a discount
calculation, a date helper and three rules that belong in the aggregate. The order service names
its domain service after the one rule that it checks:
/**
* The rule that needs two aggregates: an order cannot ship while a note on it needs action.
*
* <p>It is a domain service because neither aggregate holds the data alone. It is named after
* the rule, not {@code OrderDomainService}, so unrelated code does not collect in it. It takes a
* number, not the notes, so the order feature never sees note objects. It has no state, no
* repository and no Spring annotation, and the order feature's configuration creates it.
*
* <p>The guarantee holds only at the moment of shipping. An agent may add a note that needs
* action a second after the order shipped. The service accepts that, and the README records the
* decision.
*/The comment of ShippingPolicy gives each choice and its reason. It names
OrderDomainService as the name to avoid. The name of the class states the rule, so unrelated code has no reason to go there. It takes a number, so the order feature never
sees note objects. It has no state, no repository and no Spring annotation, so it stays plain
Java.
A name says what the class decides or computes. These forms keep the class small:
| Name | What it says |
|---|---|
| ShippingPolicy | it decides whether something is allowed, here shipping |
| a name ending in Calculator, such as a delivery charge calculator | it computes a value from the values it receives |
| a name ending in Checker or Validator | avoid it for a domain service, because it hides which rule is checked |
| OrderDomainService, OrderHelper or OrderManager | avoid it, because it names no rule and attracts any code about orders |
When a second rule across aggregates appears, write a second class with its own name. Two small policies are easier to find and to test than one service with two unrelated methods.
1An expert asksWhy does ShippingPolicy.assertCanShip take the number of notes that
need action, and not the list of notes?
ShippingPolicy.assertCanShip take the number of notes that
need action, and not the list of notes?2An expert asksWhy is "no note needs action" not an if in
OrderService.ship?
if in
OrderService.ship?3A new rule says that an order may hold at most 50 lines. Lines are added only when the order is
placed. Where does the check go, and why there?
Order.place, with Guard.maxSize. The rule needs only the order's
own data, so it belongs in the aggregate. Every path that creates an order meets it. A check in the
controller or in one use case would miss every other path.4What is the business-expert test for a line in an application service?
5TranslateWrite this C# exception for the shipping rule in the style of the order
service. It extends the platform's kind for broken rules:
public class OrderHasOpenActionsException(OrderId id, long notesNeedingAction)
: BusinessRuleViolationException("ORDER_HAS_OPEN_ACTIONS",
$"Order {id} cannot ship while {notesNeedingAction} note(s) need action");
public class OrderHasOpenActionsException(OrderId id, long notesNeedingAction)
: BusinessRuleViolationException("ORDER_HAS_OPEN_ACTIONS",
$"Order {id} cannot ship while {notesNeedingAction} note(s) need action");public class OrderHasOpenActionsException extends BusinessRuleViolationException {
public OrderHasOpenActionsException(OrderId id, long notesNeedingAction) {
super("ORDER_HAS_OPEN_ACTIONS", "Order " + id + " cannot ship while " + notesNeedingAction
+ " note(s) need action");
}
}The Java class declares its constructor in its body, and the constructor calls
super. The code is a stable text that clients may test, and the platform answers the
exception with 422.
Wiring the service: the container in depth#
At startup, Spring creates every object of the order service and connects each one to the objects it needs. These objects are called beans. This chapter follows how the service uses the container. It covers where the beans come from and how they are injected. It also covers missing beans, a cycle between two features, work at startup, and proxies.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | IServiceProvider, built from the IServiceCollection | ApplicationContext | the object that creates and holds every bean, and that a test can ask for a bean Why |
| twist | builder.Services.AddScoped<OrderService>() for each class | @Service on the class, found by scanning from OrderServiceApplication | Spring searches the package of the application class and every package below it Why |
| twist | builder.Services.AddShopPlatform() from a NuGet package | auto-configuration in shop-platform | the platform's packages are not below com.acme.orderservice, so scanning never finds them Why |
| twist | a primary constructor with nine parameters | one constructor with nine parameters, and no annotation | Spring passes a bean for each parameter, and a unit test passes mocks with new Why |
| twist | AddScoped<IOrderRepository, OrderPersistenceAdapter>() | @Component on OrderPersistenceAdapter, which implements OrderRepository | Spring finds the one bean that implements the interface, so no line maps one to the other Why |
| twist | AddSingleton(sp => new ShippingPolicy()) | a @Bean method in OrderConfig | the domain class carries no Spring annotation, so a configuration class of the feature creates it Why |
| new | no equivalent | @Configuration(proxyBeanMethods = false) | Spring does not subclass the class, so a call from one @Bean method to another creates a second object Why |
| twist | a private nested class that implements the interface | new WebMvcConfigurer() { ... } in a @Bean method | a Java anonymous class implements an interface inline, and C# has no such expression Why |
| twist | TryAddSingleton(TimeProvider.System) in the library | @Bean @ConditionalOnMissingBean Clock clock() | the platform's bean backs off when the service or a test declares its own Why |
| twist | sp.GetService<T>() returns null when nothing is registered | ObjectProvider<T>.getIfAvailable() | it returns null when no bean exists, and getObject() throws instead Why |
| trap | GetRequiredService<T>() with two registrations returns the last one | ObjectProvider<T>.getObject() with two beans throws | Spring refuses to choose between two candidates, so a second bean breaks the lookup Why |
| twist | IEnumerable<IIntegrationEventMapper> | ObjectProvider<IntegrationEventMapper>.orderedStream().toList() | every bean of the type, in the order of @Order, and an empty list when there is none Why |
| twist | new TransactionScope() around the code | an injected TransactionTemplate | Boot adds the bean once a transaction manager exists, and the platform provides that manager Why |
| twist | no equivalent built into the container | ApplicationEventPublisher.publishEvent(event) | the application context itself is the publisher, and Spring Modulith listens to it Why |
| twist | InvalidOperationException: A circular dependency was detected, at the first resolve | the cycle report at startup | both containers refuse two classes whose constructors need each other Why |
| new | Lazy<OrderService>, which needs a registration of its own | @Lazy on the constructor parameter | Spring injects a proxy that finds the real bean at its first call Why |
| new | IHostedService.StartAsync, before the server listens | SmartInitializingSingleton.afterSingletonsInstantiated() | the migrations run after every singleton exists, and before the web server and the queue listeners start Why |
| new | a decorator class that you register yourself | the proxies of @Transactional, @PreAuthorize and @Cacheable | Spring wraps the bean, and every other bean receives the wrapper Why |
| trap | a call to this.Method() inside a decorated class | a call to this.method() inside a proxied bean | the call skips the proxy, so the service puts the cache on the repository that it calls Why |
| new | no equivalent | ApplicationWiringTest | the whole container starts with every setting, and nothing connects to anything Why |
The container and the beans of the order servicetwist#
For the order of these steps in a real start, with the time each one took, see the chapter Starting the service: from main() to the first request.
For the basics of beans and injection, see the chapter Dependency injection and configuration. This chapter starts where that chapter stops. It shows the container as the order service uses it.
An object that Spring creates and connects to other objects is called a
bean. The object that creates and holds the beans is called
the application context. Its interface is
ApplicationContext, in the package
org.springframework.context. It has
the same purpose as the IServiceProvider of ASP.NET Core.
The beans of the order service come from three places.
Component scanning finds the classes that carry
an annotation such as @Service. The
@Bean methods of a feature create the objects that carry no
annotation. And auto-configuration adds the beans
of the libraries:
A test can ask the context for a bean. The wiring test of the service does this to check two beans that the platform provides:
@Autowired
ApplicationContext context;
@Test
void theContextStartsWithThePlatformsBeans() {
assertThat(context.getBean(PlatformTransactionManager.class)).isInstanceOf(MongoTransactionManager.class);
assertThat(context.getBeansOfType(SecurityFilterChain.class)).containsOnlyKeys("platformSecurityFilterChain");
}@AutowiredA test class has no constructor that Spring calls. So the test asks for the context with
@Autowiredon a field. Production code takes its beans through the constructor instead.context.getBean(PlatformTransactionManager.class)getBeanreturns the one bean of a type, likeGetRequiredService<T>()in ASP.NET Core. Production code never calls it. Only a test or the container itself looks up beans by type.context.getBeansOfType(SecurityFilterChain.class)This method returns every bean of a type, in a map from bean name to bean. The test checks that only the platform's filter chain exists.
Every bean that the order service declares is a singleton, which is the default in Spring. One object serves every request, so no bean keeps the data of a request in a field. For the scopes, see the section Lifetimes. The quick reference at the end of this chapter maps each container idea to its ASP.NET Core match.
Where the beans come from: scanning and auto-configurationtwist#
The service starts from one class:
/**
* Starts the order service, like {@code Program.cs} in ASP.NET Core.
*
* <p>{@code @SpringBootApplication} scans this package and every package below it for
* components. The platform's beans come from its auto-configuration instead, because the
* platform's packages are not below this one.
*/
@SpringBootApplication
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}@SpringBootApplication combines three annotations. One of them is
@ComponentScan, which starts component
scanning in the package com.acme.orderservice. Spring then finds every class below
that package that carries a stereotype. A stereotype is an annotation that marks a class as a
bean. The order service uses these stereotypes:
| Stereotype | Classes in the order service | Why this one |
|---|---|---|
| @Service | OrderService, NoteService, ReferenceDataService | the use cases of each feature |
| @Component | OrderPersistenceAdapter, StockAdapter, PaymentReceivedListener, OrderMetrics | an adapter or a listener, which is neither a use case nor a controller |
| @RestController | OrderController, NoteController, ReferenceDataController | Spring MVC sends HTTP requests to these classes |
| @Configuration | OrderConfig, StockClientConfig, ReferenceConfig, OpenApiConfig | a class that holds @Bean methods or switches a feature on |
Several of these classes are package-private, such as class OrderPersistenceAdapter
with no public. Spring creates them all the same. Code in another package cannot use
a package-private class. So the compiler also checks some of the layer rules. For those rules, see
the chapter Features, layers and the rules.
The other annotation that matters here is @EnableAutoConfiguration. It makes Boot
read the auto-configuration classes that each library lists in its
Java archive (JAR). The platform library lists
eight classes:
com.acme.platform.core.PlatformCoreAutoConfiguration
com.acme.platform.core.PlatformWebAutoConfiguration
com.acme.platform.core.PlatformSecurityAutoConfiguration
com.acme.platform.core.openapi.PlatformOpenApiAutoConfiguration
com.acme.platform.mongo.PlatformMongoAutoConfiguration
com.acme.platform.mongo.PlatformMongoAuditAutoConfiguration
com.acme.platform.mongo.PlatformMongoWebAutoConfiguration
com.acme.platform.messaging.PlatformMessagingAutoConfigurationThe platform's classes are in com.acme.platform. That package is not below
com.acme.orderservice, so scanning never finds them. The file above is the only way
in. This is deliberate. Spring checks a condition such as "only if the service has no such bean"
against the beans that it has processed so far. Boot guarantees that auto-configuration is
processed after every bean definition of the service. So the Boot reference recommends bean
conditions only on auto-configuration classes. For how the platform writes these classes, see the chapter
Writing the platform library.
Each way to register a bean has its place:
| Way | Use it for | In the order service |
|---|---|---|
| a stereotype on the class | your own classes | the services, adapters, controllers and listeners |
| a @Bean method in a @Configuration class | a class that must not carry a Spring annotation, or that needs settings to build | ShippingPolicy, the stock HTTP client |
| auto-configuration | a library that adds beans to every service, with conditions | the clock, the filter chain, the transaction manager, the outbox |
In ASP.NET Core, a library usually offers an extension method such as
AddShopPlatform(), and Program.cs calls it. A Spring service writes no such
line. Adding the library to the Project Object Model (POM)
file is enough. For the first scanning trap, a class in a sibling
package, see the section Which packages are scanned.
Constructor injection, with nine dependenciestwist#
public class OrderService(
IOrderRepository orders, NoteService notes,
ReferenceDataService referenceData, IStockChecker stock,
ShippingPolicy shippingPolicy, IDomainEventPublisher events,
IAuditLog audit, ITransactionRunner transaction,
TimeProvider clock)
{
}@Service
public class OrderService {
private final OrderRepository orders;
private final NoteService notes;
private final ReferenceDataService referenceData;
private final StockChecker stock;
private final ShippingPolicy shippingPolicy;
private final DomainEventPublisher events;
private final AuditLog audit;
private final TransactionTemplate transaction;
private final Clock clock;
public OrderService(OrderRepository orders, NoteService notes, ReferenceDataService referenceData,
StockChecker stock, ShippingPolicy shippingPolicy, DomainEventPublisher events,
AuditLog audit, TransactionTemplate transaction, Clock clock) {
this.orders = orders;
this.notes = notes;
this.referenceData = referenceData;
this.stock = stock;
this.shippingPolicy = shippingPolicy;
this.events = events;
this.audit = audit;
this.transaction = transaction;
this.clock = clock;
}public OrderService(OrderRepository orders, NoteService notes, ReferenceDataService referenceData,The class has one constructor, so Spring uses it with no annotation. Spring finds a bean for each of the nine parameters, creates those beans first, and then calls the constructor.
private final OrderRepository orders;Each dependency is a final field, set once in the constructor. Java has no primary constructors, so each field is written out.
AuditLog audit, TransactionTemplate transaction, Clock clock) {Four of the nine beans come from the platform or from Boot. They are the event publisher, the audit log, the transaction template and the clock. The other five belong to the service.
The C# pane names its own interfaces, such as ITransactionRunner, because .NET has
no matching built-in types. Each one has the role of the Java type at the same place in the
list.
The unit test builds the same object with new, and no Spring context starts:
private final OrderService service = new OrderService(orders, notes, referenceData, stock, new ShippingPolicy(),
events, audit, new TransactionTemplate(new NoOpTransactionManager()), CLOCK);Each argument except two is a Mockito mock. new ShippingPolicy() is the real
domain service, and the transaction template runs on a manager that does nothing. For this test
in full, with the tests of every other use case, see the
section How each use case is tested.
Nine parameters is a lot. A long constructor is a useful signal, because it shows every dependency in one place. When a service needs more, it usually holds too many use cases. For how to split it, see the section Splitting a large service.
Injecting an interface: the port and its adaptertwist#
OrderService asks for an OrderRepository. That type is an interface of
the domain, and the persistence package implements it:
builder.Services
.AddScoped<IOrderRepository, OrderPersistenceAdapter>();
internal class OrderPersistenceAdapter : IOrderRepository
{
}@Component
class OrderPersistenceAdapter extends MongoAggregateRepository<Order, OrderId, OrderDocument> implements OrderRepository {
private static final Sort NEWEST_FIRST = Sort.by(Sort.Direction.DESC, "placedAt");
private final OrderMongoRepository orderDocuments;
OrderPersistenceAdapter(OrderMongoRepository orderDocuments, OrderDocumentMapper mapper) {
super(orderDocuments, mapper);
this.orderDocuments = orderDocuments;
}@ComponentThe adapter is a bean. Spring registers it under all its types, so a request for
OrderRepositoryfinds it. No line maps the interface to the class, asAddScoped<IOrderRepository, OrderPersistenceAdapter>()does in C#.class OrderPersistenceAdapter extends MongoAggregateRepository<Order, OrderId, OrderDocument> implements OrderRepository {The class extends the platform's base class for stored aggregates, and implements the domain's port. For the base class, see the chapter Storing aggregates in DocumentDB.
OrderPersistenceAdapter(OrderMongoRepository orderDocuments, OrderDocumentMapper mapper) {The constructor is package-private too. Spring can still call it.
The same happens for the port StockChecker. StockAdapter implements it,
and the use case never knows that the answer comes over HTTP.
The rule is one bean for each port. If a second class implemented OrderRepository,
Spring would stop at startup with NoUniqueBeanDefinitionException. ASP.NET Core would
inject the last registration, with no error. For the ways to choose between two beans, see the section
Multiple implementations.
A configuration class for the plain-Java domaintwist#
builder.Services.AddSingleton(sp => new ShippingPolicy());@Configuration(proxyBeanMethods = false)
class OrderConfig {
@Bean
ShippingPolicy shippingPolicy() {
return new ShippingPolicy();
}
}ShippingPolicy is the domain service that checks the rule across an order and its
notes. The order feature keeps its domain in plain Java, with no Spring annotation. An
architecture rule, ArchitectureRules.domainIsPlainJava(), fails the build if a domain
class imports Spring. So ShippingPolicy cannot carry @Component, and the
feature declares it in OrderConfig instead. The @Bean method returns the
object, and Spring makes it a bean named shippingPolicy.
The stock client needs settings to build, so it comes from a configuration class too:
@Configuration(proxyBeanMethods = false)
class StockClientConfig {
/** Binds the platform's reusable properties class to this client's prefix. */
@Bean
@ConfigurationProperties("clients.stock")
HttpClientProperties stockClientProperties() {
return new HttpClientProperties();
}
@Bean
StockHttpClient stockHttpClient(HttpClientFactory clients, HttpClientProperties stockClientProperties) {
return clients.create(StockHttpClient.class, stockClientProperties);
}
}@ConfigurationProperties("clients.stock")On a
@Beanmethod, this annotation fills the returned object from the settings underclients.stock. For this binding, see the section One properties class, bound once for each client.StockHttpClient stockHttpClient(HttpClientFactory clients, HttpClientProperties stockClientProperties) {Spring injects the parameters of a
@Beanmethod, as it does for a constructor.HttpClientFactorycomes from the platform. The second parameter is the bean that the method above creates, which Spring finds by its type.return clients.create(StockHttpClient.class, stockClientProperties);The factory builds a declarative HTTP client for the interface
StockHttpClient. For the client, see the chapter Calling other services.
The two ways to make a domain class a bean, side by side:
| Way | What it costs | When it is the better choice |
|---|---|---|
| @Bean in the feature's configuration class | one more class, and a reader must look in two places | the full style, where the domain stays plain Java and is tested with new |
| @Component on the domain class | the domain imports Spring, and the architecture rule must allow it | the light style, where the note entity already carries Spring Data annotations |
The second way is simpler, and a careful team can use it well. One annotation is easy to read,
and Spring does nothing else to the class. The order feature still uses @Bean, because
its domain must compile and test with no framework. That keeps the rules of the business apart from
the rules of the framework. For the levels of Domain-Driven Design (DDD) in this service, see the chapter
How much DDD: three levels in one service.
proxyBeanMethods = falsenew#
Every configuration class of the order service writes
@Configuration(proxyBeanMethods = false). The setting changes how Spring treats the
class itself, so it is worth knowing what it switches off.
By default, Spring does not use a @Configuration class as it is. It creates a
subclass at runtime, with the Code Generation Library (CGLIB).
The subclass intercepts each @Bean method. When one @Bean method calls
another one directly, the subclass returns the bean that already exists. So a direct call still
gives the one shared singleton.
proxyBeanMethods = false switches the subclass off. Spring then calls each
@Bean method as a plain factory method. The Spring javadoc calls this "@Bean Lite
Mode". It says that the mode works as if the class had no @Configuration at all.
A run for this book on Spring Framework 7.0.10 showed the difference. A configuration class
had two @Bean methods, and the second one called the first one directly:
| Setting | The class Spring used | The object that the direct call returned |
|---|---|---|
| proxyBeanMethods = true, the default | Run$FullConfig$$SpringCGLIB$$0, a subclass | the same bean that the context holds |
| proxyBeanMethods = false | Run$LiteConfig, the class itself | a second object, which the context does not hold |
In lite mode, a call from one @Bean method to another creates a second
object. The code compiles, and nothing warns you. Take the other bean as a parameter
instead, as stockHttpClient does with stockClientProperties. A parameter
works in both modes.
The order service turns the subclass off for two reasons. No @Bean method of the
service calls another one, so the subclass would do nothing useful. And without the subclass,
Spring does less work at startup. The class comment of OrderConfig says the same.
Every auto-configuration of the platform is in lite mode too. The annotation
@AutoConfiguration is itself marked with
@Configuration(proxyBeanMethods = false), and its javadoc says that the setting is
always false. The nested configuration inside the messaging auto-configuration writes the setting
again, because it is a plain @Configuration:
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(name = "platform.messaging.outbox.resend-enabled", havingValue = "true", matchIfMissing = true)
@EnableScheduling
static class OutboxResendConfiguration {A nested configuration class must be static, as the javadoc of
@Configuration requires.
Keep the default only when your @Bean methods call each other on purpose. Older
code does this often, and it is correct there. A factory
lambda in ASP.NET Core has no such
mechanism. It is always a plain call.
A bean written inline: an anonymous classtwist#
// C# has no anonymous class that implements an interface,
// so the implementation needs a named class.
internal sealed class MdcMvcOptions(PathVariableMdcFilter filter)
: IConfigureOptions<MvcOptions>
{
public void Configure(MvcOptions options) =>
options.Filters.Add(filter);
}
builder.Services
.AddSingleton<IConfigureOptions<MvcOptions>, MdcMvcOptions>();@Bean
WebMvcConfigurer platformMdcWebMvcConfigurer(PlatformLoggingProperties properties) {
PathVariableMdcInterceptor interceptor = new PathVariableMdcInterceptor(properties.getMdcPathVariables());
return new WebMvcConfigurer() {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(interceptor);
}
};
}return new WebMvcConfigurer() {This expression creates an object of a class that has no name. The class implements
WebMvcConfigurer, and its body follows in the braces. Such a class is called an anonymous class. In C#,new IDisposable { }does not compile, with error CS0144.public void addInterceptors(InterceptorRegistry registry) {WebMvcConfigureris the hook of Spring model-view-controller (MVC) for code that changes its setup. It has many methods, each with a default body. The anonymous class overrides only the one it needs. Spring MVC calls everyWebMvcConfigurerbean at startup, and this one adds the interceptor that puts path variables into the mapped diagnostic context (MDC).registry.addInterceptor(interceptor);The anonymous class reads
interceptor, a local variable of the method. Java allows this only for a variable that never changes after it is set.
The C# pane shows the closest shape, a named class that adds an MVC filter. The point is that C# needs a named class where Java writes the class inline.
The companion uses anonymous classes in two more places. ArchitectureRules writes
each custom rule as new ArchCondition<>(...) { ... }. And a test creates an
exception whose constructor is protected, in one line:
// NotFoundException is non-sealed with a protected constructor, so a test can subclass it.
doThrow(new NotFoundException("ORDER_NOT_FOUND", "Order not found") {
}).when(orders).requireExists(ORDER_ID);The empty braces after the constructor call make an anonymous subclass of
NotFoundException. Its constructor is protected, so a test in another package cannot
call new NotFoundException(...) alone. An anonymous subclass can call it. Use an anonymous class for a short implementation that one place needs.
When the body grows past a few lines, give it a name. A lambda is shorter still, but only for an
interface with one abstract method. For lambdas, see the section
Lambdas and method references.
Defaults that a service can replace: the clocktwist#
// in the library's AddShopPlatform()
services.TryAddSingleton(TimeProvider.System);/** The clock that the domain reads the time from. Code never calls {@code Instant.now()}. */
@Bean
@ConditionalOnMissingBean
Clock clock() {
return Clock.systemUTC();
}@ConditionalOnMissingBeanSpring creates this bean only if no other bean of the type
Clockexists. With no type in the brackets, the condition uses the return type of the method.return Clock.systemUTC();java.time.Clockgives the current time.systemUTC()reads the system clock in Coordinated Universal Time (UTC). The type has the same purpose asTimeProviderin .NET 8.
The order domain never reads the time by itself. Each method that needs the time takes the clock
as a parameter, as in order.ship(clock). A test passes a fixed clock, so the times in
its events are known in advance. For Clock in the domain, see the chapter
The domain model in plain Java.
The condition is how a service or a test replaces a platform bean. Every bean of
PlatformCoreAutoConfiguration carries it. A service that declares its own
Clock bean gets that one, and the platform's method never runs.
TryAddSingleton is like @ConditionalOnMissingBean, but it depends on
the order of the registrations. A run on .NET 10 showed both orders:
| Registrations, in order | What GetRequiredService<TimeProvider>() returned |
|---|---|
| the service's AddSingleton, then the library's TryAddSingleton | the service's clock, and TryAdd added nothing |
| the library's TryAddSingleton, then the service's AddSingleton | the service's clock, but both registrations stay in the collection |
In Spring, the order of your own code does not matter. Auto-configuration always runs after the service's own beans are known, so the condition sees them. The order between two auto-configurations does matter. For that, see the chapter Writing the platform library.
ObjectProvider: a bean that may be missing, or new each timenew#
The platform builds clients for other services with an HttpClientFactory. The factory
needs two things that a plain constructor parameter cannot give. It needs a new Boot
RestClient.Builder for each client. And it needs the client registrations of Spring
Security, but only if the service has any.
A parameter of type ObjectProvider<T> solves both. An
ObjectProvider does not hold a bean. It finds the bean when the code asks, and the code
decides what to do when there is none:
@Bean
@ConditionalOnMissingBean
HttpClientFactory httpClientFactory(ObjectProvider<RestClient.Builder> restClientBuilders,
ObjectProvider<ClientRegistrationRepository> registrations,
ObjectProvider<OAuth2AuthorizedClientService> authorizedClients) {
Supplier<OAuth2AuthorizedClientManager> tokenManager = () -> {
ClientRegistrationRepository repository = registrations.getIfAvailable();
if (repository == null) {
throw new IllegalStateException("A client asks for a token, but no "
+ "spring.security.oauth2.client.registration is configured");
}
return new AuthorizedClientServiceOAuth2AuthorizedClientManager(repository, authorizedClients.getObject());
};
return new HttpClientFactory(restClientBuilders::getObject, tokenManager);
}ObjectProvider<RestClient.Builder> restClientBuilders,ObjectProvideris in the packageorg.springframework.beans.factory. The type argument is the bean it finds. Spring injects the provider at once, even when no such bean exists.Supplier<OAuth2AuthorizedClientManager> tokenManager = () -> {A
Supplier<T>is a function with no parameters that returns a value, likeFunc<T>in C#. The lambda runs only when a client asks for tokens. So a service with no client registrations still starts.ClientRegistrationRepository repository = registrations.getIfAvailable();getIfAvailable()returns the bean, ornullwhen there is none. The factory then throws an error with a clear message, at the moment a client needs a token.authorizedClients.getObject()getObject()returns the bean, and throws when there is none. Here the bean must exist, because Boot creates it together with the registrations.restClientBuilders::getObjectThis is a method reference. It turns the method
getObjectof this provider into aSupplier, which the factory calls once for each client. Boot'sRestClient.Builderbean is a prototype, so each call returns a new builder. One client's settings then never leak into another client.
A run for this book recorded what each method of ObjectProvider does:
| Method | No bean | One bean | Two beans |
|---|---|---|---|
| getObject() | throws NoSuchBeanDefinitionException | the bean | throws NoUniqueBeanDefinitionException |
| getIfAvailable() | null | the bean | throws NoUniqueBeanDefinitionException |
| getIfAvailable(supplier) | the supplier's value | the bean | throws NoUniqueBeanDefinitionException |
| getIfUnique() | null | the bean | null |
| orderedStream() | an empty stream | the bean | both, in the order of @Order |
| stream() | an empty stream | the bean | both, in the order of registration |
The closest match in ASP.NET Core is to inject IServiceProvider and call
GetService<T>(). It also returns null when nothing is registered.
GetRequiredService<T>() throws
InvalidOperationException: No service for type ... has been registered. The two
containers differ when there are two candidates.
Spring refuses to choose between two beans. In a run on .NET 10, two
registrations of one interface made GetRequiredService return the last one. In Spring,
getObject() and a constructor parameter both throw
NoUniqueBeanDefinitionException. So a second bean of a type that the platform looks up
breaks the lookup. Mark one bean @Primary, or ask for all of them, as the next section
does.
Use ObjectProvider only where its extra behaviour is needed. An ordinary dependency
is a constructor parameter, which shows the dependency and fails at startup when it is missing. An
ObjectProvider fails later, at the call, and only if the code asks for a bean that is
not there.
Every implementation of an interfacetwist#
public class OutboxDomainEventPublisher(
IEnumerable<IIntegrationEventMapper> mappers)
{
private readonly List<IIntegrationEventMapper> _mappers =
mappers.ToList();
}@Bean
@ConditionalOnMissingBean(DomainEventPublisher.class)
OutboxDomainEventPublisher domainEventPublisher(ApplicationEventPublisher publisher,
ObjectProvider<IntegrationEventMapper> mappers) {
return new OutboxDomainEventPublisher(publisher, mappers.orderedStream().toList());
}ObjectProvider<IntegrationEventMapper> mappers) {Each feature that publishes events to other services has one
IntegrationEventMapper. The order feature hasOrderIntegrationEventMapper, and the note feature has none.mappers.orderedStream().toList()orderedStream()returns every mapper bean, sorted by@Orderwhen a bean has one.toList()collects them into a list that cannot change. A service with no mapper gets an empty list.
The migration runner collects its migrations the same way:
MongoMigrationRunner mongoMigrationRunner(MongoTemplate mongo, ObjectProvider<MongoMigration> migrations, Clock clock,
@Value("${platform.mongo.migrations.lock-ttl:PT15M}") Duration lockTtl) {
return new MongoMigrationRunner(mongo, migrations.orderedStream().toList(), clock, lockTtl);
}Each migration of the service, such as V001CreateIndexes, is a
@Component that implements MongoMigration. The runner sorts them by their
ID itself, so the order of the stream does not matter there. For the runner, see the chapter
Migrations, indexes and seed data.
A List<IntegrationEventMapper> parameter would also work. A run on Spring
Framework 7.0.10 checked the case with no bean. The List parameter received an empty
list, and orderedStream() gave an empty stream. Both follow @Order. The
platform uses the provider, as its other lookups do. In your own code, prefer the plain
List parameter. It is shorter, and a reader sees the dependency at once. For
List and Map parameters, see the section
Injecting every implementation: List and Map.
IEnumerable<T> in ASP.NET Core gives the registrations in registration order.
Spring sorts a List parameter and orderedStream() by @Order.
So give the beans an @Order when their order matters.
The TransactionTemplate beantwist#
using var scope = new TransactionScope(
TransactionScopeAsyncFlowOption.Enabled);
var saved = SaveAndPublish(order);
var noteIds = notes.AddNotes(saved.Id, firstNotes);
scope.Complete();return transaction.execute(status -> {
Order order = Order.place(orderId, customer, deliveryMethod, items, availability, clock);
Order saved = saveAndPublish(order, AuditActions.ORDER_PLACED); // aggregate 1
List<String> noteIds = notes.addNotes(saved.id().value(), firstNotes); // aggregate 2, same transaction
return new PlacedOrder(saved.id().value(), noteIds);
});return transaction.execute(status -> {transactionis the injectedTransactionTemplate, from the packageorg.springframework.transaction.support.executestarts a transaction, runs the lambda, and commits. It returns the value of the lambda.List<String> noteIds = notes.addNotes(saved.id().value(), firstNotes); // aggregate 2, same transactionaddNotesis a@Transactionalmethod of another bean. It joins the transaction that the template started.
Nobody in the order service declares a TransactionTemplate. Boot adds a bean named
transactionTemplate as soon as a transaction manager exists. A run for this book
showed it. Without a manager, the context held no template at all.
Boot creates no transaction manager for MongoDB. The platform declares one, before Boot's transaction setup runs:
@Bean
@ConditionalOnMissingBean(TransactionManager.class)
MongoTransactionManager transactionManager(MongoDatabaseFactory databaseFactory) {
return new MongoTransactionManager(databaseFactory);
}So the template and every @Transactional method use the same
MongoTransactionManager. The wiring test checks that this manager is the one in the
context. A run for this book read the settings of Boot's template. They are the defaults, with
propagation REQUIRED, the default isolation and no timeout.
One difference from @Transactional matters. The template rolls back on any
exception that leaves the lambda, also a checked one. @Transactional commits on a
checked exception by default. For both, see the
chapter Transactions in depth. For why place uses the template,
see the section Placing an order: the stock call before the
transaction.
ApplicationEventPublisher: the container as an event bustwist#
The platform's outbox publishes each domain event through Spring's in-process event bus. Spring Modulith listens on that bus and stores each event with the change. The publisher is a plain constructor parameter:
private final ApplicationEventPublisher publisher;
private final List<IntegrationEventMapper> mappers;
public OutboxDomainEventPublisher(ApplicationEventPublisher publisher, List<IntegrationEventMapper> mappers) {
this.publisher = publisher;
this.mappers = List.copyOf(mappers);
}
@Override
public void publish(List<DomainEvent> events) {
if (events.isEmpty()) {
return;
}
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
throw new IllegalStateException(
"Publish domain events inside the use case's transaction, so they are stored with the change");
}
for (DomainEvent event : events) {
publisher.publishEvent(event);
mappers.stream()
.map(mapper -> mapper.toIntegrationEvent(event))
.flatMap(Optional::stream)
.forEach(publisher::publishEvent);
}
}private final ApplicationEventPublisher publisher;ApplicationEventPublisheris in the packageorg.springframework.context. Any bean can ask for it. In a run on Spring Framework 7.0.10, the injected publisher was the application context itself.publisher.publishEvent(event);publishEvent(Object)gives the event to every listener of its type. The event can be any object, here a domain event record of the order feature..flatMap(Optional::stream)Each mapper returns an
Optional, which is empty for an event that the mapper does not publish.Optional::streamis a method reference. It turns each optional into a stream of zero or one element, soflatMapkeeps only the integration events..forEach(publisher::publishEvent);Another method reference, this time to a method of one object. It means the same as
e -> publisher.publishEvent(e).
The class is plain Java with no annotation, and the platform's @Bean method creates
it. Its constructor takes a List, so a unit test passes a list and a mock publisher.
The bean method converts the provider to that list.
The domain itself never sees ApplicationEventPublisher. Use cases call the
platform's port DomainEventPublisher, and only this adapter knows Spring.
ASP.NET Core has no event bus in its container. The closest match is the library MediatR. A run
for this book showed that its Publish runs the handlers on the caller's thread. A
failing handler fails the caller, as with publishEvent. For the listeners, the outbox and the transaction check,
see the chapter Publishing events: the outbox. For plain application events,
see the section Application events.
The cycle between the order and note featurestwist#
Two use cases make the order and note features call each other. To ship an order,
OrderService asks NoteService how many notes need action. To add a note,
NoteService asks OrderService whether the order exists. So each constructor
needs the other bean:
public class OrderService(NoteService notes) { }
public class NoteService(OrderService orders) { }
// At the first GetRequiredService<OrderService>():
// InvalidOperationException: A circular dependency
// was detected for the service of type 'OrderService'.public NoteService(NoteRepository notes, @Lazy OrderService orders, ReferenceDataService referenceData,
DomainEventPublisher events, AuditLog audit, CurrentActor currentActor, Clock clock) {
this.notes = notes;
this.orders = orders;
this.referenceData = referenceData;
this.events = events;
this.audit = audit;
this.currentActor = currentActor;
this.clock = clock;
}Without @Lazy, Spring cannot create either bean first. Boot refuses such a cycle by
default and stops at startup with a report that starts with this line:
The dependencies of some of the beans in the application context form a cycle:A box diagram follows the words form a cycle:, and it names each bean in the loop. In a run for this book, a cycle of
constructors, a cycle of setters and a bean that injected itself all failed this way. The setting
spring.main.allow-circular-references is false by default.
The two containers fail at different times. In a run on .NET 10, building the provider worked,
and the first resolve threw. With ValidateOnBuild set, the build failed instead. Spring
creates every singleton at startup, so the cycle always shows when the service starts. For the full
report and the old behaviour, see the section
Circular dependencies.
@Lazy: a proxy that breaks the cycle, and the other ways outnew#
The note service marks its OrderService parameter with @Lazy, from the
package org.springframework.context.annotation. On a parameter, the annotation does not
delay the bean. It makes Spring inject a stand-in object. Such an object is called a
proxy.
The javadoc of @Lazy calls it a lazy-resolution proxy. It finds the real bean at the first call, and keeps it for a
singleton. In a run on Spring Framework 7.0.10, a test class Orders was
injected as Run$Orders$$SpringCGLIB$$0, and a call through it reached the real
bean.
The javadoc also names the cost. The proxy is always injected, even if the real bean does not exist. So a missing bean shows only as an exception at the first call, not at startup. In the order service the bean always exists, and the wiring test starts the context with both services.
ASP.NET Core has no built-in form. A run on .NET 10 showed that a constructor parameter of type
Lazy<OrderService> fails with Unable to resolve service for type
'System.Lazy`1[OrderService]'. It works only after you register Lazy<T>
yourself.
A cycle is a warning about the design, not about the container. Two features that call each other cannot change, be tested or move to another service apart. These are the ways out, from the container's side:
| Way out | What it gets right | What it costs |
|---|---|---|
| @Lazy on one parameter | one annotation, and the constructor still lists the dependency | the cycle stays, and a missing bean fails only at the first call |
| ObjectProvider<OrderService> in NoteService | the code says plainly that it looks the bean up later | the same cycle, with more code at each call |
| one dependency made one-way: the note controller calls requireExists | no cycle, and the note service needs nothing from orders | a caller of NoteService.addNote must remember the check |
| a third bean that both features use | no cycle, if the shared question has its own name | one more class, and here the questions belong to their features |
| an event in place of one call | no compile-time dependency at all | only for "something happened", never for a check that must answer now |
| spring.main.allow-circular-references=true | nothing in new code | it allows cycles of setters and fields again, and hides every new cycle |
The book recommends the one-way dependency for a new service. It removes the cycle at the cost of one line in a controller. The note feature's check that the order exists then moves to each entry point, which is a risk when a second entry point arrives. For the design side of these ways out, see the section Two features that call each other.
The strict rule. Dependencies between features go one way. A cycle means that the two features are one feature, or that a third concept is missing.
What we do. OrderService and NoteService depend on each
other, and @Lazy on one constructor parameter lets the container create them. The
README records the cycle and the ways out.
Why. Each call asks the owner of the data. The note service checks the order through the order feature, and the order feature counts notes through the note feature. Each check stays in one place, and the architecture rule still allows only calls through the application package.
When to stop. Stop when a third feature joins the loop. Stop also when one of the two features must move to its own service. Then make one dependency one-way.
The class comment of NoteService records the reason, next to the code:
/**
* The use cases of the note feature, with the same steps as every change: load, apply the
* rule, save, publish, audit.
*
* <p>The order feature calls {@link #addNotes} and {@link #countNeedingAction}, and this class
* calls {@code OrderService.requireExists}. The two features depend on each other, which Spring
* cannot build with constructor injection alone. {@link Lazy} injects a proxy for the order
* service, which finds the real bean at the first call. A two-way dependency is a warning sign;
* the README lists the ways out.A comment like this one belongs on every @Lazy that breaks a cycle. Without it, the
next developer removes the annotation and the service stops at startup.
The unit test is not affected. NoteServiceTest passes a Mockito mock of
OrderService to the constructor, and no proxy is involved.
Work at startup: migrations before trafficnew#
The service must not answer a request or take a message before its indexes and seed data exist. The platform's migration runner therefore runs at a precise moment of startup. The class implements one interface:
public class MongoMigrationRunner implements SmartInitializingSingleton {
// ...
@Override
public void afterSingletonsInstantiated() {
acquireLock();
try {
Set<String> applied = appliedIds();
for (MongoMigration migration : migrations) {
if (!applied.contains(migration.id())) {
run(migration);
}
}
} finally {
releaseLock();
}
}public class MongoMigrationRunner implements SmartInitializingSingleton {SmartInitializingSingletonis in the packageorg.springframework.beans.factory. Its javadoc says that Spring calls the method "right at the end of the singleton pre-instantiation phase". At that point every singleton bean exists.acquireLock();Several instances may start at once, and only one may migrate. For the lock, see the chapter Migrations, indexes and seed data.
Spring starts a context in steps. The source of Spring Framework 7.0.10 and Boot 4.1.2 gives their order:
Steps 2 and 3 come from the method refresh of AbstractApplicationContext.
It creates the singletons, which ends with the call to afterSingletonsInstantiated.
Only then does it start the lifecycle beans. Boot starts the web server in such a bean,
WebServerStartStopLifecycle. The listener containers of Spring Cloud AWS are
SmartLifecycle beans too. A run for this book also showed the order, with a
SmartInitializingSingleton that printed before a SmartLifecycle
started.
Three places could run the migrations:
| Place | What it gets right | What it costs |
|---|---|---|
| afterSingletonsInstantiated, the platform's choice | no request and no message can arrive before the data is ready | no port is open while it waits, so a probe gets no answer |
| an ApplicationRunner | the web server answers probes, and readiness stays OUT_OF_SERVICE meanwhile | the SQS listeners have already started, so a message can arrive before the indexes exist |
| a separate job before the deployment | the service starts fast, and one job migrates once | one more thing to deploy, and the code and the data can be out of step |
The ApplicationRunner is the careful choice for many services. Readiness keeps the
orchestrator's traffic away while it runs, and the probes answer. It is the wrong choice here
only because of the queue listener. For the readiness run, see the chapter
Logging, tracing and health.
The platform's choice has one cost to plan for. An instance that waits for another's migration lock has no open port, so the orchestrator's startup probe must allow for that wait. The local compose file allows 60 seconds:
# Migrations run before the web server starts, and an instance may wait for another's
# migration lock. Allow for that before failed checks count.
start_period: 60sThe setting start_period: 60s gives the container a minute before a failed check
counts. A production orchestrator needs the same allowance, as long as the lock can last.
ASP.NET Core runs each IHostedService.StartAsync before the server listens. A run on
.NET 10 showed it. Inside StartAsync, a connection to the service's own port was
refused. After StartAsync of the host, the same connection worked. So
StartAsync is the closest match to step 2. For ApplicationRunner,
@PostConstruct and the bean lifecycle, see the section
Work at startup.
Where the service relies on proxiesnew#
Several annotations in the order service do nothing by themselves. Spring reads them and wraps the bean in a proxy. Every other bean then receives the proxy, not the object. For how a proxy works, see the section What a proxy actually is. These are the places where the service needs one:
| Annotation | On | What the proxy does |
|---|---|---|
| @Transactional | the methods of OrderService and NoteService that change data | starts a transaction, or joins one, and commits or rolls back |
| @OrdersRead and @OrdersWrite | each controller method | checks the scope before the method runs, because each carries @PreAuthorize |
| @Cacheable("reference-data") | ReferenceItemRepository.findByTypeOrderByCodeAsc | returns the cached list for the same type, and calls the method only on a miss |
| @Lazy | the OrderService parameter of NoteService | finds the real OrderService at the first call |
A run for this book showed the class of such a bean. A @Transactional service was
injected as OrderService$$SpringCGLIB$$0, a subclass made at runtime. A subclass cannot
override a final method. So the service classes and their public methods are never
final. For that trap, see the section Two kinds of
proxy.
A typical use case shows the annotation that the proxy reads:
@Transactional
public void cancel(OrderId orderId) {
Order order = orders.get(orderId);
order.cancel(clock);
saveAndPublish(order, AuditActions.ORDER_CANCELLED);
}The controller calls cancel on the proxy. The proxy starts the transaction, calls
the method of the real object, and commits when the method returns.
The place method has no @Transactional. It calls the
TransactionTemplate instead, which is an ordinary object with no proxy. The template
can make only part of the method transactional, and a proxy always wraps the whole method.
For the method security interceptor and its order against the transaction, see the section How method security works. For the cache, see the chapter Caching and scheduling.
Self-invocation: why the cache is on the repositorytrap#
The reference service has two methods that read a lookup list. list returns it to
the API, and requireActive checks a code against it. Both read the list through the
repository:
public List<ReferenceItemView> list(String type) {
if (!ReferenceTypes.ALL.contains(type)) {
throw new UnknownReferenceTypeException(type);
}
return items.findByTypeOrderByCodeAsc(type).stream().map(ReferenceItemView::from).toList();
}
// ...
public void requireActive(String type, String code, String field) {
boolean known = items.findByTypeOrderByCodeAsc(type).stream()
.anyMatch(item -> item.code().equals(code) && item.active());
if (!known) {
throw new InvalidValueException(field, "is not a known value");
}
}A natural first design puts @Cacheable on list. Then
requireActive would call list. That call would go to this,
the object inside the proxy. It would skip the proxy and the cache, and every order would read the
database.
A call from one method of a bean to another method of the same bean skips the proxy. The annotation on the called method then does nothing, with no warning. A hand-written decorator in C# is skipped in the same way. But in C# you wrote the decorator yourself, so you can see it. Spring creates the proxy for you, so the skipped call is harder to see. For the prediction and the fixes, see the section Self-invocation: a call from inside the class.
The companion puts the cache where every call passes through a proxy, on the repository:
/**
* Reads lookup values, and caches each list by its type.
*
* <p>The cache is on the repository, not on the service, for a reason. {@code @Cacheable} works
* through a proxy around the bean, so only calls from another bean pass through it. The service
* calls this method from two of its own methods; if the cache were on the service, the call from
* {@code requireActive} to {@code list} would skip the proxy and the cache.
*
* <p>The cached list is shared by every caller, so callers must not change it.
*/
public interface ReferenceItemRepository extends MongoRepository<ReferenceItemDocument, String> {
@Cacheable("reference-data")
List<ReferenceItemDocument> findByTypeOrderByCodeAsc(String type);
}@Cacheable("reference-data") keeps one list for each type. Both methods of the
service call another bean, the repository, so both calls pass through its proxy. The journey test OrderJourneyIT.lookupValuesAreSeededAndCached checks that the
lists are cached.
The same rule explains two choices in the use cases. OrderService.place calls
notes.addNotes on another bean, so its @Transactional joins the
transaction. And the private helper saveAndPublish carries no annotation, because a
call to it never passes a proxy.
A test that starts the whole containernew#
Most mistakes of wiring show only when the context starts. A missing bean, a cycle, a property that does not bind or a wrong order of auto-configurations all stop the startup. Unit tests never start the context, so they cannot catch them. The order service has one test that starts it with every setting, while nothing connects to anything:
/**
* Starts the whole service with every setting, while nothing connects to anything. It catches a
* missing bean, a cycle, a bad property or a wrong auto-configuration order in seconds, without
* Docker. The settings are in {@code application-wiring.yml}.
*/
@SpringBootTest
@org.springframework.test.context.ActiveProfiles("wiring")
class ApplicationWiringTest {@SpringBootTestThe test starts the whole application context, as
mainwould.@org.springframework.test.context.ActiveProfiles("wiring")The test switches on the profile
wiring, so Boot also readsapplication-wiring.yml. That file points every connection at port 1, where nothing listens, and turns the migrations and the outbox resender off.
The MongoDB client does not need a server when Spring creates it. Only a database operation waits for a server, and the wiring profile limits that wait to 100 milliseconds. So the context starts without a database. The test then asserts a few beans, as the first section of this chapter showed. For the profile file, see the section The settings files and the profiles. For the other kinds of test, see the chapter Testing in depth.
ASP.NET Core has ValidateOnBuild. In a run on .NET 10, it found the cycle when the
provider was built. The wiring test goes further, because it starts the real context and binds every
setting.
1An expert asksEvery configuration class of the order service writes
@Configuration(proxyBeanMethods = false). Why is that safe here, and when would it
break?
@Configuration(proxyBeanMethods = false). Why is that safe here, and when would it
break?@Bean method to another creates a second object. No @Bean
method of the service calls another one. Each one takes the beans it needs as parameters, which
works in both modes. It would break if someone added a direct call, such as
new Uses(policy()).2An expert asksWhy does the platform run the migrations in
afterSingletonsInstantiated, and not in an ApplicationRunner?
afterSingletonsInstantiated, and not in an ApplicationRunner?afterSingletonsInstantiated after every singleton exists and before
the lifecycle beans start. Those beans include the web server and the SQS listener containers. So
no request and no message can arrive before the indexes and seed data exist. An
ApplicationRunner runs after the listeners have started, so a message could arrive
first. The cost is that no port is open while the runner waits for a lock.3The note service takes @Lazy OrderService orders in its constructor. What does
Spring inject, and when does it find the real bean?
@Lazy OrderService orders in its constructor. What does
Spring inject, and when does it find the real bean?OrderService bean at the first call, and keeps it, because the bean is a singleton. So
Spring can create the note service before the order service exists. A missing bean would show only
at that first call.4TranslateThe order domain is plain Java, and its class ShippingPolicy carries
no annotation. Write this registration as the order feature's configuration class:
builder.Services.AddSingleton(sp => new ShippingPolicy());
ShippingPolicy carries
no annotation. Write this registration as the order feature's configuration class:
builder.Services.AddSingleton(sp => new ShippingPolicy());@Configuration(proxyBeanMethods = false)
class OrderConfig {
@Bean
ShippingPolicy shippingPolicy() {
return new ShippingPolicy();
}
}The @Bean method returns the object, and Spring makes it a singleton bean named
shippingPolicy. Component scanning finds OrderConfig, because its package is under the package of
the application class. proxyBeanMethods = false is safe, because no
@Bean method calls another one.
Quick reference#
Each container idea of the order service, next to its ASP.NET Core match:
| ASP.NET Core | Spring | Note |
|---|---|---|
| IServiceCollection | the bean definitions | Spring collects them from scanning, @Bean methods and auto-configuration |
| IServiceProvider | ApplicationContext | it creates and holds the beans, and is also the event publisher |
| builder.Services.AddScoped<T>() | @Service or @Component on the class | the annotation is the registration |
| AddScoped<IPort, Adapter>() | @Component on the adapter class | Spring finds the one bean that implements the interface |
| AddSingleton(sp => new X()) | a @Bean method | for a class that carries no annotation, or needs settings to build |
| TryAddSingleton<T>() | @ConditionalOnMissingBean | only in auto-configuration, which runs after the service's own beans are known |
| sp.GetService<T>() | ObjectProvider<T>.getIfAvailable() | both return null when no bean or service exists |
| sp.GetRequiredService<T>() | ObjectProvider<T>.getObject() | throws when no bean exists, and also when two beans exist |
| IEnumerable<T> | List<T>, or ObjectProvider<T>.orderedStream() | every bean of the type, sorted by @Order |
| Lazy<T>, registered by you | @Lazy on a parameter | Spring injects a proxy with no registration |
| IHostedService.StartAsync | SmartInitializingSingleton | before the server and the listeners start |
| TimeProvider | java.time.Clock | the platform's bean is Clock.systemUTC() |
| ValidateOnBuild | a @SpringBootTest that starts the context | the wiring test also binds every setting |
Starting the service: from main() to the first request#
The order service starts in sixteen seconds on the author's laptop. In that time Spring reads
the settings and writes a recipe for every bean. It builds
658 objects in dependency order and wraps fourteen of them in
proxies. Then it runs the migrations and starts Tomcat. This
chapter follows that start as one story. One bean, OrderService, is in every
scene.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | WebApplication.CreateBuilder(args), Build(), then Run() | SpringApplication.run(OrderServiceApplication.class, args) | one call does all three steps, and nine events mark its phases Why |
| twist | builder.Configuration, read when Build() runs | the Environment, assembled before the context exists | the settings are complete before any bean is defined, and an EnvironmentPostProcessor can still add to them Why |
| new | no equivalent | ApplicationContextInitializer, and the four early events | code that runs before any bean definition exists, registered in spring.factories and never as a bean Why |
| twist | builder.Services, a list of ServiceDescriptor | the bean definitions, written by ConfigurationClassPostProcessor | a recipe for each bean, with its class, its scope and its constructor, and no object yet Why |
| twist | services.AddShopPlatform(), which every app must call | the imports file, read by a DeferredImportSelector | auto-configuration is registered after the service's own definitions, so its conditions can see them Why |
| new | no equivalent | BeanFactoryPostProcessor | a hook that edits the recipes before the first object exists, and the step that took six of the sixteen seconds Why |
| trap | a wiring error appears at the first resolve, unless ValidateOnBuild is on | every singleton is created at startup, dependency first | a bean that cannot be built stops the start, so a broken wiring never reaches a request Why |
| twist | the container calls the constructor, and then nothing more | the constructor, then the Aware callbacks, then the post-processors, which also run @PostConstruct | a fixed order of fourteen callbacks, and the proxy is made in the last one Why |
| new | a decorator class that you register | the auto-proxy creator, a BeanPostProcessor | it wraps the bean in postProcessAfterInitialization, so the object other beans receive is the wrapper Why |
| twist | IHostedService.StartAsync, before Kestrel listens | SmartInitializingSingleton, then SmartLifecycle.start() in phase order | the migrations run when every singleton exists, and Tomcat starts in a later phase Why |
| twist | the ApplicationStarted token | ApplicationRunner, then ApplicationReadyEvent | the runners run after the server listens, and readiness changes to accepting traffic after them Why |
| twist | SIGTERM, then StopAsync in reverse order, with 30 seconds | the shutdown hook closes the context: Lifecycle stop in reverse phase order, then @PreDestroy | the same shape as .NET, but Spring gives each phase 30 seconds and .NET gives the whole stop 30 seconds Why |
| new | no equivalent | /actuator/startup, /actuator/conditions, /actuator/beans and /actuator/proxies | four reports of one start, three from Spring Boot and one from the platform Why |
Scene 1: the clock startstwist#
For the container itself, see the chapter Wiring the service: the container in depth. That chapter shows what the container holds. This chapter shows the order in which the container fills, with the time each step took. The times come from one start of the order service in Docker, on 26 October 2026. The laptop had an Intel i9-8950HK, and the container ran Java 25.0.4.1. Your numbers will differ. The order will not.
In ASP.NET Core, three lines start a service. In Spring Boot, one line does the same work:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddShopPlatform();
var app = builder.Build();
app.Run();SpringApplication application = new SpringApplication(OrderServiceApplication.class);
if (Boolean.getBoolean("shop.startup.trace")) {
application.setApplicationStartup(new BufferingApplicationStartup(4096));
}
application.run(args);The C# side calls app.Run() on an object that Build() made. The
Java side calls application.run(args) on the application itself. The platform
needs no line of its own, because scene 4 shows how Spring finds it.
SpringApplication.run does four things, in this order. It creates the
application context. It turns the command
line into property sources. It refreshes the context,
which creates every singleton bean. And it calls the runner beans. Spring Boot publishes an
event at each phase of that work. Nine events mark the whole start, and two of them change the
liveness probe and the readiness probe:
| Event | When Spring Boot publishes it | What exists at that moment |
|---|---|---|
| ApplicationStartingEvent | at the start of run(), before any work | nothing, apart from the listeners |
| ApplicationEnvironmentPreparedEvent | when the Environment is known, before the context is created | every setting |
| ApplicationContextInitializedEvent | when the context exists and the initializers have run, before any bean definition is loaded | an empty context |
| ApplicationPreparedEvent | just before the refresh starts, after the bean definitions are loaded | the recipe of every bean, and no object |
| ApplicationStartedEvent | after the refresh, before the runners | every bean, and a listening server |
| AvailabilityChangeEvent, LivenessState.CORRECT | right after ApplicationStartedEvent | the liveness probe says live |
| ApplicationReadyEvent | after the runners | everything |
| AvailabilityChangeEvent, ReadinessState.ACCEPTING_TRAFFIC | right after ApplicationReadyEvent | the readiness probe says ready |
| ApplicationFailedEvent | if any step throws | a failed start |
Four events come before the refresh. Four come after it. The refresh is where the time goes. The startup report of the run, which the last scene explains, gives these times for the top-level steps:
| Step | Took | What happened |
|---|---|---|
| spring.boot.application.starting | 194 ms | the listeners were found and told |
| spring.boot.application.environment-prepared | 975 ms | the settings were read, from every source |
| spring.boot.application.context-prepared | 5 ms | the context was created and initialised |
| spring.boot.application.context-loaded | 13 ms | the application class was registered as the first bean definition |
| spring.context.refresh | 14,152 ms | every bean was defined, created and started |
| spring.boot.application.started | 227 ms | the started event was published, after the Started line |
| spring.boot.application.ready | 2 ms | the ready event was published, after the runners |
The line that a reader looks for first is the last one Spring Boot logs. The run printed
this line. The two numbers differ because the Java Virtual Machine
(JVM) was running for three seconds before main started:
c.a.o.OrderServiceApplication : Started OrderServiceApplication in 16.207 seconds (process running for 19.106)The Started line names the class that holds main. Spring Boot logs
it under that class's logger. The first number is the time from the start of run().
The second number is the time since the JVM started. The three seconds between them belong to
the JVM. Two chapters after the chapter Running locally and shipping explain
them, one on memory and one on code.
The strict rule. The main method of a service does nothing but
call SpringApplication.run. Everything else is configuration.
What we do. When the system
property shop.startup.trace is true, main gives the application a
BufferingApplicationStartup. That object keeps every startup step with its timing,
and the Actuator startup endpoint reports
them.
Why. The buffer can only be set before run(), so no bean and no
setting can do it. And the buffer holds up to 4,096 steps in memory that a running service never
reads, so it is off by default.
When to stop. If a team wants the trace on in every environment, move the
decision to a SpringApplicationBuilder in the platform, and keep main
to one line.
What you can see here#
The Started line, and the two numbers in it. With the trace switch on, the
report at /actuator/startup gives every step. The last scene shows how to read
it.
What you can hook here#
A listener for the four early events cannot be a bean, because no bean exists yet. Spring
Boot finds such a listener in two ways. The application can call
SpringApplication.addListeners(...) before run(). Or a library lists the
listener in its META-INF/spring.factories file under the key
org.springframework.context.ApplicationListener. The platform uses the second way
for the next scene's hook.
Scene 2: the settings, before anything elsetwist#
The first second of the start assembles the Environment. It is the object that
holds every setting, as one ordered list of property sources. Spring Boot reads fifteen kinds of
source, and a later source wins over an earlier one. The chapter
Configuration in depth covers the list and the rules. For this scene, four
facts matter:
- The settings files,
application.ymland the profile files, are loaded by a class calledConfigDataEnvironmentPostProcessor. - That class is an
EnvironmentPostProcessor, one among several that Spring Boot ships. - Every
EnvironmentPostProcessorruns when Spring Boot publishesApplicationEnvironmentPreparedEvent, before the context exists. - The ones that implement
Orderedrun in their order. The config data one has the orderHIGHEST_PRECEDENCE + 10, so it runs early.
The platform adds its defaults in the same phase. Its post-processor reads every
META-INF/platform-defaults/*.properties file on the
classpath and adds each one last, so a service's own
settings always win:
public class PlatformDefaultsEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {
private static final String DEFAULTS = "classpath*:META-INF/platform-defaults/*.properties";
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
try {
Resource[] files = new PathMatchingResourcePatternResolver().getResources(DEFAULTS);
Arrays.sort(files, Comparator.comparing(Resource::getFilename));
for (Resource file : files) {
environment.getPropertySources().addLast(new PropertiesPropertySource(
"platform-defaults:" + file.getFilename(), PropertiesLoaderUtils.loadProperties(file)));
}
} catch (IOException e) {
throw new UncheckedIOException("Could not load the platform defaults", e);
}
if (environment.getProperty("platform.logging.structured", Boolean.class, true)) {
environment.getPropertySources().addLast(new MapPropertySource("platform-defaults:structured-logging",
Map.of("logging.structured.format.console", "ecs")));
}
}
@Override
public int getOrder() {
// Run after Spring Boot has loaded application.yml, so the check above can read it.
return Ordered.LOWEST_PRECEDENCE;
}
}implements EnvironmentPostProcessor, OrderedThe class is not a bean. Spring Boot creates it with reflection, before the context exists. So it has no constructor dependencies, and it cannot inject anything.
environment.getPropertySources().addLast(new PropertiesPropertySource(A property source added last has the lowest precedence. The service's
application.yml, its environment variables and its command line were all added before this one. So each of them wins over a platform default.return Ordered.LOWEST_PRECEDENCE;The lowest precedence runs last. So this class runs after the config data post-processor, and it can read a setting from
application.ymlto decide whether to switch on JSON logs.
Spring Boot finds this class through a file in the platform jar. The file is not the
auto-configuration imports file. It is the older spring.factories file, which Spring
Boot still reads for the hooks that run before the context exists:
# EnvironmentPostProcessor is still registered here, not in the AutoConfiguration.imports file.
org.springframework.boot.EnvironmentPostProcessor=\
com.acme.platform.core.config.PlatformDefaultsEnvironmentPostProcessorThe key of the line is the full name of the interface,
org.springframework.boot.EnvironmentPostProcessor. In Spring Boot 3 the interface
was in the package org.springframework.boot.env. Spring Boot 4 moved it, and the key moved with it.
ASP.NET Core has the same idea in a different shape. builder.Configuration is a
list of configuration providers, and a later provider wins. The difference is when the list is
complete. In ASP.NET Core, code can add providers until Build(). In Spring Boot, the
Environment is complete when ApplicationEnvironmentPreparedEvent has been handled,
one second into the start, and no bean can change which source wins.
What you can see here#
The endpoint /actuator/env lists every property source in order, with each
value and the source it came from. The chapter
Which source wins reads one. In the startup report, the step
spring.boot.application.environment-prepared took 975 ms in the run. The report does
not split that time. It shows only the total for the step.
What you can hook here#
An EnvironmentPostProcessor, registered in spring.factories. Use
one when a library must set defaults for every service, as the platform does, or when a setting
must be computed from another. Do not use one to read a secret from a vault at startup. Spring
Boot's own config data loaders exist for that, and they handle the order and the failure
cases.
Scene 3: the context exists, and nothing is in itnew#
About a third of a second after the Environment is ready, the context exists, and preparing it takes 5 ms. Spring Boot picks the
context class from the classpath. The order service has Spring MVC on its classpath, so it gets
a web context. MVC is short for model-view-controller, the name of Spring's web framework. The
context class is an AnnotationConfigServletWebServerApplicationContext. The name is
long because it says three things. The context reads
annotations. It is a
servlet web application. And it owns its web server.
Three things happen before the first bean definition is loaded. Spring Boot applies the
spring.main.* settings to the bean factory, such as
spring.main.allow-circular-references, which is false by default. Then it runs every
ApplicationContextInitializer. Then it publishes
ApplicationContextInitializedEvent. Spring Boot's own initializers come from its
spring.factories. One of them, ConditionEvaluationReportLoggingListener,
is the class that writes the conditions report to the log in the last scene.
Then Spring Boot loads the sources. For the order service there is one source,
OrderServiceApplication, which becomes the first bean definition. Spring Boot logs the
Starting line at this point, and publishes ApplicationPreparedEvent.
The run logged these two lines, 1.8 seconds into the start:
c.a.o.OrderServiceApplication : Starting OrderServiceApplication using Java 25.0.4.1 with PID 1 (/app/BOOT-INF/classes started by app in /app)
c.a.o.OrderServiceApplication : The following 1 profile is active: "local"The Starting line says four useful things. It names the Java version. It gives
the process id, which is 1 in a container. It names the directory the classes were loaded
from. And it names the user, which is app and not root. The second line names the
active profile, so a wrong profile shows at the top
of the log.
@Component with an @EventListener method for
ApplicationEnvironmentPreparedEvent never runs. The event is published during the
first second, and the component is created during the refresh, ten seconds later. Register such
a listener with SpringApplication.addListeners(...), or in
spring.factories. A bean can listen for the four late events, from
ApplicationStartedEvent on.
What you can see here#
The Starting line and the profile line. If the profile line names the wrong
profile, nothing after it is worth reading. The steps
spring.boot.application.context-prepared and
spring.boot.application.context-loaded took 18 ms together.
What you can hook here#
An ApplicationContextInitializer, registered with
SpringApplication.addInitializers(...) or in spring.factories. It
receives the empty context, and it can add property sources or bean definitions in code. Spring
Boot's test support uses initializers to add the settings of a test. Application code rarely
needs one.
Scene 4: definitions, not objectstwist#
Now the refresh begins. Its first half writes a recipe for every bean, and creates no bean.
A recipe is called a bean definition. It holds the
bean's class, its scope, its constructor, the beans it depends on, and whether it is lazy.
After the start, /actuator/beans listed 625 beans, and OrderService is one of them. At this moment it is only a definition. The definition says that
the class is com.acme.orderservice.order.application.OrderService, that the scope is
singleton, and that the one constructor takes nine parameters. No OrderService object
exists.
The definitions come from one BeanFactoryPostProcessor, called
ConfigurationClassPostProcessor. It runs before every other post-processor,
because the @Bean methods must be registered before anything else looks at them.
It starts from the one definition the previous scene loaded,
OrderServiceApplication, and reads its annotation.
@SpringBootApplication is three annotations in one:
| Annotation inside @SpringBootApplication | What ConfigurationClassPostProcessor does with it |
|---|---|
| @SpringBootConfiguration | treats the class as a @Configuration class, so its @Bean methods and its imports count |
| @ComponentScan | scans the package of the class and every package below it for stereotypes, and writes a definition for each class found |
| @EnableAutoConfiguration | imports AutoConfigurationImportSelector, which reads the imports file of every jar on the classpath |
The scan comes first. It finds OrderService through its @Service
annotation, and the other 22 or so classes of the service that carry a
stereotype. The chapter
Where the beans come from: scanning and auto-configuration lists them.
Each @Configuration class found is read in turn. So OrderConfig adds a
definition for each of its @Bean methods. An @Import annotation on such a
class adds more classes to read.
The auto-configuration comes last, on purpose. AutoConfigurationImportSelector is
a DeferredImportSelector. A deferred selector runs after every
@Configuration class of the application has been read. So when the selector reads the
platform's nine auto-configurations from the imports file, the service's own definitions already
exist. That is why a condition such as @ConditionalOnMissingBean is safe on an
auto-configuration and unsafe on an application class. On the application class the condition
runs before the service's other definitions exist, and the answer depends on the order of the
scan.
The conditions are evaluated here, while the definitions are written. For the platform's clock bean, the report of the run says why the bean was defined:
"PlatformCoreAutoConfiguration#clock": [
{
"condition": "OnBeanCondition",
"message": "@ConditionalOnMissingBean (types: java.time.Clock; SearchStrategy: all) did not find any beans"
}
]The message says that no definition of a Clock existed when the condition ran.
A test that declares its own Clock bean makes the same condition fail, and the
platform's bean is never defined. For the conditions of the platform, see the chapter
Conditions: when a bean or a class applies.
Spring Boot's own auto-configurations go through the same gate, and most of them fail it. The run evaluated 362 positive matches, 194 negative matches and 22 classes with no condition. A negative match looks like this:
"ShutdownEndpointAutoConfiguration": {
"notMatched": [
{
"condition": "OnAvailableEndpointCondition",
"message": "@ConditionalOnAvailableEndpoint the configured access for endpoint 'shutdown' is NONE"
}
],
"matched": []
}The notMatched list names the condition that failed and gives its message. Here
the OnAvailableEndpointCondition found that the shutdown endpoint is not enabled,
so the auto-configuration for it was skipped. The matched list is empty because
no other condition was evaluated on that class.
In ASP.NET Core, the matching idea is builder.Services, a list of
ServiceDescriptor objects. Each descriptor holds a service type, an implementation
and a lifetime, and no object. The list is complete when Build() runs. The
difference is who writes the list. In ASP.NET Core, the application writes every entry, and a
library adds entries only when the application calls its extension method. In Spring Boot, the
scan and the imports file write the entries, and the application never names the platform.
What you can see here#
Three reports show the definitions. The conditions report, at
/actuator/conditions or in the log with --debug, says for every
auto-configuration why it was or was not applied. The endpoint /actuator/beans
lists every definition with its type, its scope and the names of the beans it depends on. The
entry for orderService names nine dependencies, which are the nine constructor
parameters. And the startup report has one step for the whole phase,
spring.context.beans.post-process, which took 6,538 ms in the run. That is about 40 percent of the
start, and ConfigurationClassPostProcessor took 5.6 seconds of it.
What you can hook here#
Three hooks add definitions, and you already use the first two. @Import on a
configuration class adds the classes it names. An ImportSelector adds classes by
name, and AutoConfigurationImportSelector is the one that imports every
auto-configuration. An ImportBeanDefinitionRegistrar writes definitions directly,
and Spring Data uses one to define a bean for each
repository interface. Write your
own selector only for a library that must choose its configuration classes at startup. For an
application, a @Conditional annotation on a @Bean method does the same
with less code.
Scene 5: the recipes are edited, and the post-processors are bornnew#
The definitions exist. Before the first object is created, two kinds of post-processor get their turn. The names are alike, so here they are side by side:
| Kind | Works on | Runs | Examples in the order service |
|---|---|---|---|
| BeanFactoryPostProcessor | the definitions, before any bean exists | once, in this scene | ConfigurationClassPostProcessor, and the one Spring Boot adds for spring.main.lazy-initialization |
| BeanPostProcessor | each bean object, as it is created | once for every bean, in the next scenes | the one for @Autowired, the one for @PostConstruct, the auto-proxy creator, the platform's proxy report |
A BeanFactoryPostProcessor may change any definition. The lazy-initialization one
marks every definition lazy when spring.main.lazy-initialization is true, so the
beans are created at their first use instead of at startup. The order service does not set it.
Spring Boot's reference says why. Lazy initialization delays the discovery of a problem, from the
start to the first request.
Then Spring creates every BeanPostProcessor, before any other bean. They are
beans themselves, so they are the first objects of the refresh. The platform's proxy report is
one of them, and its @Bean method is static for exactly this reason:
@AutoConfiguration
@ConditionalOnProperty(name = "platform.diagnostics.proxies.enabled", havingValue = "true")
public class PlatformDiagnosticsAutoConfiguration {
@Bean
static ProxyReport proxyReport() {
return new ProxyReport();
}static ProxyReport proxyReport()Spring must create a post-processor before any other bean. If the method were not static, Spring would first have to create the configuration class. That class could in turn need beans that are not yet processed. A static method avoids the whole question, and the Spring Javadoc asks for it.
@ConditionalOnPropertyThe report is off unless a service sets the property. A post-processor runs for every one of the 658 beans, so a diagnostic that nobody reads should not exist.
A bean that a post-processor depends on is created early too. Spring then logs a warning
that the bean "is not eligible for getting processed by all BeanPostProcessors". That warning
means a @Transactional annotation on such a bean does nothing, because the proxy
creator was not yet there when the bean was made. The proxy report has no dependencies, so no
bean is pulled forward by it.
The strict rule. The platform ships only what every service needs in production. A diagnostic belongs in a test, or in a tool.
What we do. The platform ships a BeanPostProcessor that lists
every proxied bean, and an Actuator endpoint that shows the list. Both exist only when
platform.diagnostics.proxies.enabled is true.
Why. The question "is my bean proxied, and by what?" comes up in every team, and the usual answer is a debugger session. The report answers it with one request, in any environment, and it has no cost when it is off.
When to stop. If the platform gains a second diagnostic, move them both to a
separate shop-platform-diagnostics jar that a service adds only when it wants
them.
What you can see here#
The startup report has a step for each post-processor:
spring.context.beandef-registry.post-process and
spring.context.bean-factory.post-process, each tagged with the class that ran. The
run had three of the first kind and twelve of the second. And the warning about a bean that is
"not eligible" for every post-processor, when it appears, names the bean to look at.
What you can hook here#
A BeanFactoryPostProcessor of your own, when a rule applies to every definition
at once. Spring Boot's lazy-initialization processor is the model. It is rare in application
code. A BeanPostProcessor of your own is less rare, and the two scenes after the
next one show the rules for writing one.
Scene 6: objects, one at a timetrap#
Now Spring creates the singletons, in the order of the definitions, and dependency first.
When it reaches a definition, it looks at the constructor parameters. For each parameter it
finds the one bean of that type, and creates it first if it does not exist yet. So a bean is
often created inside the creation of another. The startup report of the run shows exactly this
for the protagonist. The step that created orderService is inside the step that
created orderController, because the controller's constructor takes the service.
The chain went deeper. The service's constructor takes a StockChecker, the port
to the stock service. Its adapter, stockAdapter, needed the HTTP client
stockHttpClient, which needed the restClientBuilder. The
adapter is the class that implements the port with a real
HTTP call. The report gives the
time of each step, and each time includes its children:
| Bean being created | Took | What is inside the time |
|---|---|---|
| orderController | 1,030 ms | orderService and everything below it, then the controller itself |
| orderService | 1,007 ms | its nine dependencies, created first |
| stockAdapter | 927 ms | stockHttpClient, and the client's builder |
| stockHttpClient | 903 ms | the HTTP interface proxy, and restClientBuilder |
| restClientBuilder | 489 ms | the builder of the HTTP client, with its codecs |
Most of the controller's second went to the HTTP client of the stock adapter, and building
restClientBuilder alone took 489 ms. This is the general
shape of a slow start. The first bean to touch a library pays for the library, and the report
shows which bean that was.
This is where the constructor of OrderService runs. Its nine parameters arrive as
objects that Spring has already finished, with one exception that the next paragraph explains:
public OrderService(OrderRepository orders, NoteService notes, ReferenceDataService referenceData,
StockChecker stock, ShippingPolicy shippingPolicy, DomainEventPublisher events,
AuditLog audit, TransactionTemplate transaction, Clock clock) {The exception is the cycle between the order and note features. The order service needs
the note service, and the note service needs the order service. Spring Boot refuses such a cycle by default, with a report at startup. The note service breaks
it with @Lazy on its constructor parameter. So when NoteService is
created, the parameter it receives for the order service is not the order service. It is a small
proxy that finds the real bean at its first call. The chapter
@Lazy: a proxy that breaks the cycle, and the other ways out explains
the choice. For this scene, the point is the order. NoteService was finished before
OrderService existed, and the lazy proxy made that possible.
After the constructor, Spring runs a fixed list of callbacks on the new object. The Spring Javadoc gives fourteen, and the order service uses four of them:
| Order | Callback | What it is for | In the order service |
|---|---|---|---|
| 1 to 10 | the Aware interfaces, such as BeanNameAware and ApplicationContextAware | the bean learns its name, or gets the context | not used |
| 11 | BeanPostProcessor.postProcessBeforeInitialization | a post-processor sees the populated bean, before its init methods | CommonAnnotationBeanPostProcessor calls the @PostConstruct method here, as in the leak experiment of the memory chapter |
| 12 | InitializingBean.afterPropertiesSet() | the bean's own setup, with every dependency present | not used |
| 13 | a custom init-method | the same, named in the definition | not used |
| 14 | BeanPostProcessor.postProcessAfterInitialization | a post-processor may replace the bean with a wrapper | the auto-proxy creator, and the platform's proxy report |
The fourteenth callback is the twist of the next scene. Before that, the trap of this scene.
In ASP.NET Core, the container builds in Build(). The handbook's run in the chapter on
wiring shows when it creates each singleton. A wiring mistake, such as a cycle, can appear at the first resolve,
in the middle of a request. Only ValidateOnBuild, which the generic host turns on in
the Development environment, moves the error to the build. In Spring, every singleton is created
in this scene, and a bean that cannot be created stops the start. The refresh()
Javadoc promises this. After the refresh, either every singleton exists or none does.
@PostConstruct method runs at callback 11. The proxy that gives a method its
transaction, its cache or its permission check is created at callback 14. So a call from
@PostConstruct to the bean's own @Transactional method runs with no
transaction. Spring's reference says not to rely on the proxy in initialization code. Do the
work in a SmartInitializingSingleton, or
in an ApplicationRunner, when every bean is complete.
What you can see here#
Each spring.beans.instantiate step in the startup report, with its bean name,
its parent step and its time. The run has 690 of them. Sort them by time to find the bean that
paid for a library. The endpoint /actuator/beans gives the dependencies of each bean
by name. And when a cycle exists, the start fails with a report that draws the cycle as a box.
The report names the setting that would allow the cycle, as a last resort.
What you can hook here#
@PostConstruct, for setup that needs the bean's own dependencies and nothing
else. @Lazy on a parameter, to break a cycle or to delay an expensive bean.
@DependsOn, to force an order between two beans that share no dependency. And
SmartInitializingSingleton, for work that needs every bean, which scene 8
shows.
Scene 7: the twistnew#
The constructor of OrderService has run. The object exists. And the object that
Spring hands to OrderController is a different object.
Callback 14 is postProcessAfterInitialization, and one of the post-processors
that run there is the auto-proxy creator. Its Javadoc says what it does. It wraps each eligible bean with an
AOP proxy. AOP is short for aspect-oriented programming. It is the
mechanism behind the annotations that need a proxy, such as @Transactional and
@Cacheable. The glossary entry AOP gives the
words of that mechanism. The creator looks at the bean's class and finds the advisors whose
pointcuts match a method of it. If any match, it returns a proxy instead of the bean. Spring
stores the returned object as the singleton. So every other bean receives the proxy, and the
original object is inside it.
The proxy report sees this happen, because it runs in the same callback, after the creator. It declares no order, and Spring registers unordered post-processors after the ordered ones. So by the time the report sees a bean, the creator has already decided:
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
seen.incrementAndGet();
if (AopUtils.isAopProxy(bean) && bean instanceof Advised advised) {
String kind = AopUtils.isCglibProxy(bean) ? "class (CGLIB subclass)" : "interface (JDK dynamic proxy)";
List<String> advice = Arrays.stream(advised.getAdvisors())
.map(Advisor::getAdvice)
.map(Advice::getClass)
.map(Class::getSimpleName)
.toList();
proxied.add(new ProxiedBean(beanName, AopProxyUtils.ultimateTargetClass(bean).getName(), kind, advice));
}
return bean;
}AopUtils.isAopProxy(bean)Spring's own check for a proxy. It returns true for a class-based proxy and for an interface-based one, and false for a plain object.
bean instanceof Advised advisedEvery proxy that Spring creates implements
Advised. The interface gives the list of advisors, and each advisor gives its advice, which is the interceptor that runs around the method. The simple class name of the interceptor says what the proxy adds.AopProxyUtils.ultimateTargetClass(bean)The class behind the proxy, through any number of nested proxies. For a class-based proxy the proxy's own class is a generated subclass, so this call is the way to the real one.
return bean;A post-processor may return a different object, and the creator does. The report only looks, so it returns what it was given.
In the run, the creator wrapped fourteen of the 658 beans. The report at
/actuator/proxies listed them. Here they are, with what each interceptor stands
for:
| Bean | Proxy | Interceptors on it | What put them there |
|---|---|---|---|
| orderService | class | TransactionInterceptor | @Transactional on its methods |
| noteService | class | TransactionInterceptor | @Transactional on its methods |
| eventPublicationRegistry, mongoDbEventPublicationRepository | class | TransactionInterceptor | @Transactional inside Spring Modulith |
| orderController, noteController, referenceDataController | class | DeferringMethodInterceptor | the method security of @OrdersRead and @OrdersWrite |
| orderMetrics, snsEventExternalizer | class | AnnotationAsyncExecutionInterceptor, CompletionRegisteringMethodInterceptor, TransactionInterceptor | @ApplicationModuleListener, which is @Async, @Transactional and a completion record in one |
| referenceItemRepository | interface | CacheInterceptor | @Cacheable on the repository, as the chapter on wiring explains |
| noteRepository, orderMongoRepository | interface | four Spring Data interceptors | a Spring Data repository is a proxy of your interface, with SimpleMongoRepository behind it |
| stockHttpClient | interface | HttpServiceMethodInterceptor | an HTTP interface client, which is a proxy that sends requests |
| stockClientProperties | class | MethodValidationInterceptor | @Validated on the settings class |
The table shows two kinds of proxy. A class-based proxy is a subclass of the bean's class,
generated at startup by a library called CGLIB, the Code
Generation Library. Spring Boot makes
this the default with the
setting spring.aop.proxy-target-class, which is true. An interface-based proxy is a
dynamic proxy of the Java Development Kit (JDK), which
implements the bean's interfaces. For a repository, the class behind the proxy is
SimpleMongoRepository, which Spring Data supplies. The HTTP interface client has no
class of its own behind its proxy. A
repository and an HTTP interface client are interfaces that Spring implements for you, and a
proxy is how it implements them.
A class-based proxy has two limits that matter in the service. It cannot override a final method, so a
@Transactional method must not be final. And a call from inside the class, through
this, reaches the original object and not the proxy. Such a call is called
self-invocation. The chapter
Self-invocation: why the cache is on the repository shows the second
limit on a real method. The Spring source logs a warning for a public final method on a proxied
class. The reference documentation only says that such a method "cannot be advised".
The platform's test proves the twist in six lines. The bean the context returns is not an
Orders, and its superclass is:
@Test
void theProxyIsWhatOtherBeansReceive() {
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
Orders orders = context.getBean(Orders.class);
assertThat(orders.getClass()).isNotEqualTo(Orders.class);
assertThat(orders.getClass().getSuperclass()).isEqualTo(Orders.class);
}
}The test asks the context for an Orders bean, whose one method is
@Transactional. The class of the object it gets back is a generated subclass. Every
bean that takes an Orders in its constructor receives that subclass.
ASP.NET Core has no auto-proxy creator. A decorator is a class you write and register, and the container passes it to the beans that ask for the interface. Spring does the same wrapping for you, from an annotation, and the price is that the wrapping is invisible until you ask the report.
What you can see here#
Three things. The report at /actuator/proxies, and the one line it writes at
startup, which counts the proxied beans. The check AopUtils.isAopProxy(bean), in
any test that holds a bean. And the Spring warning about a final method, at WARN level in the
log of a start.
What you can hook here#
A BeanPostProcessor. You already use three. AutowiredAnnotationBeanPostProcessor
applies @Autowired and @Value. CommonAnnotationBeanPostProcessor
applies @PostConstruct and @PreDestroy. And the auto-proxy creator applies
the annotations that need a proxy. The platform's report is a fourth. The rules for one of your
own are short. Declare it in a static @Bean method with no dependencies. Return the
bean you were given unless you mean to replace it. Declare no order unless you must run before
the creator, and then implement Ordered and expect to see beans before they are
proxied.
Scene 8: every singleton exists, and the service wakes uptwist#
The last singleton is created. Nothing listens yet. Two mechanisms now run in order, and the order is the point of this scene.
The first is SmartInitializingSingleton. Spring calls
afterSingletonsInstantiated() on every bean that implements it, right after the
last singleton exists and before any lifecycle bean starts. The platform's migration runner is
one, and the run shows the order it needs. The proxy report logged its summary, and the next
line is Tomcat's:
c.a.p.core.diagnostics.ProxyReport : Proxied beans: 14 of 658 (10 class-based, 4 interface-based); the list is at /actuator/proxies
o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'The ProxyReport line is directly followed by Tomcat started. The runner logged nothing in this run, because every migration had been applied in an
earlier run. The startup report shows its place. The step of the proxy report came first, then
the runner's step, and then Tomcat started. The runner implements the interface and does its
work in the one callback:
@Override
public void afterSingletonsInstantiated() {
acquireLock();
try {
Set<String> applied = appliedIds();
for (MongoMigration migration : migrations) {
if (!applied.contains(migration.id())) {
run(migration);
}
}
} finally {
releaseLock();
}
}The callback first calls acquireLock(), so that two instances of the service
never run a migration at the same time. Then it runs each migration whose id is not in
applied, and releases the lock. The callback runs on the main thread, and the
start waits for it. That is what the migrations want. No request and no message can arrive before the indexes exist. The chapter
The runner: each migration once, before traffic covers the lock and
the history. In the startup report the step is spring.beans.smart-initialize, and
the runner's step took 159 ms.
The second mechanism is SmartLifecycle. Spring's lifecycle processor starts every
SmartLifecycle bean, lowest phase first, and the web server is one of them. Spring
Boot gives its web server the phase DEFAULT_PHASE - 2048, and the graceful-shutdown
bean the phase DEFAULT_PHASE - 1024. DEFAULT_PHASE is
Integer.MAX_VALUE. So Tomcat starts before a lifecycle bean with the default phase,
and stops after it. The inbox of the service reads a queue of Amazon Simple
Queue Service (SQS). The listener containers for that queue started after Tomcat in the run,
on a thread of their own:
19:17:41.960Z [main] o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
19:17:42.575Z [ecycle-thread-1] a.c.s.l.AbstractMessageListenerContainer : Container io.awspring.cloud.sqs.sqsListenerEndpointContainer#0 startedThe first line is on the main thread. The second is on
ecycle-thread-1, whose name the log cut short. Spring Cloud AWS made this thread to start
its container, and the line came 0.6 seconds after Tomcat.
Tomcat was created much earlier, in the refresh step onRefresh(), before the
singletons. The run logged "Tomcat initialized with port 8080" about six seconds before "Tomcat
started". Created means the server object exists and the port is known. Started means the port
is open. Between the two lines, every bean was built.
ASP.NET Core has one mechanism here, IHostedService, and its
StartAsync runs before the server listens, in registration order. The handbook's
chapter on wiring maps the migrations to it. Spring has two mechanisms because they answer two
questions. "Every bean exists" is the question of SmartInitializingSingleton.
"Start, and later stop, in this order" is the question of SmartLifecycle.
What you can see here#
The two Tomcat lines and the time between them. The thread name in square brackets, which
is main for the migrations and a lifecycle thread for a listener container. And the
spring.beans.smart-initialize steps of the startup report, one for each bean that
implements the interface, in the order Spring called them.
What you can hook here#
SmartInitializingSingleton, for work that must finish before the server listens
and needs every bean. SmartLifecycle, for a thing that starts and stops, such as a
poller or a consumer. Its getPhase() decides the order against Tomcat. A phase above
the web server's starts after the port is open. Its stop(Runnable) is called with a
callback, so a slow stop can finish on its own thread and then call back. And
isAutoStartup(), which is true by default, decides whether Spring starts the bean at
all.
Scene 9: started, then readytwist#
The refresh is over. Spring Boot logs the Started line, publishes
ApplicationStartedEvent, and sets the liveness state to CORRECT. Then it
calls the runners. An ApplicationRunner or a CommandLineRunner is a bean
with one run method. Spring Boot calls each one here, after the server listens and
before the ready event. The order service has none. Then Spring Boot publishes
ApplicationReadyEvent, and sets the readiness state to
ACCEPTING_TRAFFIC. The chapter
The health probes: liveness without the database shows what the two
states mean to Kubernetes.
The conditions report was logged just before the Started line, at DEBUG, because
the run set DEBUG=true. It took about 2,130 of the 2,230 lines in the log. Without the
setting, those lines do not exist.
Then the first request of the script came. It took 0.31 seconds. The second took 0.04 seconds. The log explains part of the difference. During the first request only, the service fetched the token-signing keys from the OAuth2 server. The rest is the first-run cost in the JVM and in Spring, because the request's code ran for the first time. The chapter The JVM under the service: code, threads and the compiler follows that first run.
Spring MVC created its DispatcherServlet earlier, during the very first HTTP request.
In this run that was the readiness poll of the run script. It logged two lines:
o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
o.s.web.servlet.DispatcherServlet : Completed initialization in 9 msThe DispatcherServlet is the servlet that receives every request and finds the
controller for it. Tomcat creates it when the first request arrives. Here that was the readiness poll, so the 9 ms
are not part of the 0.31 seconds.
ASP.NET Core's ApplicationStarted token fires after the server starts, and
callbacks registered on it run then. Spring Boot's ready event plays the same role, and the
translate question at the end of the chapter writes it.
What you can see here#
The Started line, then the probes. The
readiness probe at
/actuator/health/readiness answers UP only after the ready event. The
run script of this chapter waited for that answer before it sent the first request.
What you can hook here#
An ApplicationRunner, for work that may run after the server listens, such as a
warm-up request to the service's own endpoints. An @EventListener for
ApplicationReadyEvent, for the same moment without the extra interface. Spring
Boot's reference prefers a runner over @PostConstruct for startup work, because the
runner sees a complete application.
Scene 10: the stoptwist#
Spring Boot registers a shutdown hook with the JVM. When the container sends SIGTERM, the hook
closes the context on a thread named SpringContextShutdownHook. The run stopped the
container with docker stop, and the log shows the order. First the readiness state
changed, so a load balancer would stop sending requests. Then the context began to close:
19:17:44.483Z [ionShutdownHook] o.s.b.a.ApplicationAvailabilityBean : Application availability state ReadinessState changed from ACCEPTING_TRAFFIC to REFUSING_TRAFFIC
19:17:44.483Z [ionShutdownHook] ConfigServletWebServerApplicationContext : Closing org.springframework.boot.web.server.servlet.context.AnnotationConfigServletWebServerApplicationContext
19:17:52.613Z [ecycle-thread-3] a.c.s.l.AbstractMessageListenerContainer : Container io.awspring.cloud.sqs.sqsListenerEndpointContainer#0 stopped
19:17:52.614Z [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
19:17:52.652Z [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete
19:17:52.681Z [ionShutdownHook] .s.m.e.c.DefaultEventPublicationRegistry : No publications outstanding!Closing the context does three things in order. It publishes
ContextClosedEvent. It stops the lifecycle beans, highest phase first, which is the
reverse of the start. And it destroys the singletons, which calls each @PreDestroy
method. The log shows the lifecycle part. The SQS listener container stopped first. The awspring source gives it the default phase,
Integer.MAX_VALUE, and the Spring documentation does not state it. It stopped first, and it took eight seconds to report that it had stopped. Then the
graceful-shutdown bean ran, waited for active requests, and found none. Then Spring Modulith's
registry logged that no event publication was outstanding.
Graceful shutdown is on by default since Spring Boot 3.4. The setting
server.shutdown is graceful, and the setting
spring.lifecycle.timeout-per-shutdown-phase gives each phase 30 seconds. ASP.NET Core
has the same shape. SIGTERM calls StopApplication, the hosted services stop in the
reverse order of their registration, and HostOptions.ShutdownTimeout gives them 30
seconds in total. For the setting on the service's side, see the section
Graceful shutdown.
What you can see here#
The lines between "Closing" and the end of the log, with their thread names and their times. A gap between two lines is a bean that took that long to stop. In the run, the gap was the listener container.
What you can hook here#
@PreDestroy, for releasing what the bean opened. SmartLifecycle.stop()
and its phase, for the order. The two settings above, for the kind of shutdown and the time each
phase gets. And an @EventListener for ContextClosedEvent, which runs
first, before any bean stops.
Scene 11: the four reports, in one placenew#
Every number in this chapter came from one of four reports, and each one is a setting away:
| Report | Where | How to turn it on | What it answers |
|---|---|---|---|
| the startup steps | /actuator/startup | the trace switch in main(), and startup in management.endpoints.web.exposure.include | which step, and which bean, took the time |
| the conditions | /actuator/conditions, or the log with DEBUG=true | conditions in the exposure list, or the environment variable | why an auto-configuration was or was not applied |
| the beans | /actuator/beans | beans in the exposure list | every bean's type, scope and dependencies |
| the proxies | /actuator/proxies, and one log line | platform.diagnostics.proxies.enabled=true, and proxies in the exposure list | which beans are wrapped, and by what |
The run script that captured them is companion/runs/startup/run.sh, and its
output folder holds the four reports of the run this chapter quotes. The endpoints need a token
like any other request of the service, or a public path for the actuator in the local
profile. The run used the second. Nothing in the four reports is a secret. The endpoint
/actuator/env is another matter, and the chapter
Secrets never in a file says why it stays closed.
A fifth report is Java Flight Recorder. It captures the same startup steps when
main gives the application a FlightRecorderApplicationStartup. It also captures what
the JVM did at the same time. The two JVM chapters that follow the chapter on
running the service use it.
1Spring Boot publishes ApplicationStartedEvent and then
ApplicationReadyEvent. What runs between the two?
ApplicationStartedEvent and then
ApplicationReadyEvent. What runs between the two?ApplicationRunner and
CommandLineRunner after the started event, when the server already listens, and
publishes the ready event after them. The readiness probe answers UP only
after the ready event.2A bean has a @PostConstruct method that calls the bean's own
@Transactional method. Does the call run in a transaction?
@PostConstruct method that calls the bean's own
@Transactional method. Does the call run in a transaction?@PostConstruct has run. And a call through this
never reaches the proxy anyway. Do the work in a SmartInitializingSingleton or an
ApplicationRunner.3An expert asksWhy can a @Component never receive
ApplicationEnvironmentPreparedEvent, and what receives it instead?
@Component never receive
ApplicationEnvironmentPreparedEvent, and what receives it instead?SpringApplication.addListeners, or
listed in META-INF/spring.factories under the ApplicationListener key.4TranslateTranslate this registration of startup work. In ASP.NET Core the callback
runs once the server has started.
var app = builder.Build();
app.Lifetime.ApplicationStarted.Register(() => Console.WriteLine("listening"));
app.Run();
var app = builder.Build();
app.Lifetime.ApplicationStarted.Register(() => Console.WriteLine("listening"));
app.Run();@Component
class Listening {
@EventListener(ApplicationReadyEvent.class)
void onReady() {
System.out.println("listening");
}
}Configuration in depth#
The order service reads its settings from application.yml, from a profile file and
from environment variables. Typed classes receive the settings, and a missing value stops the
service at startup. The platform library adds its own defaults below everything else. This chapter
explains each layer, every key the service and the platform set, the Boot 4 names that changed,
and where secrets come from.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | appsettings.json and appsettings.Development.json | application.yml and application-local.yml | the local profile file overrides the main file, and SPRING_PROFILES_ACTIVE switches it on Why |
| twist | builder.Configuration["MONGO_URI"] read in Program.cs | uri: ${MONGO_URI} in application.yml | a placeholder names an environment variable, and ${AWS_REGION:eu-west-2} adds a default Why |
| twist | Platform__Mongo__DocumentDb__CaBundle | PLATFORM_MONGO_DOCUMENTDB_CA_BUNDLE | Spring maps an environment variable to a key with single underscores, by relaxed binding Why |
| twist | a connection string and JwtBearer options in code | spring.mongodb.uri, issuer-uri and audiences in application.yml | Boot reads the settings of its starters from fixed keys, so the service writes no setup code Why |
| trap | a setting that ASP.NET Core would reject or warn about | spring.data.mongodb.uri on Boot 4 | Boot 4 ignores the old key with no warning, and connects to localhost:27017 Why |
| twist | services.Configure<LoggingOptions>(section) and IOptions<T> | @ConfigurationProperties("platform.logging") on a class | the class itself is the bean, and @EnableConfigurationProperties registers it Why |
| new | a named options instance for each client | @Bean @ConfigurationProperties("clients.stock") returning new HttpClientProperties() | one reusable class, bound once for each client under its own prefix Why |
| twist | ValidateDataAnnotations().ValidateOnStart() | @Validated on the class and @NotNull on the field | a missing base-url stops the startup, with the field and the prefix in the error Why |
| twist | nested option classes with initialisers | static nested classes with defaults in field initialisers | a key that is not set keeps the default from the code Why |
| trap | "3" for a TimeSpan means 3 days | read-timeout: 3 means 3 milliseconds | a Duration without a unit is milliseconds in Spring, so always write the unit Why |
| twist | "orderId, lineId" bound to a List<string> gives an empty list | mdc-path-variables: orderId, lineId, noteId | Spring splits a comma list and trims the spaces, but .NET binds nothing Why |
| twist | configuration.GetValue<TimeSpan>("key", TimeSpan.FromMinutes(15)) | @Value("${platform.mongo.migrations.lock-ttl:PT15M}") Duration | one value with a default, for a setting that has no properties class Why |
| new | if (configuration.GetValue<bool>("...")) around the registrations | @ConditionalOnProperty(name = ..., havingValue = "true", matchIfMissing = true) | a property switches beans on or off, and a missing property counts as on Why |
| new | Sources.Insert(0, ...) in the library | an EnvironmentPostProcessor that calls addLast | the platform's defaults have the lowest precedence, so every setting of the service wins Why |
| new | no equivalent | META-INF/platform-defaults/*.properties | each package of the platform ships its defaults, and every key states a decision Why |
| trap | System.Text.Json reads a missing bool as false | Jackson 3 rejects a body that leaves out a primitive | the platform sets fail-on-null-for-primitives=false, so an omitted flag means false again Why |
| twist | appsettings, then environment variables, then the command line | the property sources of Spring, from command line down to the platform defaults | the source higher in the list wins, and the platform is always last Why |
| new | dotnet user-secrets and a secrets store | environment variables from the secrets manager | no file of the service holds a secret, and the local profile holds only fake values Why |
| new | no equivalent | spring-configuration-metadata.json from the configuration processor | the IDE completes and explains the platform's keys, from the properties classes Why |
The settings files and the profilestwist#
For the basics of application.yml, profiles and typed settings, see the chapter
Dependency injection and configuration. This chapter shows the
files of the order service, and the layers around them.
{
"Logging": {
"LogLevel": { "Acme": "Debug" }
}
}logging:
level:
com.acme: DEBUGBoth files hold the settings of one environment, here the developer's machine. The line
com.acme: DEBUG sets the log level for every class under com.acme, which
includes the platform. In .NET, the category under LogLevel takes that place.
The service has three files of settings, and each one has a job:
| File | When Boot reads it | What it holds |
|---|---|---|
| src/main/resources/application.yml | always | every key that every environment needs, with placeholders for the values that differ |
| src/main/resources/application-local.yml | when the profile local is active | the addresses of the local containers, and fake secrets |
| src/test/resources/application-wiring.yml | when a test activates the profile wiring | values that connect to nothing, for the wiring test |
| src/test/resources/application.properties | in every test | one key, readable console logs |
A Spring profile is a named set of settings that
you switch on at startup. The local compose file of the service switches on the profile
local with an environment variable:
services:
order-service:
build: .
environment:
SPRING_PROFILES_ACTIVE: localWith SPRING_PROFILES_ACTIVE: local, Boot also reads
application-local.yml, after application.yml. A key in
the profile file overrides the same key in the main file. This works as
appsettings.Development.json does with ASPNETCORE_ENVIRONMENT. Production
needs no profile. It reads application.yml, the environment variables and the
platform's defaults.
The service has no file for production. Every value that differs between environments comes
from an environment variable. So a deployment to a new environment changes no file, and every
environment runs the same application.yml.
The first key of application.yml is spring.application.name, which is
order-service. Boot writes it into every JSON log line. For the log fields, see the
chapter Logging, tracing and health.
A new service needs few keys of its own. The minimum is the name, the database, the token issuer and the path variables for the logs. Everything else has a default in Boot or in the platform.
Placeholders, defaults and the environmenttwist#
var mongoUri = builder.Configuration["MONGO_URI"]
?? throw new InvalidOperationException("MONGO_URI");
var region = builder.Configuration["AWS_REGION"]
?? "eu-west-2";mongodb:
# The Spring Boot 4 key. Boot 4 ignores the old spring.data.mongodb.uri without a warning.
# DocumentDB: mongodb://<user>:<password>@<cluster>:27017/orders?tls=true&replicaSet=rs0&readPreference=primary
# The platform forces retryWrites=false, which DocumentDB needs.
uri: ${MONGO_URI}uri: ${MONGO_URI}${MONGO_URI}is a placeholder. Spring replaces it with the value ofMONGO_URI, which the deployment sets as an environment variable. The value is the whole connection string, including the user and the password.tls=true&replicaSet=rs0&readPreference=primaryThese options of the connection string are what Amazon DocumentDB needs. For each one, see the chapter Storing aggregates in DocumentDB.
A placeholder can carry a default after a colon. The region of the service uses one:
cloud:
aws:
# Credentials come from the task's or pod's IAM role, never from configuration.
region:
static: ${AWS_REGION:eu-west-2}${AWS_REGION:eu-west-2} takes the value of AWS_REGION, or
eu-west-2 when the variable is not set. The queue name works the same way. Use a
default only for a value that is right in most environments. A connection string or a URL of
another service has no safe default, so its placeholder has none.
The README of the service lists every variable that a deployment must set:
| Environment variable | The key it fills | What it is |
|---|---|---|
| MONGO_URI | spring.mongodb.uri | the DocumentDB connection string, with its user and password |
| PLATFORM_MONGO_DOCUMENTDB_CA_BUNDLE | platform.mongo.documentdb.ca-bundle | the path of the mounted AWS certificate bundle |
| OAUTH_ISSUER_URI | spring.security.oauth2.resourceserver.jwt.issuer-uri | the token issuer whose tokens the service accepts |
| OAUTH_TOKEN_URI | ...client.provider.shop-identity.token-uri | where the service gets its own client-credentials token |
| STOCK_CLIENT_ID, STOCK_CLIENT_SECRET | ...client.registration.stock-service.client-id and client-secret | the service's own credentials for the stock service |
| STOCK_SERVICE_URL | clients.stock.base-url | the base URL of the stock service |
| PAYMENT_RECEIVED_QUEUE | messaging.queues.payment-received | the Amazon Simple Queue Service (SQS) queue, if it is not order-service-payment-received |
| AWS_REGION | spring.cloud.aws.region.static | the region, and the credentials come from the Identity and Access Management (IAM) role |
One of these variables is not a placeholder. PLATFORM_MONGO_DOCUMENTDB_CA_BUNDLE
sets its key by relaxed binding, which the next
section explains. Spring would read any key from
the environment in that way. The placeholders are there to make the dependency visible in
application.yml, and to give a short name to a long key.
In ASP.NET Core, the same values are environment variables too, and the configuration reads them with no placeholder. The C# pane above reads them in code. A Spring service writes no such code, because the placeholder in the file does the work.
Environment variables and relaxed bindingtwist#
// ASP.NET Core: a double underscore separates the levels
// Platform__Mongo__DocumentDb__CaBundle=/certs/global-bundle.pem# Settings for every environment. Secrets and environment-specific values come from environment
# variables, which the deployment sets from the secrets manager. Never put a secret in this file.
# The platform's defaults (health probes, JSON logs, graceful shutdown and more) apply already;
# see META-INF/platform-defaults/ in the platform jar. Anything here overrides them.
# Spring Boot binds an environment variable to a key by relaxed binding: for example
# PLATFORM_MONGO_DOCUMENTDB_CA_BUNDLE sets platform.mongo.documentdb.ca-bundle.The top of application.yml states the rule. The variable
PLATFORM_MONGO_DOCUMENTDB_CA_BUNDLE sets a key with no placeholder in the file. This is called
relaxed binding. The Boot reference gives the rules
for the name of the variable:
| Step | From the key platform.mongo.documentdb.ca-bundle |
|---|---|
| Replace each dot with an underscore | platform_mongo_documentdb_ca-bundle |
| Remove each dash | platform_mongo_documentdb_cabundle |
| Convert to upper case | PLATFORM_MONGO_DOCUMENTDB_CABUNDLE |
The service's comment writes CA_BUNDLE, with an underscore where the dash was. That
form binds too. A run for this book showed that both RATES_TIMEOUT_SECONDS and
RATES_TIMEOUTSECONDS set rates.timeout-seconds. A second run, with real
environment variables in a child process, bound these names:
| Environment variable | Bound to |
|---|---|
| CLIENTS_STOCK_BASEURL | clients.stock.base-url |
| CLIENTS_STOCK_READTIMEOUT=250ms | clients.stock.read-timeout |
| CLIENTS_STOCK_MDCPATHVARIABLES_0_ and _1_ | the first and second element of a list |
An element of a list takes its index between underscores. The Boot reference shows the form
MY_SERVICE_0_OTHER. ASP.NET Core writes __0 for the same thing.
A double underscore in the .NET style does not map to a level in Spring. In
ASP.NET Core, Platform__Mongo__DocumentDb__CaBundle marks each level with a double
underscore. Spring needs one underscore for each dot. For the basic form, see the section
Single values and precedence.
The Spring keys that the service setstwist#
Boot reads the settings of each starter from fixed keys. So application.yml holds
the setup that Program.cs would hold in code. The security keys of the service are an
example:
security:
oauth2:
resourceserver:
jwt:
# Spring Security downloads the issuer's signing keys and checks the "iss" claim.
# It also checks the token's "typ" header: "JWT", or no typ header, passes. "at+jwt" is refused.
issuer-uri: ${OAUTH_ISSUER_URI}
# Tokens must be issued for this service.
audiences: order-serviceissuer-uri: ${OAUTH_ISSUER_URI}The token issuer. Spring finds its signing keys from this address, at the first token. For the details, see the section Where the signing keys come from.
audiences: order-serviceA token must name this service in its
audclaim. Spring checks the claim only when this key lists an audience. For the claims, see the section What Spring checks in each token.
Each Spring key of application.yml has one job. The table names its home, where it
is explained in full:
| Key | What it does in the order service | Explained in |
|---|---|---|
| spring.application.name | the name in every log line and trace | dd-obs |
| spring.mongodb.uri | the connection string of DocumentDB, from MONGO_URI | dd-sto |
| spring.security.oauth2.resourceserver.jwt.issuer-uri | the issuer whose tokens the service accepts | dd-sec-keys |
| spring.security.oauth2.resourceserver.jwt.audiences | the audience a token must name, order-service | dd-sec-claims |
| spring.security.oauth2.client.registration.stock-service.* | the service's own client-credentials login for the stock service | dd-sec-s2s |
| spring.security.oauth2.client.provider.shop-identity.token-uri | where that token comes from | dd-sec-s2s |
| spring.cloud.aws.region.static | the AWS region, and credentials come from the IAM role | dd-sec-aws |
The local profile replaces the addresses with the container names of the local environment. It also adds keys that production never sets:
cloud:
aws:
endpoint: http://floci:4566
credentials:
access-key: local
secret-key: localendpoint: http://floci:4566This key points every AWS client at Floci, the local container that provides Amazon Simple Notification Service (SNS) and Amazon Simple Queue Service (SQS). In production the key is not set, so the clients use the real AWS addresses.
access-key: localThe local profile gives fixed credentials for Floci. Production has no such key, because the credentials come from the Identity and Access Management (IAM) role.
The local profile also sets spring.security.oauth2.resourceserver.jwt.jwk-set-uri.
The key tells Spring where to fetch the signing keys inside the Docker network. For why it differs
from the issuer, see the section
A local token issuer, and the issuer-versus-keys trick.
Boot 4 keys that changed their namestrap#
Boot 4 renamed many keys. The metadata of Boot gives each old key a level. At level warning, the IDE marks the key. At level error, Boot no longer reads the key, and the service can start with the wrong value and no error. The MongoDB key is the worst case:
Boot 4 ignores spring.data.mongodb.uri. In a run for this book,
only the old key was set. The client went to localhost:27017, failed with
MongoTimeoutException, and nothing was logged about the old key. The Boot 4 key is
spring.mongodb.uri. For the basic trap, see the section
The MongoDB setting changed its name.
The order service guards against it with a test. The wiring test asks Boot which address it really uses:
/** Spring Boot 4 reads spring.mongodb.uri; the old spring.data.mongodb.uri would be ignored silently. */
@Test
void usesTheConfiguredMongoUri() {
ConnectionString used = context.getBean(MongoConnectionDetails.class).getConnectionString();
assertThat(used.getHosts()).containsExactly("localhost:1");
assertThat(used.getDatabase()).isEqualTo("wiring-test");
}context.getBean(MongoConnectionDetails.class).getConnectionString()MongoConnectionDetailsis the bean from which Boot builds the MongoDB client. Its connection string is the one that Boot really took from the settings.assertThat(used.getHosts()).containsExactly("localhost:1");The wiring profile sets
localhost:1. If someone renamed the key back to the Boot 3 form, Boot would use its default host, and this line would fail.
The metadata of Boot 4.1.2 marks each old key as deprecated, with its replacement. A run for this book read it. These renames matter for a service like this one:
| Boot 3 key | Boot 4 key | What happens to the old key in Boot 4.1.2 |
|---|---|---|
| spring.data.mongodb.uri, and the other driver keys | spring.mongodb.uri | deprecated at level error, and the run showed that it is ignored |
| spring.data.mongodb.uuid-representation | spring.mongodb.representation.uuid | deprecated at level error |
| management.metrics.mongo.* | management.metrics.mongodb.* | deprecated at level error |
| management.otlp.tracing.endpoint | management.opentelemetry.tracing.export.otlp.endpoint | deprecated at level error |
| server.error.include-message, include-stacktrace | spring.web.error.include-message, include-stacktrace | deprecated at level error |
| spring.http.client.read-timeout, connect-timeout | spring.http.clients.read-timeout, connect-timeout | deprecated at level warning |
A key at level error is not a warning to fix later. The old MongoDB key has that level, and the
run shows that Boot does not read it. Treat every key at level error as gone. The plural
spring.http.clients is easy to miss, because the singular is only a warning.
A run for this book checked the error keys on Boot 4.1.2. A controller threw a
ResponseStatusException with the status 400 and the message "secret detail", and
Boot's own error response answered it:
| Setting | The body of the 400 response |
|---|---|
| none | timestamp, status, error and path, with no message |
| server.error.include-message=always, the Boot 3 key | the same, with no message |
| spring.web.error.include-message=always, the Boot 4 key | the same, plus "message":"secret detail" |
So Boot 4.1.2 ignores the old key. The platform's defaults file sets the new keys:
# Boot's own error responses never contain a stack trace or an exception message. Both values
# are Boot's defaults, restated. Boot 4 renamed the keys, and it ignores the old server.error.* keys.
spring.web.error.include-stacktrace=never
spring.web.error.include-message=neverBoth values, never, are the defaults, so the lines change no behaviour today. They state the choice in
the platform's one file of defaults, as the file's other restated keys do. For the error
settings, see the section What a problem never contains.
The IDE marks a deprecated key, because it reads the same metadata. That is the cheapest check of a Boot upgrade. For the other changes of Boot 4, see the chapter Migrating Spring Boot 3 to 4.
Typed settings in the platformtwist#
public class PlatformLoggingOptions
{
public bool Structured { get; set; } = true;
public List<string> MdcPathVariables { get; set; } = [];
}
services.Configure<PlatformLoggingOptions>(
configuration.GetSection("Platform:Logging"));/** Logging settings, bound from {@code platform.logging.*}. */
@ConfigurationProperties("platform.logging")
public class PlatformLoggingProperties {
/** Write JSON (ECS) logs. Set it to false locally for readable console logs. */
private boolean structured = true;
/** Path variables to copy into the MDC for each request, such as {@code orderId}. */
private List<String> mdcPathVariables = new ArrayList<>();
public boolean isStructured() {
return structured;
}
public void setStructured(boolean structured) {
this.structured = structured;
}@ConfigurationProperties("platform.logging")The class receives every key under
platform.logging. The fieldmdcPathVariablestakesplatform.logging.mdc-path-variables. For@ConfigurationProperties, see the section IOptions becomes @ConfigurationProperties.private boolean structured = true;The default is in the field. A service that sets no key gets JSON logs.
public void setStructured(boolean structured) {This class is a JavaBean. Spring creates it with its constructor, then calls a setter for each key it finds. The setters are what makes binding work for a class.
The platform has five such classes. Each
auto-configuration names the classes it needs with
@EnableConfigurationProperties, which makes each one a bean:
@AutoConfiguration(afterName = "org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration")
@EnableConfigurationProperties({PlatformLoggingProperties.class, ResilienceProperties.class})
public class PlatformCoreAutoConfiguration {Here PlatformLoggingProperties and ResilienceProperties become beans.
A library must register its classes this way. The service's scanning never reaches the
platform's packages, so @ConfigurationPropertiesScan on the service would not find
them. The five classes and their prefixes:
| Class | Prefix | Holds |
|---|---|---|
| PlatformLoggingProperties | platform.logging | JSON logs on or off, and the path variables for the mapped diagnostic context (MDC) |
| PlatformSecurityProperties | platform.security | the paths that need no token |
| ResilienceProperties | platform.resilience | the retry and circuit breaker settings, in two nested classes |
| DocumentDbProperties | platform.mongo.documentdb | the path of the certificate bundle |
| MessagingProperties | platform.messaging | the outbox resender and the inbox, in two nested classes |
A record is the other way to write such a class. For the record form, see the section IOptions becomes @ConfigurationProperties. The two ways compare like this:
| Form | What it gets right | What it costs |
|---|---|---|
| a record, bound through its constructor | it cannot change after binding, and it is short | each default needs the annotation @DefaultValue, and a record has no setters for a @Bean method to fill |
| a class with setters, as in the platform | defaults are plain field initialisers, and one class can be bound under several prefixes | it can change after binding, and the getters and setters are long |
The platform uses classes for two reasons. The defaults read as plain Java, and the HTTP client settings are bound once for each client. For a settings type that a single service owns, a record is the better choice.
One properties class, bound once for each clientnew#
A service may call several other services. Each client needs a base URL, two timeouts and maybe a token. If each client had its own properties class, every service would repeat the same fields. So the platform has one class, and each client binds it under its own prefix.
The settings of the stock client, in application.yml:
clients:
stock:
base-url: ${STOCK_SERVICE_URL}
read-timeout: 3s
# Each call carries a token from this client registration
token-registration-id: stock-serviceThe key token-registration-id names the client registration of Spring Security, so
each call carries the service's own token. The client's configuration class binds the keys.
@ConfigurationProperties on a @Bean method fills the object that the
method returns:
/** Binds the platform's reusable properties class to this client's prefix. */
@Bean
@ConfigurationProperties("clients.stock")
HttpClientProperties stockClientProperties() {
return new HttpClientProperties();
}@ConfigurationProperties("clients.stock")Spring binds the keys under
clients.stockinto the returned object, by calling its setters. A second client, sayclients.payment, would have a second method with the other prefix.return new HttpClientProperties();The object starts with the defaults of the class, a connect timeout of 2 seconds and a read timeout of 5 seconds. The settings then override what they set. Here the read timeout becomes 3 seconds, and the connect timeout stays 2 seconds.
The class is not on @EnableConfigurationProperties, because the method makes it a
bean already. The keys of the platform's client class:
| Key under clients.<name> | Default | What it does |
|---|---|---|
| base-url | none, and it is required | the root URL of the other service |
| connect-timeout | 2s | how long to wait for a connection |
| read-timeout | 5s | how long to wait for the response once connected |
| token-registration-id | none | the client registration whose token each call carries |
The closest match in ASP.NET Core is a named options instance, configured once for each client from its own section. For the client that uses these settings, see the chapter Calling other services.
Fail at startup: validated settingstwist#
public class StockOptions
{
[Required] public Uri? BaseUrl { get; set; }
}
builder.Services.AddOptions<StockOptions>()
.BindConfiguration("Clients:Stock")
.ValidateDataAnnotations()
.ValidateOnStart();@Validated
public class HttpClientProperties {
/** The root URL of the other service, such as {@code https://stock.internal}. */
@NotNull
private URI baseUrl;@Validated@Validated, fromorg.springframework.validation.annotation, tells Boot to validate the object after it binds the settings. Validation runs at startup, when Spring creates the bean.@NotNullA constraint of Bean Validation, from
jakarta.validation.constraints. A missingbase-urlleaves the field null, and validation fails.
So a deployment that forgets STOCK_SERVICE_URL never starts. Without the check, the
service would start, pass its probes and fail at the first order. The class comment says the same.
In ASP.NET Core, ValidateOnStart() moves the same check to startup.
A run for this book bound a copy of this class from a @Bean method, on Boot 4.1.2,
with no base URL. The startup failed with this chain of causes:
ConfigurationPropertiesBindException: Error creating bean with name 'stockClientProperties': Could not bind properties to 'Props.HttpClientProperties' : prefix=clients.stock ... BindValidationException: Binding validation errors on clients.stock - Field error in object 'clients.stock' on field 'baseUrl': rejected value [null]; ... default message [must not be null]#clients.stock.base-url, or its environment variable. Do not
remove the constraint to make the service start.What triggers it
clients:
stock:
read-timeout: 3s # base-url is missingThe same run on .NET 10 failed in StartAsync with
OptionsValidationException: DataAnnotation validation failed for 'StockOptions' members:
'BaseUrl' with the error: 'The BaseUrl field is required.'
Validate every setting whose absence would break a request, such as a URL or a queue name.
Leave a setting without a constraint when its default is right. The timeouts carry
@NotNull too, so a service cannot set one to an empty value.
Nested settings with defaults in codetwist#
public class ResilienceOptions
{
public RetryOptions Retry { get; } = new();
public class RetryOptions
{
public int MaxAttempts { get; set; } = 3;
public TimeSpan InitialInterval { get; set; }
= TimeSpan.FromMilliseconds(200);
}
}@ConfigurationProperties("platform.resilience")
public class ResilienceProperties {
private final Retry retry = new Retry();
private final CircuitBreaker circuitBreaker = new CircuitBreaker();
public Retry getRetry() {
return retry;
}
public CircuitBreaker getCircuitBreaker() {
return circuitBreaker;
}
/** How often to try an idempotent call. */
public static class Retry {
/** The number of attempts in total, including the first one. 3 means up to 2 retries. */
private int maxAttempts = 3;private final Retry retry = new Retry();The nested object exists from the start, and the field is
final. There is no setter for it. Spring callsgetRetry()and then binds the keys underplatform.resilience.retryinto that object.public static class Retry {A
staticnested class does not belong to an instance of the outer class. It is like a nested class in C#, which is always independent of the outer instance.private int maxAttempts = 3;The key is
platform.resilience.retry.max-attempts. The comment states what the number means. It counts every attempt, so 3 means one call and up to two retries.
The defaults are in the code, beside the field they belong to. A service that sets nothing gets them all. The keys of this class, with their defaults:
| Key | Default | What it does |
|---|---|---|
| platform.resilience.retry.max-attempts | 3 | the attempts in total, so up to 2 retries |
| platform.resilience.retry.initial-interval | 200ms | the wait before the first retry |
| platform.resilience.retry.multiplier | 2.0 | how much each later wait grows |
| platform.resilience.circuit-breaker.failure-rate-threshold | 50 | the failure rate, in percent, that opens the breaker |
| platform.resilience.circuit-breaker.sliding-window-size | 20 | the number of recent calls the rate is computed over |
| platform.resilience.circuit-breaker.minimum-number-of-calls | 10 | the breaker computes no rate before this many calls |
| platform.resilience.circuit-breaker.wait-duration-in-open-state | 30s | how long the breaker stays open |
Two facts here are easy to state wrongly. max-attempts of 3 is two retries, not
three. And the breaker does not open at "50 percent of 20 calls" alone. It computes no rate at all
before 10 calls. For how the calls use these values, see the chapter
Calling other services.
MessagingProperties has the same shape, with two nested classes, Outbox
and Inbox. Its keys are in the quick reference at the end of this chapter.
Durations and lists from texttrap#
A setting in YAML is text, and Spring converts it to the type of the field. Two conversions surprise a C# developer.
clients:
stock:
base-url: ${STOCK_SERVICE_URL}
read-timeout: 3sShow the answer
A number with no unit is milliseconds for a Duration. The Boot
reference says that the default unit is milliseconds, unless @DurationUnit says
otherwise. A run for this book bound 3 to PT0.003S. The field
readTimeout is a java.time.Duration.
.NET reads the same text differently. In a run on .NET 10, "3" bound to a
TimeSpan gave 3 days, and "3s" failed to convert. Always write the
unit, as in 3s or 250ms.
Spring accepts two forms of a duration. The table shows what a run for this book bound:
| Text in the settings | Duration |
|---|---|
| 3s | PT3S, 3 seconds |
| 250ms | PT0.25S, 250 milliseconds |
| PT15M | PT15M, the ISO-8601 form, 15 minutes |
| 3 | PT0.003S, 3 milliseconds |
A list is the other surprise. The service lists its path variables on one line:
platform:
logging:
# Added to every log line of a request, next to traceId
mdc-path-variables: orderId, lineId, noteIdThe key mdc-path-variables fills the field mdcPathVariables, a
List<String>. Spring splits the text at each comma and trims the spaces. A run for this book gave the list
[orderId, lineId, noteId]. A YAML list, with one - orderId per line,
binds to the same list.
.NET behaves differently. A run on .NET 10 bound the text orderId, lineId, noteId to
a List<string>. The list was empty, and there was no error. .NET needs one key for
each element, as in MdcPathVariables:0. So keep the habit of one key for each element
when you write settings for both platforms.
One value with a default: @Valuetwist#
var lockTtl = configuration.GetValue(
"Platform:Mongo:Migrations:LockTtl",
TimeSpan.FromMinutes(15));MongoMigrationRunner mongoMigrationRunner(MongoTemplate mongo, ObjectProvider<MongoMigration> migrations, Clock clock,
@Value("${platform.mongo.migrations.lock-ttl:PT15M}") Duration lockTtl) {
return new MongoMigrationRunner(mongo, migrations.orderedStream().toList(), clock, lockTtl);
}@Value("${platform.mongo.migrations.lock-ttl:PT15M}") Duration lockTtl@Valueinjects one setting into a parameter. After the colon is the default, 15 minutes in the ISO-8601 form. Spring converts the text to aDuration, as it does for a properties class.
A placeholder works in an annotation too. The queue listener takes its queue name from the settings, so each environment can name its own queue:
/** The queue name comes from configuration, so each environment can name its own queue. */
@SqsListener("${messaging.queues.payment-received}")
void on(PaymentReceivedV1 event) {
process(event);
}@SqsListener resolves ${messaging.queues.payment-received} at startup.
The key is set in application.yml, with the default
order-service-payment-received. For the listener, see the chapter
Consuming events: SQS and the inbox.
Prefer a properties class for a setting that belongs to a group. The lock time to live (TTL) is
the one
platform setting outside a class. So the IDE has no description for it, and its default is in an
annotation, not beside the other migration keys. A field in a properties class, such as
platform.mongo.migrations, would fix both. @Value is right for a single
value that one method needs, such as the queue name of one listener.
Switches: @ConditionalOnPropertynew#
A wiring test must not run the migrations, and a service with no events must not start the outbox. The platform lets a property switch such a part off. The switch is an annotation on the bean or on the whole auto-configuration:
/** Runs the migrations at startup. Slice and wiring tests turn it off with the property. */
@Bean
@ConditionalOnProperty(name = "platform.mongo.migrations.enabled", havingValue = "true", matchIfMissing = true)
MongoMigrationRunner mongoMigrationRunner(MongoTemplate mongo, ObjectProvider<MongoMigration> migrations, Clock clock,@ConditionalOnProperty(name = "platform.mongo.migrations.enabled", havingValue = "true", matchIfMissing = true)Spring creates the bean only when the property has the value
true.matchIfMissing = truealso creates it when the property is not set at all. So the runner is on by default, and a test turns it off withenabled: false.
A run for this book checked the three cases of this condition:
| platform.messaging.enabled | The bean |
|---|---|
| not set | present, because of matchIfMissing |
| false | absent |
| TRUE, in upper case | present, because the value is compared without case |
On a whole class, the condition switches every bean of that class. The messaging auto-configuration carries it, so one property removes the outbox and the inbox:
/**
* The outbox and the inbox. A service that sends and receives no events turns all of it off
* with {@code platform.messaging.enabled=false}, together with Spring Cloud AWS's
* {@code spring.cloud.aws.sns.enabled=false} and {@code spring.cloud.aws.sqs.enabled=false}.
*
* <p>The resender is in its own nested configuration, which also turns on scheduling. So
* scheduling is on only when the resender is on, and a service sees it in one property:
* {@code platform.messaging.outbox.resend-enabled}.
*/
@AutoConfiguration(after = PlatformMongoAutoConfiguration.class)
@ConditionalOnProperty(name = "platform.messaging.enabled", havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(MessagingProperties.class)
public class PlatformMessagingAutoConfiguration {The @ConditionalOnProperty on the class switches off every bean of the class,
including the nested resender configuration. A service with no events also sets three properties
of Spring Cloud AWS and Spring Modulith. For the four properties, see the section
Switching a whole area off.
The wiring test sets only spring.cloud.aws.sqs.enabled: false and the two platform
switches for the migrations and the resender. It keeps the outbox, so the test checks its wiring
too.
In ASP.NET Core, the same switch is an if around the registrations in
Program.cs. Spring keeps the switch on the bean itself, so the library decides it and
the service only sets the property.
The platform's defaults, below everything elsenew#
Every service of the shop needs the same health probes, JSON logs and safe error responses. The
platform could ask each service to copy twenty keys into its application.yml. Copies
drift. So the platform adds the keys itself, at the lowest precedence. Any service can still
override any of them.
A class that changes the settings before the application context exists is called an environment post-processor. The platform has one:
public class PlatformDefaultsEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {
private static final String DEFAULTS = "classpath*:META-INF/platform-defaults/*.properties";
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
try {
Resource[] files = new PathMatchingResourcePatternResolver().getResources(DEFAULTS);
Arrays.sort(files, Comparator.comparing(Resource::getFilename));
for (Resource file : files) {
environment.getPropertySources().addLast(new PropertiesPropertySource(
"platform-defaults:" + file.getFilename(), PropertiesLoaderUtils.loadProperties(file)));
}
} catch (IOException e) {
throw new UncheckedIOException("Could not load the platform defaults", e);
}
if (environment.getProperty("platform.logging.structured", Boolean.class, true)) {
environment.getPropertySources().addLast(new MapPropertySource("platform-defaults:structured-logging",
Map.of("logging.structured.format.console", "ecs")));
}
}
@Override
public int getOrder() {
// Run after Spring Boot has loaded application.yml, so the check above can read it.
return Ordered.LOWEST_PRECEDENCE;
}
}implements EnvironmentPostProcessor, Ordered {In Boot 4, the interface is
org.springframework.boot.EnvironmentPostProcessor. The Boot 3 typeorg.springframework.boot.env.EnvironmentPostProcessorstill exists, and is deprecated since 4.0.0.Orderedlets the class say when it runs among the other post-processors."classpath*:META-INF/platform-defaults/*.properties"The prefix
classpath*:finds the matching files in every Java archive (JAR) on the classpath, not only in the first one. The platform has three files, one for each of its packages.new PathMatchingResourcePatternResolver().getResources(DEFAULTS)This Spring class turns the pattern into a list of
Resourceobjects. AResourceis a file that Spring can read, from a JAR or from a folder.Arrays.sort(files, Comparator.comparing(Resource::getFilename));The files are sorted by name:
core.properties,messaging.properties,mongo.properties. A sorted order is the same on every machine.environment.getPropertySources().addLast(new PropertiesPropertySource(The environment holds an ordered list of property sources. For each key, Spring takes the value from the first source that has it.
addLastputs the file at the end, below every other source.PropertiesLoaderUtils.loadPropertiesreads the file into aPropertiesobject.if (environment.getProperty("platform.logging.structured", Boolean.class, true)) {This condition reads the settings that exist already, with
trueas the default. A properties file cannot say "only if another key is not false". So this one default is code.Map.of("logging.structured.format.console", "ecs")This key makes Boot write each console log line as JSON in the Elastic Common Schema (ECS). A
MapPropertySourceholds keys from a map in code.return Ordered.LOWEST_PRECEDENCE;The order of the post-processor, not of its keys. Boot's own post-processor that loads
application.ymlhas the orderHIGHEST_PRECEDENCE + 10. This one runs after it, so the condition above can read the service's setting.
Boot finds the class through one line in the platform's JAR:
# EnvironmentPostProcessor is still registered here, not in the AutoConfiguration.imports file.
org.springframework.boot.EnvironmentPostProcessor=\
com.acme.platform.core.config.PlatformDefaultsEnvironmentPostProcessorThe key of the line, org.springframework.boot.EnvironmentPostProcessor, is the Boot 4
interface. A run for this book registered one processor under
this key and one under the deprecated Boot 3 key. Both ran on Boot 4.1.2. So an old registration
still works, but it is deprecated. The post-processors run before logging starts. In Boot 4.1.2,
the listener that runs them has the order HIGHEST_PRECEDENCE + 10, and the logging
listener HIGHEST_PRECEDENCE + 20. So the platform's logging defaults reach the logging
system in time.
The platform's files come below every other source. The diagram shows the order, from command-line arguments and Java system properties down to the platform's files:
Two tests of the platform check the behaviour:
@Test
void addsTheDefaultsWithTheLowestPrecedence() {
StandardEnvironment environment = new StandardEnvironment();
environment.getPropertySources().addFirst(new MapPropertySource("application.yml",
Map.of("server.shutdown", "immediate")));
postProcessor.postProcessEnvironment(environment, new SpringApplication());
assertThat(environment.getProperty("server.shutdown")).isEqualTo("immediate");
assertThat(environment.getProperty("spring.data.mongodb.auto-index-creation")).isEqualTo("false");
assertThat(environment.getProperty("logging.structured.format.console")).isEqualTo("ecs");
}environment.getPropertySources().addFirst(new MapPropertySource("application.yml",The test replaces the service's file with a map source. It sets
server.shutdown, whichcore.propertiesalso sets.assertThat(environment.getProperty("server.shutdown")).isEqualTo("immediate");The service's value wins over the platform's
graceful.assertThat(environment.getProperty("spring.data.mongodb.auto-index-creation")).isEqualTo("false");A key that only the platform sets comes through.
The second test, leavesStructuredLoggingOffWhenTheServiceAsks, sets
platform.logging.structured to false and checks that the ECS key is absent.
.NET has no post-processor. A library that wants the same effect inserts its source at the start
of the list, because in .NET a source added later wins. A run on .NET 10 did this with
builder.Configuration.Sources.Insert(0, ...). A command-line argument then won over the
library, and a key that only the library set came through.
The alternative is a defaults file that each service copies, or a profile in the platform JAR that each service activates. Both are simple. A copied file goes stale in every service at a different time, and a profile can be forgotten. A post-processor needs no step in the service, and it cannot be skipped. Use it for defaults that every service must have.
What the platform sets, and whynew#
Each package of the platform has its own file of defaults. Every key in them is a decision, and the comment beside it gives the reason. Some keys restate a Boot default on purpose, as the top of the core file explains:
# Defaults that every service gets from the platform's core package.
# They have the LOWEST precedence: a service's application.yml overrides any of them.
# Some keys restate a Spring Boot 4.1 default. They stay here on purpose: the platform's
# behaviour is visible in one file, and a Boot upgrade that changes a default cannot change it.
# Health probes for the orchestrator (a Boot default, restated):
# /actuator/health/liveness - is the process alive? Never include other services or the database.
# /actuator/health/readiness - can it take traffic? The mongo defaults add the database.
management.endpoint.health.probes.enabled=true
management.endpoints.web.exposure.include=health,info
management.endpoint.health.show-details=never
# Tracing. Every request gets a traceId in the MDC, even when it is not sampled.
# Lower the sampling rate in production if volumes are high.
management.tracing.sampling.probability=1.0
# Traces are exported only when management.opentelemetry.tracing.export.otlp.endpoint is set.
# Metrics would be exported to localhost:4318 by default, so they are off until a service
# sets management.otlp.metrics.export.enabled=true and management.otlp.metrics.export.url.
management.otlp.metrics.export.enabled=false
# Finish the requests in flight when the service is stopped (graceful is a Boot default, restated).
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=20sThe comments give the reason next to each key. For example,
management.tracing.sampling.probability=1.0 keeps every trace. Each key of
core.properties, with the Boot 4.1.2 default and the reason for the platform's
value:
| Key | Platform value | Boot 4.1.2 default | Why |
|---|---|---|---|
| management.endpoint.health.probes.enabled | true | true | restated, so the probes stay on even if a Boot release changes the default |
| management.endpoints.web.exposure.include | health,info | health | info is added, which the platform's security also leaves public |
| management.endpoint.health.show-details | never | never | restated, so a probe shows the status and no details |
| management.tracing.sampling.probability | 1.0 | 0.1 | every trace is kept, and a busy service lowers the rate |
| management.otlp.metrics.export.enabled | false | true | with no collector, Boot would push metrics to localhost:4318 and log failures |
| server.shutdown | graceful | graceful | restated, so requests in flight finish when an instance stops |
| spring.lifecycle.timeout-per-shutdown-phase | 20s | 30s | a real change, so each phase of the shutdown waits at most 20 seconds |
| spring.jackson.deserialization.fail-on-null-for-primitives | false | true in Jackson 3 | an omitted boolean means false again, as the next section shows |
| spring.web.error.include-stacktrace and include-message | never | never | Boot's own error page shows no stack trace and no message, and Boot 4 ignores the old server.error names |
| logging.structured.json.customizer | PersonalDataMaskingCustomizer | none | JSON logs mask e-mail addresses and other personal data |
| springdoc.writer-with-order-by-keys | true | false | the generated OpenAPI file lists keys in a fixed order, so it changes only with the API |
| springdoc.default-produces-media-type | application/json | not checked | the document says JSON instead of */* |
| springdoc.api-docs.enabled and swagger-ui.enabled | true and false | not checked, and false | the document stays on for the drift check, and the local profile turns Swagger UI on |
The trace ID is in the mapped diagnostic context (MDC) and in each log line even when a trace is not sampled. A run for this book showed it with the default rate of 0.1. So a lower rate in production loses traces, but never the IDs in the logs. For tracing, probes and shutdown, see the chapter Logging, tracing and health. For the OpenAPI keys, see the chapter API and event contracts.
The mongo package adds four keys:
# Indexes are created by migrations (MongoMigration), never by Spring at startup.
# Startup index creation is invisible, nobody reviews it, and it can block on a large collection.
spring.data.mongodb.auto-index-creation=false
# The service is ready for traffic only when the database is reachable.
# Liveness deliberately leaves the database out: a database outage must not restart every instance.
management.endpoint.health.group.readiness.include=readinessState,mongoThe excerpt shows two of the four keys. auto-index-creation=false leaves every index
to the migrations. The table gives all four:
| Key | Platform value | Default | Why |
|---|---|---|---|
| spring.data.mongodb.auto-index-creation | false | off in Spring Data | restated, because every index comes from a reviewed migration |
| management.endpoint.health.group.readiness.include | readinessState,mongo | readinessState only | readiness fails when the database is down, and liveness does not |
| spring.mongodb.representation.uuid | standard | unspecified | Boot 4 expects an explicit choice, and the outbox stores UUIDs |
| spring.data.mongodb.representation.big-decimal | decimal128 | unspecified | money stays exact even in a field with no type mapping, such as an event in the outbox |
The Boot 4 migration guide says that "An explicit configuration is expected" for the two representations. For the stored types, see the chapter Storing aggregates in DocumentDB.
The messaging package sets the outbox and the queues:
| Key | Platform value | Default | Why |
|---|---|---|---|
| spring.modulith.events.completion-mode | delete | update | a sent event is removed, so the collection stays small |
| spring.modulith.events.mongodb.transaction-management.enabled | false | true | the platform's mongo package already declares the transaction manager |
| spring.modulith.events.republish-outstanding-events-on-restart | false | false | restated, because the resender sends unsent events on a schedule |
| spring.modulith.events.externalization.enabled | true | true | restated, so the events reach SNS |
| spring.cloud.aws.sqs.queue-not-found-strategy | fail | create, in the code | a missing queue stops the startup, instead of a new queue with no dead-letter queue |
| spring.modulith.events.staleness.resubmitted | PT15M | not set, so the monitor is off | a row that stays RESUBMITTED for 15 minutes goes back to FAILED, and the resender takes it again |
For each of these, see the chapters Publishing events: the outbox and Consuming events: SQS and the inbox.
Jackson 3 and a body that leaves out a booleantrap#
A note can need action, or not. The request to add a note has a boolean for that, and
a client may leave it out when it is false. Jackson, the JSON
library of Spring, reads the body:
public record AddNoteRequest(
@NotBlank @Size(max = 2000) String text,
@Schema(description = "A NOTE_CATEGORY code", example = "DELIVERY") @NotBlank String category,
@Schema(description = "Whether an agent must act on the note before the order ships") boolean actionRequired) {
}The last component, boolean actionRequired, is a primitive. A primitive cannot be
null, so a body without the field gives Jackson no value to put there.
Jackson 3 refuses a body that leaves out a primitive. In Jackson 3.1.7,
FAIL_ON_NULL_FOR_PRIMITIVES is on, also in the mapper that Boot 4.1.2 builds. A body
without actionRequired fails with MismatchedInputException, and Spring
model-view-controller (MVC) answers 400 with the detail "Failed to
read request". Jackson 2 had the feature off, so the same request worked on Boot 3. In a run on
.NET 10, System.Text.Json with the web defaults read the same body as ActionRequired =
False. For the prediction, see the section
Jackson 3: what changed for request bodies.
The platform restores the old behaviour with one key, in core.properties:
# Jackson 3 rejects a JSON body that leaves out a primitive field (boolean, int). Treat a missing
# primitive as its default value instead, so an omitted "actionRequired" means false.
spring.jackson.deserialization.fail-on-null-for-primitives=falsespring.jackson.deserialization is a map from a feature of Jackson to on or off. The
key after it is the name of the feature in lower case with dashes. A run for this book showed that
the key works. The body then gave actionRequired=false, and so did an explicit
null. System.Text.Json still rejected an explicit null for a
bool in the .NET run.
A slice test of the service records the behaviour, so a change of the platform would fail the build:
/** Jackson 3 would reject the missing primitive; the platform's default makes it false. */
@Test
void anOmittedFlagMeansFalse() throws Exception {
when(notes.addNote(ORDER_ID, new NewNote("FYI", "GENERAL", false))).thenReturn("note-2");
mvc.perform(post("/orders/" + ORDER_ID + "/notes").with(AGENT).contentType(MediaType.APPLICATION_JSON)
.content("""
{ "text": "FYI", "category": "GENERAL" }
"""))
.andExpect(status().isCreated());
verify(notes).addNote(eq(ORDER_ID), eq(new NewNote("FYI", "GENERAL", false)));
}The test posts the body without the flag and expects 201. It then checks that the service
received false. There is a careful alternative. A Boolean wrapper with
@NotNull would force every client to send the flag, and the contract would say so.
The platform chose the default, so a client that worked against a Boot 3 service keeps working. For
the other Jackson 3 defaults, see the chapter The web layer in depth.
Which source winstwist#
For the moment in a start when Spring reads every source, see the chapter Starting the service: from main() to the first request.
The same key can come from several sources. Spring takes the first source in its list that has the key. A run for this book printed that list for an application with a post-processor like the platform's. The order, first wins, for the order service:
| Position | Source | In the order service |
|---|---|---|
| 1 | command-line arguments, such as --clients.stock.base-url=... | the README uses them to run the service from an IDE |
| 2 | Java system properties, set with -D | not used |
| 3 | OS environment variables | every value that differs between environments |
| 4 | application-{profile}.yml | application-local.yml, and application-wiring.yml in a test |
| 5 | application.yml | every key that every environment needs |
| 6 | the platform defaults, added last | core.properties, then messaging.properties, mongo.properties and the ECS switch |
| 7 | the defaults in code | the field initialisers of the properties classes, and the default of a @Value |
The README shows position 1. To run the service from an IDE, it overrides the container names with arguments:
--spring.mongodb.uri=mongodb://localhost:27017/orders?directConnection=true
--spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:8090/default/jwks
--spring.security.oauth2.client.provider.shop-identity.token-uri=http://localhost:8090/default/token
--spring.cloud.aws.endpoint=http://localhost:4566
--clients.stock.base-url=http://localhost:8089Each argument, such as --clients.stock.base-url, wins over application-local.yml, which is still
active. So only the addresses change, and every other local setting stays.
A test can set keys above all of these, with @SpringBootTest(properties = ...). The
journey test does this. The full list of the Boot reference has more sources, such as
SPRING_APPLICATION_JSON. For that list, see the section
Single values and precedence.
Between the platform's own files, the order of the file names decides. core.properties
is added first, so it wins over messaging.properties for a key that both set. No key
is in two files today.
The order in ASP.NET Core has the same idea, with the opposite rule. A source added later wins, so a library's defaults go at index 0.
Secrets never in a filenew#
A committed password stays in the history of the repository for ever. So no file of the order service holds a secret. Each secret reaches the service as an environment variable, which the deployment sets from the secrets manager. The client secret for the stock service is an example:
registration:
stock-service:
provider: shop-identity
client-id: ${STOCK_CLIENT_ID:order-service}
client-secret: ${STOCK_CLIENT_SECRET}
authorization-grant-type: client_credentials
scope: stock.reserveclient-id: ${STOCK_CLIENT_ID:order-service}The client ID is not a secret, so it has a default.
client-secret: ${STOCK_CLIENT_SECRET}The secret has no default in
application.yml. Only the local profile sets a fake one.
The secrets of the service, and where each one comes from:
| Secret | In production | Locally |
|---|---|---|
| the database user and password | inside MONGO_URI, from the secrets manager | no password, the local MongoDB has none |
| the client secret for the stock service | STOCK_CLIENT_SECRET, from the secrets manager | local, in application-local.yml, which the mock issuer accepts |
| AWS credentials | none, the IAM role of the task or pod | the fixed access-key local, for Floci |
The local profile is committed, because its values are not secret. They work only against the containers of the local environment. For a real password on a developer's machine, see the section Secrets. It uses a local file that git ignores. The shop needs no such file, because nothing local is real.
A library such as Spring Cloud AWS can also read the secrets manager at startup. The service
then writes spring.config.import=aws-secretsmanager: with the names of its secrets.
That removes the step in the deployment. It also ties the startup to AWS, and the service then needs the permission to read
secrets. The shop keeps the environment variables, so the service reads its settings in one way in
every environment.
Two more rules keep secrets out of the logs. Boot's own error responses contain no exception message, by default. And the JSON logs mask personal data. For both, see the chapters The web layer in depth and Logging, tracing and health.
Completion in the IDE: the configuration metadatanew#
A developer types platform.resilience. in application.yml and expects
the IDE to list the keys. The IDE needs a description of the keys for that. Boot's own keys have
one in each Boot JAR. The platform's keys need one too.
The description is a file, META-INF/spring-configuration-metadata.json. The Spring
Boot configuration processor writes it at compile time, from the
@ConfigurationProperties classes. The parent Project Object
Model (POM) runs the processor for every project.
For that setting and its trap, see the section
The configuration processor, and the file it did not write.
A build of the companion wrote the file into the platform's jar. It held 16 keys in 9 groups.
Each key had its type, its default and a description. The description is the
/** ... */ comment on the field:
/** The number of attempts in total, including the first one. 3 means up to 2 retries. */
private int maxAttempts = 3;The file lists the field maxAttempts as
platform.resilience.retry.max-attempts, of type
java.lang.Integer, with the default 3 and the comment as its description. The first
build showed one key without a description, platform.resilience.retry.multiplier.
Its field had no comment, so the platform added one. A key with no comment still completes in the
IDE, but the IDE cannot explain it.
The order service's jar got a file too, with the four keys of clients.stock. The
service binds them with @ConfigurationProperties("clients.stock") on a
@Bean method, to the platform's class HttpClientProperties. The fields of
that class have comments and initial values. Yet the service's file listed the four keys with
their types only, with no description and no default. The class comes compiled, in the
platform's jar, and the service's build did not find its comments or its defaults there. So the
IDE completes these keys, but cannot explain them. The platform's own keys, compiled from source,
have both.
The metadata also lists deprecated keys with their replacement. That is how the IDE marks a
Boot 3 key in a Boot 4 service. A key that is not in any metadata, such as
platform.mongo.migrations.lock-ttl from a @Value, gets no completion and
no warning.
1An expert asksThe platform's post-processor adds its files with addLast, and its
getOrder() returns LOWEST_PRECEDENCE. What does each method do?
addLast, and its
getOrder() returns LOWEST_PRECEDENCE. What does each method do?addLast places the defaults at the end of the list of property sources. Spring
takes a key from the first source that has it, so every setting of the service wins.
getOrder() decides when the post-processor runs. It runs after Boot's post-processor
that loads application.yml, so it can read platform.logging.structured
before it adds the ECS switch.2An expert asksWhy does the platform validate base-url with @Validated
and @NotNull, instead of letting the first call fail?
base-url with @Validated
and @NotNull, instead of letting the first call fail?3TranslateWrite this options setup in the style of the order service. The stock client's
settings are bound from clients.stock, and a missing base URL stops the startup:
builder.Services.AddOptions<StockOptions>()
.BindConfiguration("Clients:Stock")
.ValidateDataAnnotations()
.ValidateOnStart();
clients.stock, and a missing base URL stops the startup:
builder.Services.AddOptions<StockOptions>()
.BindConfiguration("Clients:Stock")
.ValidateDataAnnotations()
.ValidateOnStart();/** Binds the platform's reusable properties class to this client's prefix. */
@Bean
@ConfigurationProperties("clients.stock")
HttpClientProperties stockClientProperties() {
return new HttpClientProperties();
}The validation is on the class. HttpClientProperties carries
@Validated. Its field baseUrl carries @NotNull. Spring
validates when it creates the bean, so no extra call is needed.
Quick reference#
The platform's properties, with their defaults:
| Property | Default | What it does |
|---|---|---|
| platform.logging.structured | true | JSON (ECS) console logs, and the local profile sets false |
| platform.logging.mdc-path-variables | none | path variables copied into the MDC for each request |
| platform.security.public-paths | /actuator/health/**, /actuator/info | the paths that need no token |
| platform.resilience.retry.max-attempts | 3 | calls in total, so up to 2 retries |
| platform.resilience.retry.initial-interval | 200ms | the first wait, and later waits grow with random spread |
| platform.resilience.retry.multiplier | 2.0 | how much each wait grows |
| platform.resilience.circuit-breaker.failure-rate-threshold | 50 | the failure rate, in percent, that opens the breaker |
| platform.resilience.circuit-breaker.sliding-window-size | 20 | the calls the rate is computed over |
| platform.resilience.circuit-breaker.minimum-number-of-calls | 10 | no rate is computed before this many calls |
| platform.resilience.circuit-breaker.wait-duration-in-open-state | 30s | how long the breaker stays open |
| clients.<name>.base-url | none, required | the root URL of one other service |
| clients.<name>.connect-timeout | 2s | how long one client waits for a connection |
| clients.<name>.read-timeout | 5s | how long one client waits for a response |
| clients.<name>.token-registration-id | none | the client registration whose token each call carries |
| platform.mongo.documentdb.ca-bundle | none | the AWS certificate bundle for TLS, and it is not set locally |
| platform.mongo.migrations.enabled | true | run the migrations at startup |
| platform.mongo.migrations.lock-ttl | PT15M | how long a migration lock lasts, longer than the slowest migration |
| platform.messaging.enabled | true | the outbox publisher, the inbox and the resender |
| platform.messaging.outbox.resend-enabled | true | the scheduled resender, and scheduling with it |
| platform.messaging.outbox.resend-interval | PT1M | how often the resender runs |
| platform.messaging.outbox.resend-older-than | PT5M | only events older than this are resent |
| platform.messaging.outbox.lock-at-most-for | PT5M | the longest one instance holds the resender lock |
| platform.messaging.inbox.retention | P14D | how long processed event IDs are kept, by a TTL index |
Use cases: the application service#
Each feature of the order service has one application service. Its public methods are the use cases, such as placing or shipping an order. A use case follows the same steps every time. It loads, lets the domain apply its rules, saves, publishes the events and writes an audit entry. It holds no business rule. This chapter covers each use case, the calls between features, and the tests.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | one handler class per command, as with MediatR | one application service per feature, such as OrderService | reads and changes of a feature are on one class, until it grows past about ten use cases Why |
| new | no equivalent | load, rule and change, save, publish, audit | every use case that changes data has the same steps, so a reader finds each step in the same place Why |
| twist | an action that takes a DTO and returns the entity | parameters are value objects, results are IDs or views | a caller cannot skip a rule, because no aggregate leaves the service Why |
| new | no equivalent | OrderView leaves out the customer's contact | a view carries personal data only when a use case needs it Why |
| twist | an interface in the application project | StockChecker in order/application/port | calling another service is a step of the use case, so its port belongs to the application layer Why |
| new | no equivalent | the stock call before TransactionTemplate.execute | a slow HTTP call never holds a DocumentDB transaction open, and the order ID is the idempotency key Why |
| trap | context.SaveChanges(), then keep using the same entity | Order saved = orders.save(order) | the instance passed in keeps the old version, so the use case continues with the returned copy Why |
| new | no equivalent | the order and its first notes in one transaction | one request saves two aggregates, because the agent sees it as one action Why |
| new | no equivalent | NoteService.addNotes(orderId, List<NewNote>) | features call each other only through the application service, with plain values Why |
| twist | a second service class in the same style | NoteService, with String IDs and a Spring Data repository | the light style has the same steps, with fewer types Why |
| twist | a validation attribute that reads a lookup table | ReferenceDataService.requireActive(type, code, field) | the rule "a code must be an active lookup value" is written once, and every feature asks it Why |
| new | no equivalent | if (order.recordPayment(payment)) | the only if in a use case decides the next step, and never a business rule Why |
| twist | an action per verb on the same service | pickLine, cancel, get and list | the short use cases have the same steps, and the reads return views Why |
| new | no equivalent | the journeys of a request, a message and an event | each step of a journey has one home, so a bug is found by its step Why |
| new | no equivalent | OrderFulfilmentService beside OrderService | a large service is split by area of the business, not into a read side and a write side Why |
| twist | a unit test of the service with Moq | OrderServiceTest with Mockito and a real TransactionTemplate | the test checks the order of the steps, with the real domain and mocked ports Why |
One application service per featuretwist#
A use case is one thing that a user or another system wants to do, such as placing an order. The class that holds the use cases of a feature is called its application service. The order service has three features, so it has three application services:
| Feature | Application service | Its public methods |
|---|---|---|
| order | OrderService | place, pickLine, ship, cancel, recordPayment, get, list, requireExists |
| note | NoteService | addNote, addNotes, resolveAction, list, countNeedingAction |
| reference | ReferenceDataService | list, requireActive |
Every caller goes through these classes. The controllers call them for HTTP requests. The
message listener calls OrderService.recordPayment for a payment. And the features call
each other through them. The class comment of OrderService states the role:
/**
* The use cases of the order feature. This is the one entry point for the controller, the
* message listener and other features.
*
* <p>Every change follows the same steps: load, let the domain apply its rules and change
* itself, save, publish the events, record the audit. The steps that call other services run
* before the transaction. This class decides the order of the steps and never contains a
* business rule: a line that a business expert would recognise as a rule belongs in the domain.
*
* <p>Methods take value objects, which are valid already, and return IDs or views, never an
* aggregate.The reads are on the same class as the changes. The reads get and
list need no class of their own. The service does not separate commands from
queries, which is called
command query responsibility segregation (CQRS). For why the service has no CQRS, see the chapter
Features, layers and the rules.
Older designs, and many examples on the web, use one class for each use case. You may meet
classes such as PlaceOrder and OrderQueries. Each command then has a
record, such as PlaceOrderCommand. The two styles compare like
this:
| Style | What it gets right | What it costs |
|---|---|---|
| one service per feature, as here | a reader finds every use case of a feature in one file, and shared steps stay private | the class grows with the feature, and must be split by hand |
| one class per use case, with a command record | each class is small, and a pipeline can wrap every command the same way | many small files, and the shared steps move to a base class or a helper |
In .NET, the second style often goes with MediatR, with one handler for each request. It works
well when every command needs the same steps around it, such as validation and logging. The book
still recommends one service per feature for a service of this size. Ten use cases fit in one
file, and the private helper saveAndPublish keeps the steps the same. Choose one class
per use case when a feature has many use cases that share nothing, or when a team already runs a
command pipeline.
The steps of a use casenew#
A new developer must add a use case to cancel a line. Where does the rule go? Where is the event published? If every use case had its own shape, each answer would need a search. So every use case that changes data follows the same steps, in the same order.
The use case ship shows every step:
public async Task Ship(OrderId orderId)
{
var order = await orders.Get(orderId);
var notesNeedingAction =
await notes.CountNeedingAction(orderId.Value);
shippingPolicy.AssertCanShip(order, notesNeedingAction);
order.Ship(clock);
await SaveAndPublish(order, AuditActions.OrderShipped);
}/** Ships the order. The rule across the order and its notes is checked by the domain service. */
@Transactional
public void ship(OrderId orderId) {
Order order = orders.get(orderId); // load
long notesNeedingAction = notes.countNeedingAction(orderId.value()); // ask the note feature
shippingPolicy.assertCanShip(order, notesNeedingAction); // rule across aggregates
order.ship(clock); // rule and change
saveAndPublish(order, AuditActions.ORDER_SHIPPED); // save, publish, audit
}@TransactionalThe whole use case runs in one transaction. The proxy of the bean starts it before the first line, and commits after the last one. For the proxy, see the section Where the service relies on proxies.
Order order = orders.get(orderId); // loadgetis a default method of the repository port. It throwsOrderNotFoundExceptionwhen no order has the ID. So the use case loads the order in one line, and the platform answers 404.long notesNeedingAction = notes.countNeedingAction(orderId.value()); // ask the note featureThe order feature does not read notes. It asks the note feature for a number, and passes the ID as plain text.
shippingPolicy.assertCanShip(order, notesNeedingAction); // rule across aggregatesA rule that needs data from two aggregates is in a domain service,
ShippingPolicy. For where each rule goes, see the chapter Where each rule goes.order.ship(clock); // rule and changeThe aggregate checks its own rules, paid and every line picked, and changes its status. It raises the event
OrderShippedand keeps it until it is pulled.saveAndPublish(order, AuditActions.ORDER_SHIPPED); // save, publish, auditOne private helper does the last three steps. See the section Continue with the saved copy.
The method has no permission annotation. The controller checks the scope before it calls the method. So the payment listener and other features call the same method with no identity. For why, see the section Choosing the place by the shape of the service.
Two agents can change the same order at the same time. Both agents load the order, and both ship it. The second save fails, because its version is old. The comment of the repository port names the two ways in which a save fails. The platform answers both with 409:
/**
* Saves the aggregate and returns the stored copy, which holds the new version.
*
* <p>Continue with the returned instance. The instance you passed in keeps the old version,
* so saving it a second time fails. For a new aggregate it fails as a duplicate insert, with
* {@code DuplicateKeyException}; for a loaded one, as a concurrent update. Saving also fails
* with Spring's {@code OptimisticLockingFailureException} when someone else saved the
* aggregate after you loaded it; that exception is unchecked, so this plain-Java interface
* can only name it here.
* Inside a transaction, MongoDB usually reports the same conflict as a write conflict, which
* Spring translates to {@code DataIntegrityViolationException}. The platform answers both with 409.The use case catches no exception. When OptimisticLockingFailureException leaves the
method, the transaction rolls back. So the event and the audit record of the failed attempt are
never stored. The audit log writes with
MongoTemplate, which joins the running transaction. The agent gets 409 with the code
CONCURRENT_UPDATE and loads the order again. For versions and
write conflicts, see the chapters Storing aggregates in DocumentDB and
Transactions in depth.
Parameters are value objects, results are IDs or viewstwist#
public PlacedOrder Place(CustomerContact customer,
DeliveryMethod deliveryMethod, List<OrderItem> items,
List<NewNote> firstNotes)
{
throw new NotImplementedException();
}public PlacedOrder place(CustomerContact customer, DeliveryMethod deliveryMethod, List<OrderItem> items,
List<NewNote> firstNotes) {Each parameter of place is a value
object of the domain, except the notes. A value
object checks itself when it is created. new DeliveryMethod("") throws, and the
platform answers 400 with the field. So the use case never checks a parameter. The controller
builds the value objects from the request, as the section
One request through the checks shows.
The result is a small record with the new IDs:
/**
* The result of placing an order: only the new IDs. The caller reads the order again if it
* needs more.
*/
public record PlacedOrder(String orderId, List<String> noteIds) {
public PlacedOrder {
noteIds = List.copyOf(noteIds);
}
}public PlacedOrder {This is a compact constructor. It runs before the fields are set, and can replace a parameter.
noteIds = List.copyOf(noteIds);The record keeps a copy that cannot change. A caller that changes its own list later does not change the result.
A use case returns one of three things. A change that creates something returns the new IDs. A read returns a view. Any other change returns nothing, and the controller answers 204. No use case returns an aggregate:
| Use case | Returns | Why |
|---|---|---|
| place | PlacedOrder, with the IDs | the controller needs the ID for the Location header |
| ship, cancel, pickLine, recordPayment | nothing | the caller knows the ID already, and reads again if it needs more |
| get, list | OrderView, or a PageResult of them | the data of the order, with no method that changes it |
| NoteService.addNote | the note's ID, as a String | the light style uses plain IDs |
Returning the aggregate would make the view record unnecessary. A careful team can return it and agree that callers
never call its methods. Nothing enforces that agreement, though. A caller with an
Order can call order.ship(clock) and skip the shipping policy, the save
and the event. A view cannot be used that way. So the book recommends views for every service, and
the cost is one record and one mapping method.
Views that leave out personal datanew#
A view is the data that callers see of an aggregate. The order view holds the status, the lines and the last payment. It does not hold the customer's name or e-mail address:
/**
* What callers of the order feature see of an order: plain data, never the aggregate. A caller
* therefore cannot call {@code order.ship()} and skip the use case's steps.
*
* <p>The customer's contact is left out. A view carries personal data only when a use case
* needs it.
*/
public record OrderView(String orderId, String status, String deliveryMethod, Instant placedAt, Instant shippedAt,
Instant cancelledAt, List<Line> lines, LastPayment lastPayment) {
public record Line(String lineId, String productCode, int quantity, String status, Instant pickedAt) {
static Line from(OrderLine line) {
return new Line(line.id().value(), line.product().value(), line.quantity(), line.status().name(),
line.pickedAt());
}
}
// ...
static OrderView from(Order order) {
return new OrderView(order.id().value(), order.status().name(), order.deliveryMethod().code(),
order.placedAt(), order.shippedAt(), order.cancelledAt(),
order.lines().stream().map(Line::from).toList(),
LastPayment.from(order.lastPayment()));
}
}public record OrderView(String orderId, String status, String deliveryMethod, Instant placedAt, Instant shippedAt,Every component is plain data, such as a
Stringor anInstant. The typed IDs and the enums of the domain do not leave the feature.public record Line(String lineId, String productCode, int quantity, String status, Instant pickedAt) {A record can contain records.
OrderView.Lineis the view of one line, andOrderView.LastPaymentthe view of the payment.static OrderView from(Order order) {The mapping method has no access modifier, so it is package-private. Only the application package can build a view from an aggregate. A controller in the api package reads views, but cannot build them.
The view leaves the customer out on purpose. The service keeps the contact for the delivery, and no use case of the API needs to return it. Data that is never returned cannot leak in a response, a log line or a cache. The events leave the customer out too. If a screen needs the contact later, a new use case returns it, and the review of that use case asks who may see it.
The note feature has a view of the same kind:
/** What callers see of a note. */
public record NoteView(String noteId, String orderId, String text, String category, boolean actionRequired,
boolean needsAction, Instant createdAt, Instant actionResolvedAt) {
static NoteView from(Note note) {
return new NoteView(note.id(), note.orderId(), note.text(), note.category(), note.actionRequired(),
note.needsAction(), note.createdAt(), note.actionResolvedAt());
}
}needsAction is computed by the entity. It is true when the note needs action and no
one has resolved it yet. The view carries the answer, so no caller repeats the rule.
The controller then maps a view to a response data transfer object (DTO). That second mapping looks like a copy. It lets the API change its JSON without touching the use case. For the DTOs and the mappers, see the chapter The web layer in depth.
Ports in the application layertwist#
// in the application project
public interface IStockChecker
{
StockAvailability Reserve(
OrderId order, List<OrderItem> items);
}/**
* The port to the stock service, in this service's types. The use case does not know that the
* answer comes over HTTP, and a unit test replaces it with a mock.
*
* <p>It lives in {@code application/port}, not in the domain: calling another service is a step
* of the use case, and the domain only judges the answer.
*/
public interface StockChecker {
/**
* Asks the stock service to confirm that the items are available, and to hold them for the
* order. The stock service keeps the hold until it sees {@code OrderPlacedV1}, or releases it
* after a timeout if the order is never placed.
*
* <p>Calling it twice for the same order holds nothing twice, because the order ID is the
* idempotency key.
*/
StockAvailability reserve(OrderId order, List<OrderItem> items);
}A port is an interface that the inside of the service declares. An
adapter outside the domain and the application layer
implements it. StockChecker is such a port. The order feature has two kinds of port, in two places:
| Port | Package | Why there |
|---|---|---|
| OrderRepository | order/domain/repository | the domain says what it needs to load and save an aggregate |
| StockChecker | order/application/port | calling another service is a step of the use case, and the domain only judges the answer |
The domain receives the answer as a value, StockAvailability.
Order.place takes it as a parameter and refuses the order when a product is missing.
So the domain has the rule, and it never calls the stock service. The adapter
StockAdapter implements the port over HTTP. An architecture rule checks that the
application layer of the order feature uses no adapter.
For the adapter, retries and the anti-corruption layer, see the chapter Calling other services.
Placing an order: the stock call before the transactionnew#
Placing an order calls the stock service over HTTP. That call can take seconds. A DocumentDB
transaction has a limit of one minute, and its log must stay under 32 MB, as of October 2026. A
slow call inside the transaction would hold it open. So place makes the call first,
and starts the transaction after it:
public PlacedOrder place(CustomerContact customer, DeliveryMethod deliveryMethod, List<OrderItem> items,
List<NewNote> firstNotes) {
referenceData.requireActive(ReferenceTypes.DELIVERY_METHOD, deliveryMethod.code(), "deliveryMethod");
OrderId orderId = OrderId.newId();
StockAvailability availability = stock.reserve(orderId, items); // outside the transaction
return transaction.execute(status -> {
Order order = Order.place(orderId, customer, deliveryMethod, items, availability, clock);
Order saved = saveAndPublish(order, AuditActions.ORDER_PLACED); // aggregate 1
List<String> noteIds = notes.addNotes(saved.id().value(), firstNotes); // aggregate 2, same transaction
return new PlacedOrder(saved.id().value(), noteIds);
});
}referenceData.requireActive(ReferenceTypes.DELIVERY_METHOD, deliveryMethod.code(), "deliveryMethod");The cheap check runs first. An inactive delivery method is a 400, and the stock service is never called.
OrderId orderId = OrderId.newId();The ID is chosen before the order exists. The stock call sends it as the idempotency key, so a retried call holds the products once.
StockAvailability availability = stock.reserve(orderId, items); // outside the transactionThis is the call to the other service. No transaction is open yet.
return transaction.execute(status -> {The field
transactionis a TransactionTemplate. Only the lambda is transactional.executereturns what the lambda returns, here thePlacedOrder. The parameterstatuscan mark the transaction for rollback. This code does not use it.Order order = Order.place(orderId, customer, deliveryMethod, items, availability, clock);The domain decides. It refuses the order when the stock service reported a missing product.
The method uses the template, because @Transactional would wrap the whole method.
The stock call would then run inside the transaction. For the template bean, see the section
The TransactionTemplate bean.
The order of the steps has a cost. The stock service may hold the products, and the transaction
may then fail. No order is stored, so no OrderPlacedV1 is published. The stock service
releases the hold after its timeout, as the comment of the port says. The other order of the steps
puts the call inside the transaction. That order avoids the hold with no order. But it holds a
transaction open for the length of an HTTP call, and a retry of the call holds it open longer
still. The book recommends the call
first, with an idempotency key, for any call that can be slow.
Two tests of the service record the order of the steps:
| Test | What it checks |
|---|---|
| OrderServiceTest.Place.checksTheDeliveryMethodBeforeCallingTheStockService | an inactive delivery method throws, and the stock port is never called |
| OrderServiceTest.Place.savesNothingWhenTheStockIsNotAvailable | a missing product throws, and nothing is saved, published or audited |
Continue with the saved copytrap#
The last three steps of every change are in one private helper:
/**
* Saves the order, publishes its events and records the audit, all in the caller's
* transaction.
*
* <p>The events are pulled before saving, because the saved copy that the repository returns
* is rebuilt from the database and has none. The caller continues with the returned copy,
* which holds the new version.
*/
private Order saveAndPublish(Order order, String auditAction) {
List<DomainEvent> raised = order.pullEvents();
Order saved = orders.save(order);
events.publish(raised);
audit.record(auditAction, AuditActions.ORDER, saved.id().value());
return saved;
}List<DomainEvent> raised = order.pullEvents();The events are taken from the aggregate first. The copy that
savereturns is built from the stored document, and has no events.Order saved = orders.save(order);The repository returns a new object with the new version. The object passed in keeps the old version, because the version of the full-style aggregate is
final.events.publish(raised);The events go to the outbox, in the same transaction as the save. For the outbox, see the chapter Publishing events: the outbox.
audit.record(auditAction, AuditActions.ORDER, saved.id().value());The audit record names the action, the kind of resource and its ID. The platform adds who acted and when. For the audit, see the section The current actor, and the audit record.
return saved;The helper returns the saved copy.
placecontinues with it, as insaved.id().value().
After a save, the object you passed in is stale. With EF Core, a C# developer
changes a tracked entity, calls SaveChanges, and keeps using the same object.
SaveChanges returns only the number of entries it wrote. In the
full style, only the returned copy holds the new
version. A second save of the old object fails as a concurrent update.
So a use case that saves twice must continue with the returned copy. For the light style, where
Spring Data writes the version into the same object, see the section
Versions after a save.
No use case of the service saves an aggregate twice today. The helper returns the copy anyway, so a later use case that saves twice is safe. The unit test makes the mock behave like the real repository, and returns a new copy with the next version:
/** Makes the mocked repository behave like the real one: save returns a stored copy with the next version. */
private void saveReturnsTheStoredCopy() {
when(orders.save(any())).thenAnswer(call -> {
Order order = call.getArgument(0);
long next = order.version() == null ? 0 : order.version() + 1;
return Order.rehydrate(order.id(), next, order.customer(), order.deliveryMethod(), order.placedAt(),
order.lines(), order.status(), order.lastPayment(), order.shippedAt(), order.cancelledAt());
});
}thenAnswer runs the lambda for each call of save.
Order.rehydrate builds a new aggregate from stored values, as the mapper does when it
loads a document. A mock that returned the same object would hide a use case that ignores the
result.
One request that saves the order and its first notesnew#
An agent places an order and writes a note in the same form, "Leave with a neighbour". For the
agent, this is one action. If the note failed after the order was stored, the agent would see an
error and find an order without the note. So place saves the order and its first notes
in one transaction. Inside the lambda, the order feature calls the note feature:
/**
* For the order feature: adds the first notes of a new order. It joins the caller's
* transaction (propagation REQUIRED), so the order and its notes are stored together.
*/
@Transactional
public List<String> addNotes(String orderId, List<NewNote> newNotes) {
return newNotes.stream().map(newNote -> add(orderId, newNote)).toList();
}@Transactional with the default propagation REQUIRED joins a transaction
that is already open. A run for this book showed it on MongoDB. Both inserts carried the same
transaction number, and an exception in the inner method rolled back both. The journey test shows
the effect on the whole service:
@Test
void anOrderAndItsNotesAreStoredTogetherOrNotAtAll() throws Exception {
when(stock.reserve(any(), any())).thenReturn(StockAvailability.allAvailable());
long ordersBefore = mongo.getCollection("orders").countDocuments();
mvc.perform(post("/orders").with(agent).contentType(MediaType.APPLICATION_JSON).content("""
{ %s, "deliveryMethod": "STANDARD", %s,
"notes": [ { "text": "Called the customer", "category": "PHONE_CALL", "actionRequired": false } ] }
""".formatted(CUSTOMER, VALID_LINES)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[0].field").value("category"));
// The order was saved before its note failed, and the rollback removed it again.
assertThat(mongo.getCollection("orders").countDocuments()).isEqualTo(ordersBefore);
}"notes": [ { "text": "Called the customer", "category": "PHONE_CALL", "actionRequired": false } ] }The category
PHONE_CALLis not an active lookup value. The order is valid, so the order is saved first, and then the note fails..andExpect(jsonPath("$.errors[0].field").value("category"));The agent gets 400, which names the field of the note.
assertThat(mongo.getCollection("orders").countDocuments()).isEqualTo(ordersBefore);The rollback removed the order again. Nothing of the failed request is stored.
The strict rule. One transaction changes one aggregate. Vaughn Vernon gives this rule in Part I of his essays "Effective Aggregate Design" (2011). Other aggregates follow by events, in their own transactions, and are consistent a moment later.
What we do. place stores the new order and its first notes in one
transaction. The note feature joins the transaction of the order feature. Every other use case of
the service changes one aggregate.
Why. The agent submits one form, and expects all of it or nothing. Each of the three other designs has a cost:
| Design | What it gets right | Why the service does not use it |
|---|---|---|
| an event from the order adds the notes | the strict rule holds, and the order commits alone | a note that fails validation fails after the order exists, and the agent does not see the error |
| two requests from the user interface | each request changes one aggregate | the second request can fail after the first one succeeded, so the user interface needs code to repair half an action |
| the first notes embedded in the order | one document, so one write and no transaction | the notes share the order's version and size, and the note feature loses its own aggregate |
Vernon accepts this case. In Part II, he names user interface convenience as a reason to change several aggregates in one transaction. It is safe when creating new aggregates together means the same as creating them one at a time, because each one keeps its own rules. The order and its first notes are new aggregates of that kind. No other writer can conflict with an aggregate that does not exist yet. The transaction holds a few documents in one database, and it calls no other service. So it stays far inside the limits of a DocumentDB transaction, one minute and a 32 MB log, as of October 2026.
When to stop. Stop when the transaction would change an existing aggregate instead of creating a new one. Stop when it grows past a few documents, for example when one request can carry many notes. Stop when it would need a call to another service, or when the notes move to another service. Stop also when a third aggregate wants to join. Then save the order alone. Add the notes with an event, or in a second step that the client can retry. Show the agent a state such as "notes pending".
The transaction never spans services. The stock call is outside it, as the section Placing an order: the stock call before the transaction shows. For what holds between aggregates in general, see the section What holds between two aggregates, and what does not.
Calls between featuresnew#
The order and note features need each other four times. Each call goes through the other feature's application service, with plain values:
| Interaction | Caller and callee | What crosses | Transaction |
|---|---|---|---|
| place an order with its first notes | OrderService.place calls NoteService.addNotes | the order ID as text, and a list of NewNote | the caller's, joined |
| ship an order | OrderService.ship calls NoteService.countNeedingAction | the order ID as text, and a long back | the caller's |
| add a note | NoteService.addNote calls OrderService.requireExists | the order ID as text | the note's |
| list the notes of an order | NoteService.list calls OrderService.requireExists | the order ID as text | none, a read |
The rules for such calls follow from the table:
| Rule | Why |
|---|---|
| call the other feature's application service, never its repository | the owner checks its own rules, and its data is stored as it decides |
| pass plain values, such as a String ID or a NewNote | no type of one feature's domain enters the other feature |
| never pass or return an aggregate | the caller could change it and skip the owner's steps |
| let the owner check its own IDs | a bad ID gets the owner's own 400 or 404 |
The note to add is a record of plain values, in the note feature's application package:
/**
* A note to add, in plain values. The order feature passes it when it places an order with its
* first notes, so no note type crosses into the order feature.
*/
public record NewNote(String text, String category, boolean actionRequired) {
}NewNote holds the category as a String. The note feature checks the
category against the lookup list when it creates the note. The order feature checks an order ID for the note
feature:
/**
* For other features: checks that an order exists. IDs cross between features as text, and
* the owner of the ID checks it, so a bad ID gives the order feature's own 400 or 404.
*/
public void requireExists(String orderId) {
OrderId id = OrderId.of(orderId);
if (!orders.exists(id)) {
throw new OrderNotFoundException(id);
}
}OrderId id = OrderId.of(orderId);The text becomes a typed ID here, inside the order feature. A text that is not a universally unique identifier (UUID) throws, and the platform answers 400.
throw new OrderNotFoundException(id);The order feature's own exception, which the platform answers with 404 and the code
ORDER_NOT_FOUND.
Two architecture rules of the platform enforce the rules in the build. The first rule fails when
a feature uses a class of another feature outside that feature's application package. The ports in
application/port count as outside. The second rule fails when an application service
uses another feature's repository or entity:
/**
* A service never takes another feature's repository or entity. It asks that feature's
* service, which checks that feature's rules. This is a narrower, clearer form of the rule
* above for the most common mistake.
*/
public static ArchRule servicesDoNotUseOtherFeaturesRepositoriesOrEntities(String basePackage) {The name servicesDoNotUseOtherFeaturesRepositoriesOrEntities states the rule.
ArchitectureTest of the service calls both rules on the compiled classes. For the
rules and the packages, see the chapter Features, layers and the rules. The
two features depend on each other, which the container must allow. For that, see the section
@Lazy: a proxy that breaks the cycle, and the other ways out.
The note feature's use casestwist#
public async Task ResolveAction(string orderId, string noteId)
{
var note = await notes.FindById(noteId)
?? throw new NoteNotFoundException(noteId);
note.RequireBelongsTo(orderId);
note.ResolveAction(currentActor.Id, clock);
await SaveAndPublish(note, AuditActions.NoteActionResolved);
}@Transactional
public void resolveAction(String orderId, String noteId) {
Note note = notes.findById(noteId).orElseThrow(() -> new NoteNotFoundException(noteId));
note.requireBelongsTo(orderId);
note.resolveAction(currentActor.id(), clock);
saveAndPublish(note, AuditActions.NOTE_ACTION_RESOLVED);
}notes.findById(noteId).orElseThrow(() -> new NoteNotFoundException(noteId));The light style injects the Spring Data repository directly.
findByIdreturns anOptional, andorElseThrowthrows the note feature's 404 when it is empty.note.requireBelongsTo(orderId);A note under another order is not found either. The entity checks it. For why it answers 404 and not 403, see the section Rules about one record: in the domain, with the actor passed in.
note.resolveAction(currentActor.id(), clock);The use case reads the actor, and passes the actor's ID to the note as a plain value. The note refuses an agent who wrote it, so a second agent checks the work.
The steps are the same as in the full style, with fewer types. The IDs are String,
the repository is the Spring Data interface, and the entity is its own stored document. The private
helper saveAndPublish ignores the result of save, because Spring Data
writes the new version into the same object. For the light style, see the chapter
How much DDD: three levels in one service.
The other use cases of the note feature:
| Method | Who calls it | What it does |
|---|---|---|
| addNote(orderId, newNote) | the note controller | checks that the order exists, then adds one note, in its own transaction |
| addNotes(orderId, newNotes) | OrderService.place | adds the first notes, in the caller's transaction |
| list(orderId, page) | the note controller | a page of NoteView, newest first, and 404 for an unknown order |
| countNeedingAction(orderId) | OrderService.ship | a plain number, so no note crosses the boundary |
list checks the order first, so an unknown order is a 404, not an empty page. An empty
page would hide a wrong ID in the URL.
The reference feature: one rule, written oncetwist#
public void RequireActive(string type, string code, string field)
{
var known = items.FindByType(type)
.Any(item => item.Code == code && item.Active);
if (!known)
throw new InvalidValueException(field, "is not a known value");
}public void requireActive(String type, String code, String field) {
boolean known = items.findByTypeOrderByCodeAsc(type).stream()
.anyMatch(item -> item.code().equals(code) && item.active());
if (!known) {
throw new InvalidValueException(field, "is not a known value");
}
}The reference feature has no aggregate. It lists lookup values, such as the delivery methods,
and checks a code against them for the other features. InvalidValueException names the
field, so the client gets 400 for deliveryMethod or category.
The names of the lists are constants, which every feature uses:
public final class ReferenceTypes {
public static final String DELIVERY_METHOD = "DELIVERY_METHOD";
public static final String NOTE_CATEGORY = "NOTE_CATEGORY";
static final Set<String> ALL = Set.of(DELIVERY_METHOD, NOTE_CATEGORY);
private ReferenceTypes() {
}
}ALL is package-private. Only the reference feature needs the full set, to answer 404
for an unknown list. The private constructor stops anyone from creating the class. The list method
returns ReferenceItemView, a record with the code, the label and the active flag.
The rule "a code must be an active lookup value" is an if in an application
service. That is a departure from the rule that business rules are in the domain, and it is made on
purpose. For the reasons and for the sign that the rule must move, see the section
The lookup rule in an application service.
The list is cached, so the check does not read the database for every order. The cache must avoid self-invocation. For why the cache is on the repository, see the section Self-invocation: why the cache is on the repository.
The only kind of if in a use casenew#
A payment arrives twice, because a queue delivers at least once. The second one must change nothing. Where does that check go? It is a rule about payments, so the aggregate decides. The use case only reacts to the answer:
/**
* Records a payment from the payment service. A message starts this use case, so no user
* acts, and the audit log records the system actor.
*
* <p>It joins the transaction of the inbox that called it. The one {@code if} here is not a
* business rule: the domain decided to ignore the payment, so there is nothing to save.
*/
@Transactional
public void recordPayment(OrderId orderId, Payment payment) {
Order order = orders.get(orderId);
if (order.recordPayment(payment)) {
saveAndPublish(order, AuditActions.PAYMENT_RECORDED);
}
}if (order.recordPayment(payment)) {recordPaymentreturnstruewhen it recorded the payment. It returnsfalsefor a payment that is not newer than the last one, such as the same payment delivered twice. The rule is inside the aggregate.saveAndPublish(order, AuditActions.PAYMENT_RECORDED);Only a recorded payment is saved, published and audited.
The test for this if is simple. Does the line decide what the use case does next, or
does it decide what the business allows? This one only decides whether there is anything to save.
The two kinds, side by side:
| Decision | Where it belongs | In the order service |
|---|---|---|
| save only when the domain recorded the payment | the use case | the if in OrderService.recordPayment |
| list every order when no status is given | the use case | the choice of query in OrderService.list |
| ignore a payment that is not newer than the last one | the domain | the first if in Order.recordPayment |
| refuse to ship an order that is not paid | the domain | Order.ship, which throws OrderNotPaidException |
| refuse an order when a product is missing | the domain | Order.place, which judges StockAvailability |
If a business expert would recognise the line as a rule, it belongs in the domain. The class
comment of OrderService says the same. For the decision order of rules, see the chapter
Where each rule goes.
Two tests record both outcomes. OrderServiceTest.RecordPayment.savesTheOrderWhenThePaymentIsNew
checks the save and the audit. savesNothingWhenTheDomainIgnoresARepeatedPayment checks
that nothing is saved, published or audited.
The short use cases and the readstwist#
public async Task PickLine(OrderId orderId, LineId lineId)
{
var order = await orders.Get(orderId);
order.PickLine(lineId, clock);
await SaveAndPublish(order, AuditActions.LinePicked);
}@Transactional
public void pickLine(OrderId orderId, LineId lineId) {
Order order = orders.get(orderId);
order.pickLine(lineId, clock);
saveAndPublish(order, AuditActions.LINE_PICKED);
}Warehouse staff pick each line. pickLine has three of the steps, because it asks no
other feature. order.pickLine refuses a line that is picked already, and an order that
is finished. cancel has the same shape, with order.cancel(clock).
The reads need no transaction:
public OrderView get(OrderId orderId) {
return OrderView.from(orders.get(orderId));
}
public PageResult<OrderView> list(OrderStatus status, PageQuery page) {
PageResult<Order> result = status == null ? orders.findAll(page) : orders.findByStatus(status, page);
return result.map(OrderView::from);
}PageResult<Order> result = status == null ? orders.findAll(page) : orders.findByStatus(status, page);A missing status lists every order. This choice of query is not a business rule.
return result.map(OrderView::from);PageResultis the platform's page type, in plain Java.mapconverts each order to a view, and keeps the page number and the total.
The reads have no @Transactional. A read-only transaction would still open a server
transaction on MongoDB, and one read gains nothing from it. For that, see the chapter
Transactions in depth.
The journeys through the servicenew#
A bug report says "the order was placed, but the warehouse never heard of it". Where do you look? Each step of a journey has one home, so the search starts from the step. These are the main journeys of the order service.
A request to place an order passes the filter chain first. Then an
interceptor fills the
mapped diagnostic context (MDC) of the logs, and Spring
model-view-controller (MVC) reads the body. Next,
method security checks the scope. Only then does the
request reach the use case. After the commit, the event goes to Amazon Simple Notification Service
(SNS). The journey of POST /orders:
Each step, with the chapter that explains it:
| Step | What happens | Explained in |
|---|---|---|
| 1 | the bearer token is checked, or the request gets 401 | dd-sec-path |
| 2 | the interceptor copies orderId and the other path IDs into the MDC | dd-obs |
| 3 | Jackson reads the body, and @Valid checks it | dd-web |
| 4 | @OrdersWrite checks the scope orders.write | dd-sec-method |
| 5 | the controller builds CustomerContact, DeliveryMethod and the items | dd-web |
| 6 | requireActive, then the stock call outside the transaction | dd-uc-place |
| 7 | the order, its outbox rows, its audit record and its notes, in one transaction | dd-uc-two |
| 8 | Spring Modulith sends OrderPlacedV1 to SNS, on another thread | dd-out |
Step 8 is outside the request. A run for this book showed that the SNS send happens after the commit, on an executor thread, outside any transaction. So the answer 201 does not wait for SNS, and a failed send is retried by the outbox resender. In the bug report above, look at step 8 first.
A payment enters through the queue listener instead. The listener only translates the message into value objects and calls the use case:
@Override
protected void handle(PaymentReceivedV1 event) {
Payment payment = new Payment(new PaymentReference(event.paymentReference()),
new Money(event.amount(), event.currency()), event.paidAt());
orders.recordPayment(OrderId.of(event.orderId()), payment);
}The platform's base class calls handle once for each event, through the
inbox, as the
system actor. So the same use case serves a message and could serve a request, with no second copy.
The other journeys, in short:
| Journey | Its steps | Explained in |
|---|---|---|
| ship an order | the same as place up to step 5, then load, ask the notes, check the policy, ship and save, with 409 when another agent saved first | dd-uc-steps |
| a payment message | SQS delivers it, the inbox records its ID, recordPayment runs as the system actor, in the inbox's transaction | dd-inb |
| an event out | the use case publishes, the outbox stores a row in the transaction, Spring Modulith sends it after the commit | dd-out |
| the stock call | the port, the adapter, the client with its token, retries, the circuit breaker, a 503 when the stock service is down | dd-cli |
The journey test OrderJourneyIT.anOrderShipsOnlyWhenPaidPickedAndNoNoteNeedsAction
runs placing, paying and shipping end to end, with the events. It places an order with a note,
ships it after each condition is met, and checks the audit records and the events on the
queue.
StockAdapterTest runs the stock call against a local HTTP server.
Splitting a large servicenew#
OrderService has eight public methods. A year from now it may have twenty, with returns,
refunds, address changes and split deliveries. A class of twenty use cases is hard to read, and its
constructor grows with every one. The class comment gives the rule:
* <p>With about ten use cases, split the class by area, such as {@code OrderFulfilmentService},
* not into a read side and a write side.Split by an area of the business. Picking and shipping could move to an
OrderFulfilmentService. Returns and refunds would then be an
OrderReturnsService. Each class keeps the same steps, and each one shares the
repository port and the helpers it needs. The ways to split, compared:
| Split | What it gets right | What it costs |
|---|---|---|
| by area, as recommended | each class is a part of the business that a team member can name | the shared helper must move to a small class that both use |
| into a read side and a write side | reads can change faster, and use other storage later | the two halves of one feature change together, and the reads still need the same views |
| one class per use case | each class is tiny | the steps and helpers move to a base class, and a reader opens many files |
The read and write split is the first step of CQRS. It is worth it when the reads need another shape of storage, such as a search index. The order service has no such need, so it splits by area. With constructor injection, a constructor with more than about ten parameters is a sign that the split is due. For that signal, see the section Constructor injection, with nine dependencies.
How each use case is testedtwist#
var stock = new Mock<IStockChecker>();
var service = new OrderService(orders.Object, notes.Object,
referenceData.Object, stock.Object, new ShippingPolicy(),
events.Object, audit.Object, transactions, clock);private final OrderRepository orders = mock(OrderRepository.class);
private final NoteService notes = mock(NoteService.class);
private final ReferenceDataService referenceData = mock(ReferenceDataService.class);
private final StockChecker stock = mock(StockChecker.class);
private final DomainEventPublisher events = mock(DomainEventPublisher.class);
private final AuditLog audit = mock(AuditLog.class);
private final OrderService service = new OrderService(orders, notes, referenceData, stock, new ShippingPolicy(),
events, audit, new TransactionTemplate(new NoOpTransactionManager()), CLOCK);private final StockChecker stock = mock(StockChecker.class);mockis the static method of Mockito that creates a stand-in object for an interface or a class. Moq in .NET has the same purpose.new ShippingPolicy()The domain service is real. A unit test of a use case uses the real domain, and mocks only the ports and the other features.
new TransactionTemplate(new NoOpTransactionManager())A real template, on a transaction manager of the platform that does nothing. So the lambda of
placeruns, and no database is needed.
The test of place checks the order of the steps with InOrder:
InOrder order = inOrder(referenceData, stock, orders, events, audit, notes);
order.verify(referenceData).requireActive("DELIVERY_METHOD", "STANDARD", "deliveryMethod");
order.verify(stock).reserve(reservedFor.capture(), eq(TWO_MUGS)); // the other service first
order.verify(orders).save(saved.capture()); // then the order
order.verify(events).publish(List.of(new OrderPlaced(reservedFor.getValue(), STANDARD, TWO_MUGS, NOW)));
order.verify(audit).record("ORDER_PLACED", "order", reservedFor.getValue().value());
order.verify(notes).addNotes(reservedFor.getValue().value(), firstNotes); // then its notesInOrder order = inOrder(referenceData, stock, orders, events, audit, notes);InOrderchecks that the calls on these mocks happened in this order, and not only that they happened.order.verify(stock).reserve(reservedFor.capture(), eq(TWO_MUGS)); // the other service firstAn
ArgumentCaptorkeeps the order ID that the use case sent to the stock service. A later line checks that the saved order has the same ID.
Each use case has a test at the level that suits it:
| What is tested | Test | Needs |
|---|---|---|
| the order of the steps, the event, the audit, a refusal | OrderServiceTest and NoteServiceTest, with mocks | nothing, it runs in milliseconds |
| the HTTP status, the scope, the JSON | OrderControllerTest and NoteControllerTest, with the service mocked | a slice of Spring, no database |
| the order and its notes in one transaction, the rollback | OrderJourneyIT | MongoDB and the AWS emulator in Docker |
| the stock call, its token, its retries | StockAdapterTest | a local HTTP server |
One rule decides which test proves what. A unit test proves the steps, and only the journey test proves the transaction. The no-op manager cannot roll anything back, so a unit test never shows a rollback. For each kind of test, see the chapter Testing in depth.
1An expert asksWhy does place call the stock service before it starts the
transaction, and what does that order cost?
place call the stock service before it starts the
transaction, and what does that order cost?2An expert asksAn application service could return the aggregate, which saves a view type. Why
does the order service return OrderView instead?
OrderView instead?Order could call order.ship(clock) directly. It would
skip the shipping policy, the save, the event and the audit. A view has no method that changes
anything. The view also leaves out the customer's contact, so personal data leaves the feature only
when a use case needs it.3OrderService.recordPayment contains an if. Why is it not a business rule
in the wrong layer?
OrderService.recordPayment contains an if. Why is it not a business rule
in the wrong layer?true or
false. The if only decides whether there is anything to save. A line that a
business expert would recognise as a rule, such as "ignore an older payment", is inside
Order.recordPayment.4The note feature needs to know whether an order exists. Which call does it make, and what crosses
between the features?
OrderService.requireExists(orderId), with the order ID as plain text. The
order feature turns the text into an OrderId and checks it. A bad ID gets the order
feature's own 400, and an unknown one its 404. No type of the order domain enters the note
feature.5TranslateA message starts this use case, inside the transaction of the inbox. Write it as a
method of OrderService:
public async Task RecordPayment(OrderId orderId, Payment payment)
{
var order = await orders.Get(orderId);
if (order.RecordPayment(payment))
await SaveAndPublish(order, AuditActions.PaymentRecorded);
}
OrderService:
public async Task RecordPayment(OrderId orderId, Payment payment)
{
var order = await orders.Get(orderId);
if (order.RecordPayment(payment))
await SaveAndPublish(order, AuditActions.PaymentRecorded);
}@Transactional
public void recordPayment(OrderId orderId, Payment payment) {
Order order = orders.get(orderId);
if (order.recordPayment(payment)) {
saveAndPublish(order, AuditActions.PAYMENT_RECORDED);
}
}@Transactional with the default propagation joins the transaction of the inbox.
orders.get throws the 404 exception when no order has the ID. The domain returns
false for a repeated payment, and then nothing is saved.
The web layer in depth#
The web layer of the order service turns HTTP into calls of the use cases, and back. Its
controllers hold no rule. Records carry the JSON in and out, and small mappers convert them.
One platform class turns every exception into an RFC 9457 problem with a
traceId. This chapter follows one request through the web framework, and measures
what each part does when something fails.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | middleware, routing, then filters around the action | servlet filters, the DispatcherServlet, interceptors, argument resolution, the controller method and the message converters | each part can stop the request, and the part that stops it decides who writes the answer Why |
| twist | JwtBearer's challenge writes the 401 | the security filter chain writes the 401, before Spring MVC runs | the platform's advice never sees a 401, so the security chain writes that problem itself Why |
| trap | an exception in afterCompletion means the request failed | afterCompletion gets ex = null when an advice handled the exception | read response.getStatus() instead, because a handled exception never reaches afterCompletion Why |
| twist | an [ApiController] class that calls a service | a @RestController class with no rule, no repository and no try/catch | the controller turns HTTP into a use-case call and back, and the platform writes every error Why |
| twist | [HttpPost("{orderId}/ship")] | @PostMapping("/{orderId}/ship") | a state change is a POST with a verb in the path, not a PUT of the whole order Why |
| twist | [FromRoute] Guid orderId | @PathVariable String orderId, then OrderId.of(orderId) | the controller builds the typed ID, so a bad ID is a 400 that names the field orderId Why |
| twist | a request class with [Required] and [MaxLength] | a record with @NotBlank, @Size, and @Valid on each nested record and list element | the 400 lists every bad field, and the domain checks the values again Why |
| twist | the action returns a DTO, never an entity | the controller returns a response record, never a domain class | the JSON is a contract with other teams, so the domain can change without changing it Why |
| twist | a hand-written mapping method | a package-private @Component mapper for each feature | plain Java code, so a new record component is a compile error in the mapper Why |
| twist | Created(uri, body) | ResponseEntity.created(URI.create(...)).body(...) | a 201 with a Location header and a body, built in one expression Why |
| twist | NoContent() | ResponseEntity.noContent().build() | a 204 for a command that returns nothing, with ResponseEntity<Void> as the return type Why |
| new | a paging class of your own | PageQuery(page, size) in, PageResponse<T> out | one page shape for every service, and a size over 100 is a 400 that names the field size Why |
| trap | an omitted bool binds to false | an omitted boolean fails the request with 400 in Jackson 3 | Boot 4 rejects the body unless fail-on-null-for-primitives is false, which the platform sets Why |
| same | unknown JSON properties are ignored | unknown JSON properties are ignored | System.Text.Json and Jackson 3 both skip a property that the record does not have |
| twist | a UTC DateTimeOffset is written as 2026-01-15T09:00:00+00:00 | an Instant is written as 2026-01-15T09:00:00Z | an ISO-8601 string with no extra module, so a test can compare it as text Why |
| new | AddProblemDetails() and an IExceptionHandler | a @RestControllerAdvice that extends ResponseEntityExceptionHandler | one platform class answers every exception, and the kind of the exception decides the status Why |
| twist | a feature writes its own handler | a feature extends NotFoundException, InvalidValueException or BusinessRuleViolationException | the base class decides the status, so a feature never writes a handler Why |
| trap | AddProblemDetails() puts a traceId into the problems it writes | a traceId appears only where your own code adds it | override handleExceptionInternal, or inherited cases such as 405 and 415 have none Why |
| twist | StatusCodes.Status422UnprocessableEntity | HttpStatus.UNPROCESSABLE_CONTENT | Spring 7 uses the new name for 422, and UNPROCESSABLE_ENTITY is deprecated Why |
| twist | Conflict() for a rule that the current state breaks | 409 only for a concurrent update, and 422 for every broken rule | each status asks the client for one reaction, so a client can act on the status alone Why |
| trap | several IExceptionHandler classes, tried in the order they are registered | several advices, and Spring picks the advice before the method | without @Order, the class names decide which advice answers, so the catch-all needs the lowest precedence Why |
| new | a handler for DbUpdateConcurrencyException | OptimisticLockingExceptionHandler in the platform's mongo package | the 409 handler is next to the technology that throws, and it also catches a write conflict Why |
| twist | UseExceptionHandler() with AddProblemDetails() | spring.web.error.include-stacktrace and include-message, both never | Boot's own error response holds no stack trace and no message, and the old server.error keys are deprecated Why |
| new | no equivalent | messages without personal data, and a 500 that says only "Quote the traceId" | an exception's message reaches the caller, so it holds IDs and never a name or an email address Why |
| new | a middleware with ILogger.BeginScope | a HandlerInterceptor, added by a WebMvcConfigurer bean | a library adds the interceptor with a bean of its own, and the interceptor then runs around every controller method Why |
| twist | context.Request.RouteValues["orderId"] | request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE) | a map of the path variables, or null when no controller method matched Why |
| twist | if (x is IDictionary<string, object> map) | if (x instanceof Map<?, ?> variables) | a type pattern tests the type and names the result in one step Why |
One request through Spring MVCnew#
An agent ships an order. The browser sends POST /orders/{orderId}/ship with a
token. Before the controller method runs, several parts of Spring look at the request. Each
part can stop it. When a part stops the request, another part writes the answer. A C#
developer knows the same idea as the middleware pipeline of ASP.NET Core.
The web framework of Spring is called Spring model-view-controller (MVC). It runs inside a web server that follows the Jakarta Servlet standard.
The chapter Controllers, routing and binding teaches the basics of a controller, a servlet filter and an interceptor. For the filters and interceptors, see the section Middleware. This section puts all the parts in order:
Each part has one job in the order service:
| Part | What it does here | What it answers when it stops the request |
|---|---|---|
| servlet filters | Spring Security checks the token | 401, written by the security chain |
| DispatcherServlet | finds the controller method for the path and the HTTP method | 404 or 405, written by the advice |
| interceptors | the platform's MDC interceptor copies orderId, lineId and noteId into the MDC | nothing, it only adds log context |
| argument resolution | reads the JSON body, runs @Valid, and converts path and query values | 400, written by the advice |
| the controller method | builds value objects and calls one use case | 400, 403, 404, 409, 422 or 503, written by the advice |
| the message converter | writes the returned record as JSON, or the problem as application/problem+json | nothing, it writes what it is given |
Spring Security's checks are filters, so a request without a valid token never reaches
Spring MVC. The platform's advice therefore never sees a 401. For how the security chain
writes the 401 problem itself, see the section One request through the
checks. The test
aRequestWithoutATokenIsA401ProblemBeforeAnyController proves that no use case
runs:
@Test
void aRequestWithoutATokenIsA401ProblemBeforeAnyController() throws Exception {
mvc.perform(post("/orders").contentType(MediaType.APPLICATION_JSON).content(VALID_ORDER))
.andExpect(status().isUnauthorized())
.andExpect(header().exists("WWW-Authenticate"))
.andExpect(jsonPath("$.title").value("Unauthorized"))
.andExpect(jsonPath("$.status").value(401));
verifyNoInteractions(orders);
}The request has a valid body and no token. verifyNoInteractions(orders) checks
that the mocked use case was never called. The test is in the nested class
OrderControllerTest.Security.
A run for this book recorded every callback of a normal request. The order was the filter,
preHandle, the controller, postHandle, afterCompletion,
and the filter again. In ASP.NET Core, routing chooses the endpoint inside the middleware
pipeline, and filters wrap the action. The closest match for an interceptor is an action
filter.
What an interceptor sees when a request failstrap#
An interceptor has three callbacks. preHandle runs before the controller method,
postHandle runs after it returns, and afterCompletion runs at the very
end. afterCompletion has a parameter ex for an exception. A C#
developer expects that parameter to hold the exception of every failed request.
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
Exception ex) {
names.forEach(MDC::remove);
}Show the answer
An exception that an advice handled is not passed to afterCompletion. The
advice resolved the exception and wrote the 422. Spring MVC then treats the request as
handled. A run recorded afterCompletion ex=null status=422, and
postHandle did not run. Only an exception that no advice handles arrives in
ex. To see a failure, read response.getStatus().
The platform's interceptor does not need ex. It removes its values in every
case. The runs for this book recorded these callbacks:
| The request | preHandle | The controller | postHandle | afterCompletion |
|---|---|---|---|---|
| a normal request | runs, with the path variables | runs | runs | runs, ex is null |
| the use case throws, and the advice answers 422 | runs | runs | does not run | runs, ex is null |
| the method throws, and no advice handles it | runs | runs | does not run | runs, ex is the exception |
| a body that fails @Valid | runs | does not run | does not run | runs, ex is null |
| DELETE on a path that has only GET, a 405 | does not run | does not run | does not run | does not run |
| a path with no controller, a 404 | runs, with no path variables | does not run | does not run | not recorded in the run |
A body that fails @Valid runs preHandle first. Spring MVC checks
the body while it resolves the method's arguments, and that happens after
preHandle. An exception that no advice handles leaves the servlet as
ServletException: Request processing failed. The order service never gets there,
because its advice has a handler for Exception.
For a 405, Spring refuses the HTTP method while it looks up the handler. Spring does
that before any interceptor runs. So the run recorded only the filter and the advice for a DELETE on
a GET path.
For a path with no controller, the run showed that Spring gave the request to its handler
for static files. That handler then answered 404. The interceptor ran, and the attribute of the
path variables was null.
Both cases change what the platform's interceptor can put into the mapped diagnostic context (MDC), the logging context of the thread. For the log lines of these cases, see the section Path variables in the MDC, with an interceptor.
Controllers that only translatetwist#
The order controller is the HTTP door of the order feature. Its documentation states what it does, and what it never does:
/**
* The HTTP API of orders. It translates HTTP into calls of the use cases, and back. It holds no
* rule, uses no repository, and catches no exception: the platform turns every exception into
* a problem response.
*
* <p>Each method names the scope it needs with {@link OrdersRead} or {@link OrdersWrite}. This
* is where callers enter, so this is where permissions are checked. Spring MVC reads and
* validates the body before it calls the method, so a caller without the scope who sends an
* invalid body gets 400, not 403. Path IDs are converted inside the methods, after the check.
*
* <p>The OpenAPI annotations describe each operation and its errors. springdoc builds the
* document from them, and the build fails when {@code contracts/openapi.yaml} differs.
*/
@RestController
@RequestMapping("/orders")
@Tag(name = "orders")
class OrderController {
private final OrderService orders;
private final OrderApiMapper mapper;
OrderController(OrderService orders, OrderApiMapper mapper) {
this.orders = orders;
this.mapper = mapper;
}@RestControllerSpring writes the return value of each method to the response body, as JSON. For the basics, see the section A controller.
@RequestMapping("/orders")Every path of the class starts with
/orders.@Tag(name = "orders")This annotation comes from the Swagger annotations, which springdoc reads. It groups the operations in the OpenAPI document. See the chapter API and event contracts.
class OrderController {The class is package-private. No other package calls a controller, so nothing outside
order.apican use it.OrderController(OrderService orders, OrderApiMapper mapper) {The controller needs two things, the use cases and the mapper. Each one is a bean, so Spring passes both to the constructor.
A controller method has the same three steps every time. It reads values from the request, calls one use case, and turns the result into a response. The ship method is the shortest example:
@OrdersWrite
@PostMapping("/{orderId}/ship")
// ...
ResponseEntity<Void> ship(@PathVariable String orderId) {
orders.ship(OrderId.of(orderId));
return ResponseEntity.noContent().build();
}@OrdersWriteThe caller needs the
orders.writescope. For permissions, see the chapter Security in depth.@PostMapping("/{orderId}/ship")Shipping is a
POSTto a path that ends with a verb.orders.ship(OrderId.of(orderId));The method builds the typed ID and calls one use case. If the order cannot ship, the use case throws, and the platform writes the problem.
The // ... line stands for the OpenAPI annotations. The chapter
API and event contracts explains them.
A change of state is an action-style POST, with a verb in the path. The order
service has four of them, for picking a line, shipping, cancelling and resolving a note. A
PUT of the whole order would let a client set the status to SHIPPED
directly. The order must decide whether it may ship, so the client asks for the action and the
domain answers. The quick reference lists all ten endpoints.
Three things never appear in a controller:
- A rule. "An order ships only when it is paid" is in the domain. For where each rule goes, see the chapter Where each rule goes.
- A repository. The controller calls the use case, and the use case loads and saves. For the use cases, see the chapter Use cases: the application service.
- A try/catch block. The platform's advice turns every exception into a problem. See the section One handler for every problem.
Notes belong to an order, so the paths of the note controller start with the path of the order:
@RestController
@RequestMapping("/orders/{orderId}/notes")
@Tag(name = "notes")
class NoteController {The class prefix itself holds a path variable, {orderId}. Each method of the
class then declares @PathVariable String orderId to read it. The lookup lists have
a third controller, ReferenceDataController, at /reference-data/{type}.
The UI uses those lists to fill its drop-down lists.
A C# controller often returns NotFound() or BadRequest(). The
controllers of the order service never build an error response. The domain throws, and the
platform's advice writes the problem. A success is the returned record, or a
ResponseEntity for 201 and 204.
IDs in the path: text in, value objects insidetwist#
[HttpGet("{orderId}")]
public OrderResponse Get([FromRoute] Guid orderId) =>
mapper.ToResponse(orders.Get(new OrderId(orderId)));@OrdersRead
@GetMapping("/{orderId}")
// ...
OrderResponse get(@PathVariable String orderId) {
return mapper.toResponse(orders.get(OrderId.of(orderId)));
}The Java method takes the ID as a String, not as a UUID. It
converts the text with OrderId.of, inside the method. OrderId checks
the format in its constructor, and throws an InvalidValueException that names the
field:
protected Identifier(String value, String fieldName) {
String normalised = value == null ? null : value.toLowerCase(Locale.ROOT);
if (normalised == null || !UUID_FORMAT.matcher(normalised).matches()) {
throw new InvalidValueException(fieldName, "must be a valid identifier");
}
this.value = normalised;
}OrderId passes "orderId" as the fieldName of this
constructor. The platform's advice turns the InvalidValueException into a 400, and
its errors list names that field. The test
anInvalidIdInThePathIsA400ThatNamesTheField sends
GET /orders/not-a-uuid. It expects orderId as the first field in the
errors.
The text parameter has two more effects:
- The permission check runs before the conversion. A read-only caller who sends a bad ID to a
write endpoint gets 403. With
@PathVariable UUID, the conversion runs first, and the caller gets 400. See the section Three places to check a permission, measured. - One class,
Identifier, checks every ID in the same way, so the domain decides what a valid ID is. It accepts upper and lower case, and stores the value in lower case. Spring's conversion toUUIDwould fail with another exception,MethodArgumentTypeMismatchException, and another message.
The C# pane wraps a Guid that ASP.NET Core has already converted. The Java
method gets the raw text and converts it itself. The note feature uses the light style. So the
note controller gives the note ID to the use case as a plain String. A malformed
note ID is then not a 400. The use case finds no note with that ID, and the answer is a 404. For
the three levels of Domain-Driven Design (DDD), see the chapter
How much DDD: three levels in one service.
Request and response DTOs as recordstwist#
A class that only carries data into or out of the API is called a data transfer object (DTO). The body that places an order is a record with Bean Validation annotations. Its nested records describe the customer, the lines and the notes:
@Schema(description = "An order to place, with its first notes")
public record PlaceOrderRequest(
@NotNull @Valid CustomerRequest customer,
@Schema(description = "A DELIVERY_METHOD code", example = "STANDARD") @NotBlank String deliveryMethod,
@NotEmpty @Size(max = 50) List<@Valid LineRequest> lines,
@Schema(description = "Notes saved with the order, in the same transaction")
@Size(max = 20) List<@Valid NoteRequest> notes) {
public record CustomerRequest(
@Schema(example = "Jo Bloggs") @NotBlank @Size(max = 200) String name,
@Schema(example = "jo@example.com") @NotBlank @Email String email) {
}
public record LineRequest(
@Schema(example = "MUG-001") @NotBlank String productCode,
@Schema(example = "2") @Min(1) @Max(999) int quantity) {
}
// ...
}@NotNull @Valid CustomerRequest customer,@NotNullrequires the customer object.@Validtells the validator to check the constraints inside it as well. Without@Valid, a blank name would pass. See the section A nested object needs its own @Valid.@NotEmpty @Size(max = 50) List<@Valid LineRequest> lines,The list needs at least one line and at most 50.
@Validon the element type checks each line.@Size(max = 20) List<@Valid NoteRequest> notes) {The notes are optional, so the list has no
@NotNull. A missing list isnull, and the mapper turns it into an empty list.@Schema(example = "MUG-001")@Schemaadds a description or an example to the OpenAPI document. It changes nothing at run time.
The nested records are declared inside PlaceOrderRequest, so a reader sees the
whole body in one file. Java calls them PlaceOrderRequest.CustomerRequest and so
on. For the constraint annotations, see the section Constraint
annotations.
When the body is invalid, the client gets every bad field at once. The test
invalidFieldsAreAllListed sends a blank name, a bad email address and no lines:
@Test
void invalidFieldsAreAllListed() throws Exception {
mvc.perform(post("/orders").with(AGENT).contentType(MediaType.APPLICATION_JSON).content("""
{ "customer": { "name": "", "email": "not-an-email" }, "deliveryMethod": "STANDARD", "lines": [] }
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.title").value("Invalid request"))
.andExpect(jsonPath("$.errors.length()").value(3))
.andExpect(jsonPath("$.errors[?(@.field == 'customer.name')]").exists())
.andExpect(jsonPath("$.errors[?(@.field == 'customer.email')]").exists())
.andExpect(jsonPath("$.errors[?(@.field == 'lines')]").exists());
}A field of a nested record is named with a dot, as in customer.name. A failed
@Valid throws MethodArgumentNotValidException, and the platform's
advice turns it into this 400. See the section One handler for every
problem.
The domain checks the same values again. CustomerContact refuses a blank name
with the field name customer.name, the same name that the DTO's error uses. The
DTO's annotations give a friendly list of errors. The domain's check is the one that counts,
because every caller of a use case passes through the domain, with or without a DTO. The
record's own documentation says so.
The response is a record too:
/** An order as clients see it. It is a separate class from the domain, so either can change. */
public record OrderResponse(
String orderId,
@Schema(allowableValues = {"NEW", "PAID", "SHIPPED", "CANCELLED"}) String status,
String deliveryMethod,
Instant placedAt,
Instant shippedAt,
Instant cancelledAt,
List<LineResponse> lines,
PaymentResponse lastPayment) {
public record LineResponse(String lineId, String productCode, int quantity,
@Schema(allowableValues = {"WAITING", "PICKED"}) String status, Instant pickedAt) {
}
public record PaymentResponse(String reference, BigDecimal amount, String currency, Instant paidAt) {
}
}Jackson writes a record by its accessor names, so orderId() becomes the member
"orderId". A record needs no getters and no annotations for this. The status is a
String, not the domain's enum. @Schema(allowableValues = ...) still
lists the four values in the OpenAPI document.
Why a separate class, when Order already has these fields? The JSON is a
contract with the UI and with other teams. The domain changes when the business rules change.
With a separate record, a new field in Order does not appear in the API, and a
renamed field does not break a client. So the domain and the API can each change on its
own. The
domain class also holds things that a client must never see, such as the version for
optimistic locking.
One more record completes the order API. OrderPlacedResponse holds the IDs of
the new order and of its notes. The note feature and the lookup lists have records of the same
kind. Each feature keeps them in a dto package next to its controller.
API mapperstwist#
Each feature has one class that converts between its DTOs and the types of the application layer. The order mapper has four methods:
/** Converts between the order API's DTOs and the application layer's types. */
@Component
class OrderApiMapper {
List<OrderItem> toItems(PlaceOrderRequest request) {
return request.lines().stream()
.map(line -> new OrderItem(new ProductCode(line.productCode()), line.quantity()))
.toList();
}
/** Notes are optional in the request, so a missing list means no notes. */
List<NewNote> toNewNotes(PlaceOrderRequest request) {
List<PlaceOrderRequest.NoteRequest> notes = request.notes() == null ? List.of() : request.notes();
return notes.stream().map(note -> new NewNote(note.text(), note.category(), note.actionRequired())).toList();
}
OrderPlacedResponse toResponse(PlacedOrder placed) {
return new OrderPlacedResponse(placed.orderId(), placed.noteIds());
}
// ...
}@ComponentThe mapper is a bean, so Spring passes it to the controller's constructor.
class OrderApiMapper {The class is package-private, like the controller. Only
order.apiuses it.new OrderItem(new ProductCode(line.productCode()), line.quantity())The mapper builds domain value objects from the request.
ProductCodechecks its own format, so a bad code fails here with a 400.request.notes() == null ? List.of() : request.notes();A missing list of notes becomes an empty list. The use case never sees
null.
The fourth method, toResponse(OrderView), builds the nested response records
from a view of the application layer. For views, see the chapter Use cases:
the application service. The note feature's mapper has one method, because the note
controller builds its request objects in place:
@Component
class NoteApiMapper {
NoteResponse toResponse(NoteView note) {
return new NoteResponse(note.noteId(), note.text(), note.category(), note.actionRequired(),
note.needsAction(), note.createdAt(), note.actionResolvedAt());
}
}The mapper is plain code that you write. A mapping library, such as
MapStruct, would generate the same methods. Plain code
has three advantages here. A record's constructor needs every component, so a new component in
NoteResponse is a compile error in the mapper until someone fills it. A debugger
steps through the mapping. And the null check for the notes is visible where it happens.
Choose a library when a feature has many large DTOs that copy fields one to one.
The mapper converts, and nothing else. It has no rule and no call to a repository. A test of
the controller covers it, because the slice test imports the real mapper with
@Import(OrderApiMapper.class).
201 with a Location, 204 with no bodytwist#
[HttpPost]
public IActionResult Add(string orderId, AddNoteRequest request)
{
var noteId = notes.AddNote(orderId, new NewNote(request.Text, request.Category, request.ActionRequired));
return Created($"/orders/{orderId}/notes/{noteId}", new NoteAddedResponse(noteId));
}ResponseEntity<NoteAddedResponse> add(@PathVariable String orderId, @Valid @RequestBody AddNoteRequest request) {
String noteId = notes.addNote(orderId, new NewNote(request.text(), request.category(), request.actionRequired()));
return ResponseEntity.created(URI.create("/orders/" + orderId + "/notes/" + noteId))
.body(new NoteAddedResponse(noteId));
}ResponseEntity.created(URI.create("/orders/" + orderId + "/notes/" + noteId))createdsets the status 201 and theLocationheader. It takes ajava.net.URI, andURI.createbuilds one from the text..body(new NoteAddedResponse(noteId));bodyfinishes the response with a JSON body.
The C# pane gives Created the URI as a string. The Spring method
created takes a URI object. A command that returns nothing ends with
ResponseEntity.noContent().build(), and its return type is
ResponseEntity<Void>. A plain read returns the record itself, and Spring
answers 200. For the other helpers, see the section Returning
results.
The tests check the status and the header. OrderControllerTest.placingReturns201WithTheLocation
expects Location: /orders/{id}, and NoteControllerTest.resolvingReturns204
expects 204. The Location of a new order is the URL of GET /orders/{orderId}, so a
client can read the new order at once.
Paging: PageQuery in, PageResponse outnew#
An agent lists the orders that are waiting. A shop has thousands of orders, so the list comes one page at a time. Every shop service lists data in the same way. So the platform defines one type for the request and one for the answer, and every service uses them.
The list method reads three query parameters:
@OrdersRead
@GetMapping
// ...
PageResponse<OrderResponse> list(@RequestParam(required = false) OrderStatus status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return PageResponse.from(orders.list(status, new PageQuery(page, size)), mapper::toResponse);
}@RequestParam(required = false) OrderStatus status,The status filter is optional, so a missing value is
null. Spring converts the text to the enumOrderStatus. An unknown value such asLOSTis a 400 that names the fieldstatus.@RequestParam(defaultValue = "0") int page,Without the default, a missing
pagewould be a 400. See the section Model binding.new PageQuery(page, size)The controller builds the platform's page request, which checks both numbers.
PageResponse.from(orders.list(status, new PageQuery(page, size)), mapper::toResponse);The use case returns a page of views.
PageResponse.frommaps each view to its response record, and keeps the numbers of the page.mapper::toResponseis a method reference.
PageQuery is a value object in the platform's domain package. It holds no Spring
type, so a use case can take it:
public record PageQuery(int page, int size) implements ValueObject {
public static final int MAX_SIZE = 100;
public static final int DEFAULT_SIZE = 20;
public PageQuery {
if (page < 0) {
throw new InvalidValueException("page", "must not be negative");
}
if (size < 1 || size > MAX_SIZE) {
throw new InvalidValueException("size", "must be between 1 and " + MAX_SIZE);
}
}public PageQuery {This is a compact constructor. It checks the values before the record stores them.
throw new InvalidValueException("size", "must be between 1 and " + MAX_SIZE);A size of 1,000 would load 1,000 orders in one request. The limit protects the database. The exception names the field
size, so the client sees what to correct.
The test thePageSizeIsLimited sends ?size=1000. It expects a 400
that names the field size. The answer has one shape in every service:
public record PageResponse<T>(List<T> items, int page, int size, long totalItems, int totalPages) {
/** Converts a page from the application layer, mapping each item to its response DTO. */
public static <S, T> PageResponse<T> from(PageResult<S> result, Function<? super S, ? extends T> mapper) {
return new PageResponse<>(result.items().stream().<T>map(mapper).toList(),
result.page(), result.size(), result.totalItems(), result.totalPages());
}
}public record PageResponse<T>(List<T> items, int page, int size, long totalItems, int totalPages) {A generic record. Its five components become the five members of the JSON, from
itemstototalPages.Function<? super S, ? extends T> mapperFunctionis likeFunc<S, T>in C#. The question marks are wildcards. They let the method accept a mapper written for a supertype ofS.result.items().stream().<T>map(mapper).toList(),<T>mapnames the type argument ofmapexplicitly, so the result is aList<T>.
PageResult is the platform's page in the domain package. It has the same numbers
and computes totalPages from them. The test listsOrdersAsAPage
expects totalItems 1 and totalPages 1 for one order.
Spring Data has its own types for a page, Pageable and Page. A
feature could pass them from the controller to the repository. That is less code, and it is a
good choice for a simple feature. The order service does not use them in its API, for two
reasons. The use cases and the domain would then depend on Spring Data. And the JSON of a page
would follow Spring Data's shape, not a shape that the shop owns and keeps the same in every
service. The adapter that stores the order
aggregate turns a PageQuery into a query.
For how it does that, see the chapter Storing aggregates in
DocumentDB.
Jackson 3: what changed for request bodiestrap#
Spring Boot 4 reads and writes JSON with Jackson 3.
Boot 3 used Jackson 2. Most code works the same, but one default changed in a way that breaks
clients without a warning. An agent adds a note and does not say whether it needs action. The
note's request record has a boolean for that:
public record AddNoteRequest(
@NotBlank @Size(max = 2000) String text,
@Schema(description = "A NOTE_CATEGORY code", example = "DELIVERY") @NotBlank String category,
@Schema(description = "Whether an agent must act on the note before the order ships") boolean actionRequired) {
}mvc.perform(post("/orders/" + ORDER_ID + "/notes").with(AGENT).contentType(MediaType.APPLICATION_JSON)
.content("""
{ "text": "FYI", "category": "GENERAL" }
"""))Show the answer
Jackson 3 refuses a missing primitive. In Jackson 3.1.7, the feature
FAIL_ON_NULL_FOR_PRIMITIVES is on by default, and Boot keeps it on. A missing
value counts as null. A primitive boolean cannot hold
null. So Jackson throws MismatchedInputException. Spring MVC wraps
it in HttpMessageNotReadableException, and answers 400.
Jackson 2 had the feature off, so the same body gave false on Boot 3.
System.Text.Json also gives false. The platform turns the feature off.
The platform restores the old behaviour for every service, in its defaults file:
# Jackson 3 rejects a JSON body that leaves out a primitive field (boolean, int). Treat a missing
# primitive as its default value instead, so an omitted "actionRequired" means false.
spring.jackson.deserialization.fail-on-null-for-primitives=falsespring.jackson.deserialization is a map of Jackson's features. The key is the
feature's name in lower case, with dashes. With the value false, a missing
primitive gets its default value. A boolean is then false, and an
int is 0. An explicit
"actionRequired": null is false too. The test
NoteControllerTest.anOmittedFlagMeansFalse sends the body above and expects 201,
with false passed to the use case. For how the platform's defaults file works, see
the chapter Configuration in depth.
The setting is a choice. A strict API could keep Jackson's default, and require every client
to send every field. That catches a client that forgot a field. The shop chose the lenient form,
because actionRequired is a flag whose natural default is false. A
field without a natural default should be a
wrapper type, such as Boolean or
Integer, with @NotNull. Then a missing value is a 400 that names the
field.
A service that moves from Boot 3 to Boot 4 can start to refuse requests that worked
before. A client that leaves out a boolean or an int got the
default value on Boot 3. On Boot 4, the same request gets a 400, because Jackson cannot read
the body. No compiler and no unit test of the domain notices it. Only a controller test with a body that
leaves out the field finds it.
Two other defaults matter for the service, and both match what a C# developer expects:
- Unknown properties are ignored. A body with an extra member, such as
"extra": 1, is read without an error. Jackson 3 and Boot's mapper both haveFAIL_ON_UNKNOWN_PROPERTIESoff. A plain Jackson 2 mapper had it on. - An
Instantis an ISO-8601 string. Jackson 3 writes"2026-01-15T09:00:00Z"with no extra module. Jackson 2 needed a module forjava.time.
The test OrderControllerTest.listsOrdersAsAPage relies on the second default.
It expects placedAt to be exactly the string 2026-01-15T09:00:00Z. In
.NET, a UTC DateTime is written with the same Z. A
DateTimeOffset is written with +00:00 instead. A client that
compares the text must expect the Z form from the shop.
One handler for every problemnew#
An agent tries to ship an order that is not paid. The domain throws
OrderNotPaidException. The client must get a 422 with the code
ORDER_NOT_PAID, in the same format as every other error of every shop service.
No feature should write code for that.
The rule is that the kind of the exception decides the status. The domain has exactly three kinds of exception, and each feature extends one of them. For the exception classes themselves, see the chapter The domain model in plain Java. The platform maps each kind to a status:
| The exception | Status | What the problem adds |
|---|---|---|
| InvalidValueException, a failed @Valid, unreadable JSON, a value of the wrong type | 400 | errors, a list of the fields with a message each |
| AccessDeniedException, from method security | 403 | nothing, the detail is "You are not allowed to do this" |
| NotFoundException | 404 | code, such as ORDER_NOT_FOUND |
| OptimisticLockingFailureException, or a write conflict | 409 | code CONCURRENT_UPDATE |
| BusinessRuleViolationException | 422 | code, such as ORDER_NOT_PAID |
| DownstreamServiceException | 503 | nothing, and the log names the service that failed |
| anything else | 500 | nothing, and the log holds the exception |
One class does this for every service. The platform registers it as a bean, so a service gets it by adding the library:
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private static final String INVALID_REQUEST = "Invalid request";
@ExceptionHandler(NotFoundException.class)
ResponseEntity<ProblemDetail> notFound(NotFoundException ex) {
return respond(domainProblem(HttpStatus.NOT_FOUND, "Not found", ex));
}
@ExceptionHandler(InvalidValueException.class)
ResponseEntity<ProblemDetail> invalidValue(InvalidValueException ex) {
ProblemDetail problem = domainProblem(HttpStatus.BAD_REQUEST, INVALID_REQUEST, ex);
problem.setProperty("errors", List.of(fieldError(ex.field(), ex.getMessage())));
return respond(problem);
}
@ExceptionHandler(BusinessRuleViolationException.class)
ResponseEntity<ProblemDetail> businessRule(BusinessRuleViolationException ex) {
return respond(domainProblem(HttpStatus.UNPROCESSABLE_CONTENT, "Business rule violated", ex));
}@RestControllerAdviceThe class is a controller advice. Its handlers apply to the exceptions of every controller. For the basics, see the section Global exception handling.
@Order(Ordered.LOWEST_PRECEDENCE)This advice is asked last. See the section Two advices, and why @Order decides.
extends ResponseEntityExceptionHandler {The base class comes with Spring MVC. It handles Spring MVC's own exceptions, such as a 405 or a 415. The platform inherits those handlers.
@ExceptionHandler(NotFoundException.class)This method handles
NotFoundExceptionand every subclass of it, such asOrderNotFoundException.problem.setProperty("errors", List.of(fieldError(ex.field(), ex.getMessage())));setPropertyadds a member to the problem. Spring writes it at the top level of the JSON, next totitleandstatus.
The helper domainProblem builds the problem with the message of the exception as
the detail. It also adds the code of the exception:
private static ProblemDetail domainProblem(HttpStatus status, String title, DomainException ex) {
ProblemDetail problem = ProblemDetails.problem(status, title, ex.getMessage());
problem.setProperty("code", ex.code());
return problem;
}ex.getMessage() becomes the detail, and ex.code() becomes the member
code.
A run for this book made the same calls in a small test application. It wrote this body for a 422. Its title is the default title of Spring, because the run set no title. The platform sets the title "Business rule violated" instead:
{"detail":"not paid","instance":"/orders/42/rule","status":422,"title":"Unprocessable Content","code":"ORDER_NOT_PAID","traceId":"t-123"}The members code and traceId are at the top level. The test
aBrokenBusinessRuleIsA422WithItsCode expects code to be
ORDER_NOT_PAID. A client may rely on the code, so a feature never changes it.
The other handlers of the class answer 403, 503 and 500:
@ExceptionHandler(AccessDeniedException.class)
ResponseEntity<ProblemDetail> accessDenied(AccessDeniedException ex) {
return respond(ProblemDetails.problem(HttpStatus.FORBIDDEN, "Forbidden", "You are not allowed to do this"));
}
@ExceptionHandler(DownstreamServiceException.class)
ResponseEntity<ProblemDetail> downstream(DownstreamServiceException ex) {
log.warn("Dependency {} is unavailable", ex.dependency(), ex);
return respond(ProblemDetails.problem(HttpStatus.SERVICE_UNAVAILABLE, "Dependency unavailable",
"A service this request depends on is unavailable. Try again later."));
}
@ExceptionHandler(Exception.class)
ResponseEntity<ProblemDetail> unexpected(Exception ex) {
log.error("Unexpected error", ex);
return respond(ProblemDetails.problem(HttpStatus.INTERNAL_SERVER_ERROR, "Internal error",
"Something went wrong. Quote the traceId if you contact support."));
}"You are not allowed to do this"The 403 says nothing about which scope is missing, or which record the caller tried to reach.
log.warn("Dependency {} is unavailable", ex.dependency(), ex);The 503 is logged as a warning, with the name of the service, such as
stock. An outage of the stock service is then visible in the logs. For the stock client and its retries, see the chapter Calling other services.@ExceptionHandler(Exception.class)The catch-all for everything else. It logs the exception as an error, with its stack trace, and answers with a text that reveals nothing.
Inside one advice class, the closest match wins. A run showed that a
ResponseEntityExceptionHandler subclass with a handler for Exception
still sends MethodArgumentNotValidException to the base class. So the catch-all
does not take the 400s. AccessDeniedException still gets its own handler, and its
403.
The platform overrides three of the inherited handlers, so that every 400 has the same shape.
The first one lists the fields of a failed @Valid:
@Override
protected @Nullable ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
ProblemDetail problem = ProblemDetails.problem(HttpStatus.BAD_REQUEST, INVALID_REQUEST,
"One or more fields are invalid");
problem.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
.map(error -> fieldError(error.getField(), String.valueOf(error.getDefaultMessage())))
.toList());
return ResponseEntity.badRequest().body(problem);
}protected @Nullable ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,This method of the base class handles a failed
@Valid. The platform overrides it.@Nullablecomes from JSpecify, and says that the method may returnnull. A second advice class for the same exception would compete with this one. See the section One advice class, not two.ex.getBindingResult().getFieldErrors().stream()The exception holds one error for each field that failed. Each becomes a
{"field": ..., "message": ...}entry.
The second override is handleHttpMessageNotReadable. It answers JSON that
cannot be read with a 400. Its detail says that the body is not valid JSON, or that a value has
the wrong type. A third override, handleTypeMismatch, names the field of a query
parameter of the wrong type, such as ?status=LOST.
The InvalidValueException handler serves one more case. A delivery method must
be an active value of a lookup list. The reference feature checks that for the other
features:
public void requireActive(String type, String code, String field) {
boolean known = items.findByTypeOrderByCodeAsc(type).stream()
.anyMatch(item -> item.code().equals(code) && item.active());
if (!known) {
throw new InvalidValueException(field, "is not a known value");
}
}The caller passes the name of its request field as field, such as
deliveryMethod. An unknown code then throws an InvalidValueException.
That is a 400 that names the field, not a 422. The client sent a value that is not allowed, as with a bad email
address. It breaks no rule about the order itself.
The status is chosen by the kind, in one place. The careful alternative is a handler in each feature, written next to the feature's exceptions. It keeps the HTTP status close to the code that throws, and a reader of the feature sees both in one package. But each feature would repeat the same mapping, and the shapes would drift apart. The domain also must not know about HTTP. An architecture rule forbids Spring in the domain. With the mapping by kind, a new rule is one new subclass, and its status needs no new code. A handler in a feature is the better choice only for an exception that no kind describes, and that should be rare.
In ASP.NET Core, AddProblemDetails() and an IExceptionHandler do
the same job:
public class DomainExceptionHandler(IProblemDetailsService problems) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken token)
{
if (exception is not InvalidOperationException) return false;
context.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
return await problems.TryWriteAsync(new() { HttpContext = context, Exception = exception });
}
}@ExceptionHandler(BusinessRuleViolationException.class)
ResponseEntity<ProblemDetail> businessRule(BusinessRuleViolationException ex) {
return respond(domainProblem(HttpStatus.UNPROCESSABLE_CONTENT, "Business rule violated", ex));
}The C# handler returns false for an exception it does not handle, and the next
handler gets a turn. The Java method names its exception type in the annotation, and Spring
chooses the method. A run of the C# handler on ASP.NET Core 10 wrote a 422 problem with a
traceId. The controller tests of the order service check every row of the status
table. The quick reference lists them.
A traceId on every problemtrap#
Support staff ask a customer for the traceId of a failed request. With it, they
find every log line of that request, in every service it reached. So every problem must carry
it. The platform's helper adds it when it builds a problem:
/** Creates a problem with a title, a detail text and the trace ID. */
public static ProblemDetail problem(HttpStatusCode status, String title, String detail) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);
problem.setTitle(title);
return withTraceId(problem);
}
/** Adds the trace ID to a problem that someone else created, such as Spring MVC. */
public static ProblemDetail withTraceId(ProblemDetail problem) {
String traceId = MDC.get(TRACE_ID);
if (traceId != null) {
problem.setProperty(TRACE_ID, traceId);
}
return problem;
}String traceId = MDC.get(TRACE_ID);Micrometer Tracing puts the trace ID of the request into the mapped diagnostic context (MDC), under the key
traceId. A run showed that the ID is there even when the trace is not sampled. For tracing, see the chapter Logging, tracing and health.if (traceId != null) {A slice test runs no tracing, so the MDC can be empty. Then the problem has no
traceId, and nothing fails.
Every handler of the platform calls ProblemDetails.problem. But the handlers
that the class inherits from ResponseEntityExceptionHandler build their own
problems. That base class has handlers for 20 exception types of Spring MVC. They include a 405,
a 415, a missing parameter and a path with no controller.
A traceId added only in your own handlers is missing from Spring MVC's
problems. An advice that calls a helper like ProblemDetails.problem in each
of its own handlers looks complete. But a bad enum in the query, a 405, a 404 for an unknown
path and a 415 all go to the inherited handlers. Their problems then carry no
traceId. The support desk cannot find the logs of exactly those requests.
Every inherited handler ends in one method of the base class,
handleExceptionInternal. The platform overrides that one method:
/**
* Every handler of {@link ResponseEntityExceptionHandler} passes through this method, so the
* trace ID reaches the problems Spring MVC creates itself, such as 405 and 415.
*/
@Override
protected @Nullable ResponseEntity<Object> handleExceptionInternal(Exception ex, @Nullable Object body,
HttpHeaders headers, HttpStatusCode statusCode,
WebRequest request) {
ResponseEntity<Object> response = super.handleExceptionInternal(ex, body, headers, statusCode, request);
if (response != null && response.getBody() instanceof ProblemDetail problem) {
ProblemDetails.withTraceId(problem);
}
return response;
}ResponseEntity<Object> response = super.handleExceptionInternal(ex, body, headers, statusCode, request);The base class builds the response first. For most exceptions, the
bodyargument isnull, andsupercreates the problem. So the method adds the ID to the result ofsuper, not to the argument.response.getBody() instanceof ProblemDetail problemA type pattern. It checks that the body is a
ProblemDetail, and names itproblemin one step.ProblemDetails.withTraceId(problem);The platform's helper adds the
traceIdmember.
A run with this override gave every inherited case a traceId. The run tried six
cases. They were a bad enum in the query, a 405, a 404 with no handler, a 415, broken JSON and
an invalid body. The order service tests the same cases in the nested class
OrderControllerTest.TraceId:
@BeforeEach
void startATrace() {
// In a slice test no tracing runs, so the test puts a trace ID where tracing would.
MDC.put("traceId", "trace-1");
}
// ...
@Test
void onAMethodThatIsNotAllowed() throws Exception {
mvc.perform(delete("/orders/" + ORDER_ID).with(AGENT))
.andExpect(status().isMethodNotAllowed())
.andExpect(jsonPath("$.traceId").value("trace-1"));
}The class has seven tests, one for each case of the run and one for a missing token. The 401 gets its
traceId from the security chain's own entry point, which uses the same helper.
In ASP.NET Core, AddProblemDetails() adds a traceId to the problems
that it writes. A run of the 422 above showed it, and the automatic 400 of
[ApiController] has one too. In Spring, nothing adds it for you. In the same run, a
405 of ASP.NET Core 10 had an empty body. So a 405 is a case where the Spring service gives the
client more than the default ASP.NET Core service does.
422 Unprocessable Content, and 409 only for a concurrent updatetwist#
context.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;return respond(domainProblem(HttpStatus.UNPROCESSABLE_CONTENT, "Business rule violated", ex));Spring Framework 7 names the status 422 UNPROCESSABLE_CONTENT, with the reason
phrase "Unprocessable Content". The old name, UNPROCESSABLE_ENTITY, still exists
and is deprecated. The test matchers changed in the same way. The new matcher is
status().isUnprocessableContent(), and the old isUnprocessableEntity() is
deprecated.
In .NET 10, StatusCodes has only Status422UnprocessableEntity. The
enum HttpStatusCode has both names.
The order service answers 422 for every broken business rule, and 409 only for a concurrent update. A careful team can choose differently. The name of 409 is Conflict. A team may read a rule that the current state breaks as such a conflict. Shipping a cancelled order conflicts with the state of the order, so that team answers 409 for it. The reading is sound, and it keeps 422 for a request that is wrong in itself.
The shop still keeps 409 and 422 apart, because each status asks the client for a different reaction:
| Status | What happened | What the client does |
|---|---|---|
| 409 CONCURRENT_UPDATE | someone else changed the same order at the same moment | reload the order, and try the same action again |
| 422 with a code, such as ORDER_NOT_PAID | the order's rules refuse the action | show the reason to the user, and do not retry |
With one status for both, a client must read the code to know whether a retry
can work. With two statuses, a client can act on the status alone. The 422 always carries a
stable code, and the OpenAPI contract lists the codes of each operation. The test
aBrokenBusinessRuleIsA422WithItsCode records the 422, and the tests of the next
sections record the 409.
Two advices, and why @Order decidestrap#
The platform has two advices. The global handler is one. The 409 handler in the mongo
package is the other. Both could answer an OptimisticLockingFailureException,
because the global handler has a catch-all for Exception. A C# developer expects
the more specific handler to win.
Spring chooses the advice before it chooses the method. It asks the advices
in order. The first advice that has any matching handler answers, even if a later advice has a
closer match. So a catch-all in the first advice wins over a specific handler in the second. A
run with two advices and no @Order showed that the class names then decide:
| The two advices, with no @Order | Who answered a ConflictException |
|---|---|
| AaaCatchAll and ZzzSpecific | the catch-all, with 500 |
| AaaSpecific and ZzzCatchAll | the specific advice, with 409 |
Nothing warns you, and a renamed class changes the answer.
@Order fixes the order. A lower number comes first. The platform gives the
catch-all the lowest precedence, and the specific advice the highest:
@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
public class OptimisticLockingExceptionHandler {The constant Ordered.HIGHEST_PRECEDENCE is Integer.MIN_VALUE. The
constant Ordered.LOWEST_PRECEDENCE is Integer.MAX_VALUE. A run with these two values let the
specific advice answer 409, whatever the class names were. So the rule is short. Give a
catch-all advice the lowest precedence. Give every specific advice a higher one.
Spring Boot has its own advice for problem details too. It is switched on only by
spring.mvc.problemdetails.enabled, and it backs off when a
ResponseEntityExceptionHandler bean exists. The platform's handler is such a bean,
so Boot's advice is never created. For more, see the section
ProblemDetail.
ASP.NET Core also tries its handlers one after the other. A run registered a catch-all
IExceptionHandler before a specific one. The catch-all answered with 500. So in
both platforms, the order decides. In ASP.NET Core, the order of registration is visible in
Program.cs. In Spring, without @Order, the order is hidden.
The 409 handler in the mongo packagenew#
Two agents pick lines of the same order at the same moment. Each request loads the order, changes it and saves it. The second save finds that the stored version has changed. For how the version works, see the chapter Storing aggregates in DocumentDB. The client of the second request must get a 409, and then reload and try again.
The exception that signals this comes from Spring Data and MongoDB. The platform's web code should not know about MongoDB. So the 409 handler is in the platform's mongo package, next to the technology that throws. The web package knows nothing about it:
@ExceptionHandler(OptimisticLockingFailureException.class)
ResponseEntity<ProblemDetail> staleVersion(OptimisticLockingFailureException ex) {
return conflict();
}
@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<ProblemDetail> dataIntegrity(DataIntegrityViolationException ex) {
if (WriteConflicts.isWriteConflict(ex)) {
return conflict();
}
log.error("Unexpected data error", ex);
return ResponseEntity.internalServerError().body(ProblemDetails.problem(HttpStatus.INTERNAL_SERVER_ERROR,
"Internal error", "Something went wrong. Quote the traceId if you contact support."));
}
private static ResponseEntity<ProblemDetail> conflict() {
ProblemDetail problem = ProblemDetails.problem(HttpStatus.CONFLICT, "Concurrent update",
"Someone else changed this at the same time. Reload it and try again.");
problem.setProperty("code", "CONCURRENT_UPDATE");
return ResponseEntity.status(HttpStatus.CONFLICT).body(problem);
}@ExceptionHandler(OptimisticLockingFailureException.class)Outside a transaction, Spring Data throws this exception when the stored version differs.
@ExceptionHandler(DataIntegrityViolationException.class)Inside a transaction, MongoDB usually stops the second writer earlier, with a write conflict. Spring translates that into a
DataIntegrityViolationException.if (WriteConflicts.isWriteConflict(ex)) {The same Spring exception also covers real data errors. So the handler checks the cause, and answers 409 only for a write conflict. Any other data error is a 500.
problem.setProperty("code", "CONCURRENT_UPDATE");The code tells the client to reload and retry.
The check looks for MongoDB's error code for a write conflict, anywhere in the chain of causes:
public static boolean isWriteConflict(Throwable error) {
for (Throwable cause = error; cause != null; cause = cause.getCause()) {
if (cause instanceof MongoException mongo
&& (mongo.getCode() == WRITE_CONFLICT
|| mongo.hasErrorLabel(MongoException.TRANSIENT_TRANSACTION_ERROR_LABEL))) {
return true;
}
}
return false;
}WRITE_CONFLICT is 112, MongoDB's error code. The label
TransientTransactionError marks an error after which the whole transaction may be
tried again. For write conflicts and transactions in depth, see the chapter
Transactions in depth.
The handler is registered by an auto-configuration of the mongo package, and only in a web application:
@AutoConfiguration
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public class PlatformMongoWebAutoConfiguration {
@Bean
OptimisticLockingExceptionHandler optimisticLockingExceptionHandler() {
return new OptimisticLockingExceptionHandler();
}
}@ConditionalOnWebApplication creates the bean only in a servlet web
application, so a worker with no web server gets no handler. For auto-configurations, see the
chapter
Writing the platform library.
Two controller tests cover the two shapes. aStaleVersionIsA409 throws an
OptimisticLockingFailureException. aWriteConflictInsideATransactionIsAlsoA409
builds the exception that MongoDB would send:
@Test
void aWriteConflictInsideATransactionIsAlsoA409() throws Exception {
MongoCommandException writeConflict = new MongoCommandException(
BsonDocument.parse("{ok: 0, code: 112, codeName: 'WriteConflict', errmsg: 'Write conflict'}"),
new ServerAddress());
doThrow(new DataIntegrityViolationException("Write conflict", writeConflict)).when(orders).cancel(any());
mvc.perform(post("/orders/" + ORDER_ID + "/cancel").with(AGENT))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("CONCURRENT_UPDATE"));
}The test builds a MongoCommandException with the code 112. It puts that exception
inside a DataIntegrityViolationException, as the translation of Spring would. The
slice imports the web auto-configuration of the mongo package, so the real handler answers. The
closest match in ASP.NET Core is a handler that you write for
DbUpdateConcurrencyException.
What a problem never containsnew#
A problem goes to the client, and often to the browser of a customer-service agent. Anything in it can appear in a screenshot or a support ticket. So two things never appear in a problem of the shop. One is personal data. The other is a stack trace or any internal detail.
The message of a domain exception becomes the detail of the problem. So the
message must hold IDs, and never a name, an email address or an address. The base class of
every domain exception states the rule:
/**
* The base of every exception the domain throws. It has exactly three kinds, and the kind
* decides the HTTP status, so a feature never writes an exception handler.
*
* <ul>
* <li>{@link NotFoundException}: the thing does not exist (404).</li>
* <li>{@link InvalidValueException}: a value has the wrong format (400).</li>
* <li>{@link BusinessRuleViolationException}: the request breaks a business rule (422).</li>
* </ul>
*
* <p>The class is sealed, so no other direct subclass can exist. Extend one of the three kinds.
* The message is returned to the caller, so it must never hold personal data. IDs are fine.
*/
public abstract sealed class DomainException extends RuntimeException
permits NotFoundException, InvalidValueException, BusinessRuleViolationException {The class is a sealed type. Its
permits clause names the three kinds, so no fourth kind can appear by
accident. OrderNotPaidException follows the rule. Its message is
"Order 7b1e... cannot ship before it is paid", with the order's ID and nothing about the
customer. An invalid email address gives the message "customer.email has an invalid format".
It names the field, and not the value that the client sent.
For every other exception, the catch-all answers with a fixed text, "Something went wrong.
Quote the traceId if you contact support." The details go only to the log, with the
traceId. The 409 handler does the same for a data error that is not a write
conflict. The test anUnexpectedErrorIsA500WithoutItsMessage throws an exception
with a secret in its message. It checks that the answer is a 500 with the fixed text, and that
the body does not contain the secret.
Spring Boot also has an error response of its own. The platform's defaults switch off the parts of it that could leak:
# Boot's own error responses never contain a stack trace or an exception message. Both values
# are Boot's defaults, restated. Boot 4 renamed the keys, and it ignores the old server.error.* keys.
spring.web.error.include-stacktrace=never
spring.web.error.include-message=neverThese two settings say when the error response of Spring Boot includes the stack trace and
the exception message. With never, the response includes no stack trace and no
message.
Boot 4 has new names for these two keys. The configuration metadata of Boot
4.1.2 marks server.error.include-stacktrace and
server.error.include-message as deprecated since 4.0.0, at the level error. Their
replacements are spring.web.error.include-stacktrace and
spring.web.error.include-message. A run for this book set the old
include-message key to always, and Boot 4.1.2 still left the message
out. So Boot 4.1.2 ignores the old key. A service that copies the key from a Boot 3 service
loses the setting, and must use the new name.
The default of each new key is never, so the two lines change nothing today. The
platform keeps them as a statement of the rule, as its comment says. For the run and for the
other keys that Boot 4 renamed, see the section Boot 4 keys that changed their
names.
A 401 never reaches the advice. The security chain writes it, with the same title, status and
traceId. See the section One request through the checks.
For masking personal data in the logs, see the chapter Logging, tracing and
health.
The MDC interceptornew#
Support staff look for every log line about one order. So the platform copies chosen path
variables, such as orderId, into the
mapped diagnostic context (MDC) for each request. The MDC is
the logging context of the thread. Every log line of the request then carries the values.
This section shows the three parts of the interceptor that belong to Spring MVC. They are its callbacks, the request attribute that holds the path variables, and its registration. For the setting and the log lines, see the section Path variables in the MDC, with an interceptor.
The constructor of the interceptor takes the names to copy. They come from the setting
platform.logging.mdc-path-variables:
public class PathVariableMdcInterceptor implements HandlerInterceptor {
private final List<String> names;
public PathVariableMdcInterceptor(List<String> names) {
this.names = List.copyOf(names);
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE) instanceof Map<?, ?> variables) {
for (String name : names) {
if (variables.get(name) instanceof String value) {
MDC.put(name, value);
}
}
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
Exception ex) {
names.forEach(MDC::remove);
}
}public class PathVariableMdcInterceptor implements HandlerInterceptor {HandlerInterceptorhas the three callbacks. Each one is a default method of the interface, so the class implements only the two it needs.request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE)Spring MVC stores the path variables of the matched method in this request attribute, as a map. For
POST /orders/42/ship, the map is{orderId=42}.instanceof Map<?, ?> variables) {A type pattern. The attribute has the type
Object, and it can benull. The pattern is false fornull, so the code needs no separate null check. The type arguments of a map are not known at run time, so the pattern writesMap<?, ?>, a map of unknown types.if (variables.get(name) instanceof String value) {A second pattern. A name that the path does not have gives
null, and nothing is put into the MDC.return true;truelets the request continue.falsewould stop it here.names.forEach(MDC::remove);afterCompletionruns at the end of the request, even after an exception. So the interceptor removes its values there.
Spring MVC fills the attribute only after it has matched a controller method. So an
interceptor is the right place to read path variables. A servlet filter runs earlier, and it has
no path variables yet. The attribute holds an empty map for a path with no variables, and it is
null when no controller method matched. For the measured cases, see the section
Path variables in the MDC, with an interceptor.
The platform registers the interceptor through a WebMvcConfigurer bean:
/** Registers the MDC interceptor. A {@link WebMvcConfigurer} bean is how a library adds to Spring MVC. */
@Bean
WebMvcConfigurer platformMdcWebMvcConfigurer(PlatformLoggingProperties properties) {
PathVariableMdcInterceptor interceptor = new PathVariableMdcInterceptor(properties.getMdcPathVariables());
return new WebMvcConfigurer() {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(interceptor);
}
};
}WebMvcConfigurer platformMdcWebMvcConfigurer(PlatformLoggingProperties properties) {Spring MVC reads its settings from the
WebMvcConfigurerbeans. So a library adds to Spring MVC with a bean of its own, and a service can still have its own bean too.return new WebMvcConfigurer() {An anonymous class that implements the interface. It overrides one method, and keeps the defaults of the others.
registry.addInterceptor(interceptor);The interceptor now runs for every controller method.
The closest match in C# is context.Request.RouteValues["orderId"], read in a
middleware. For the interceptor's test, and for how the MDC compares with
ILogger.BeginScope, see the section Path variables in the
MDC, with an interceptor.
1An expert asksThe order service takes @PathVariable String orderId, and calls
OrderId.of(orderId) inside the method. Why not declare the parameter as a
UUID?
@PathVariable String orderId, and calls
OrderId.of(orderId) inside the method. Why not declare the parameter as a
UUID?Identifier decides what a valid
ID is, the same way for every ID. And its InvalidValueException gives a 400 whose
errors list names the field orderId.2An expert asksWhy does GlobalExceptionHandler override
handleExceptionInternal, when every one of its own handlers already adds the
traceId?
GlobalExceptionHandler override
handleExceptionInternal, when every one of its own handlers already adds the
traceId?traceId. Every one of them ends in handleExceptionInternal. So one
override adds the traceId to all of them.3The platform has a catch-all advice and a specific 409 advice. What decides which one answers
an OptimisticLockingFailureException, and how does the platform make it
reliable?
OptimisticLockingFailureException, and how does the platform make it
reliable?@Order, the class names decide. The platform gives the catch-all
@Order(Ordered.LOWEST_PRECEDENCE) and the 409 advice
@Order(Ordered.HIGHEST_PRECEDENCE), so the 409 advice always answers.4TranslateWrite this action as a method of the shop's note controller, whose class
maps /orders/{orderId}/notes. It needs the orders.write scope:
[HttpPost]
public IActionResult Add(string orderId, AddNoteRequest request)
{
var noteId = notes.AddNote(orderId, new NewNote(request.Text, request.Category, request.ActionRequired));
return Created($"/orders/{orderId}/notes/{noteId}", new NoteAddedResponse(noteId));
}
/orders/{orderId}/notes. It needs the orders.write scope:
[HttpPost]
public IActionResult Add(string orderId, AddNoteRequest request)
{
var noteId = notes.AddNote(orderId, new NewNote(request.Text, request.Category, request.ActionRequired));
return Created($"/orders/{orderId}/notes/{noteId}", new NoteAddedResponse(noteId));
}@OrdersWrite
@PostMapping
// ...
ResponseEntity<NoteAddedResponse> add(@PathVariable String orderId, @Valid @RequestBody AddNoteRequest request) {
String noteId = notes.addNote(orderId, new NewNote(request.text(), request.category(), request.actionRequired()));
return ResponseEntity.created(URI.create("/orders/" + orderId + "/notes/" + noteId))
.body(new NoteAddedResponse(noteId));
}@Valid is needed, or the constraints of the record are never checked.
ResponseEntity.created takes a URI, and sets 201 and the
Location header. body adds the JSON.
Quick reference#
The endpoints of the order service, and their success statuses:
| Method and path | Success | Errors the contract lists |
|---|---|---|
| POST /orders | 201, with a Location | 400, 401, 403, 422 PRODUCTS_UNAVAILABLE, 503 |
| GET /orders?status=&page=&size= | 200, one page | 400, 401, 403 |
| GET /orders/{orderId} | 200 | 400, 401, 403, 404 |
| POST /orders/{orderId}/lines/{lineId}/pick | 204 | 400, 401, 403, 404, 409, 422 |
| POST /orders/{orderId}/ship | 204 | 400, 401, 403, 404, 409, 422 |
| POST /orders/{orderId}/cancel | 204 | 400, 401, 403, 404, 409, 422 |
| POST /orders/{orderId}/notes | 201, with a Location | 400, 401, 403, 404 |
| GET /orders/{orderId}/notes | 200, one page | 400, 401, 403, 404 |
| POST /orders/{orderId}/notes/{noteId}/resolve | 204 | 401, 403, 404, 409, 422 |
| GET /reference-data/{type} | 200 | 401, 403, 404 |
The members of a problem of the shop:
| Member | When it appears | What it holds |
|---|---|---|
| status | always | the HTTP status, as a number |
| title | always | a short text, such as Invalid request or Business rule violated |
| detail | always | the explanation, with IDs and never personal data |
| instance | always, because Spring MVC adds it | the path of the request |
| traceId | whenever the MDC holds a trace ID, which tracing sets for each request | the ID that finds every log line of the request |
| code | 404, 409 and 422 | a stable code, such as ORDER_NOT_PAID or CONCURRENT_UPDATE |
| errors | 400 | a list of fields, each with field and message |
The controller tests that check each status:
| Status | Test |
|---|---|
| 400, every invalid field | OrderControllerTest.invalidFieldsAreAllListed |
| 400, a bad ID in the path | OrderControllerTest.anInvalidIdInThePathIsA400ThatNamesTheField |
| 400, a page size over 100 | OrderControllerTest.thePageSizeIsLimited |
| 401 | OrderControllerTest.Security.aRequestWithoutATokenIsA401ProblemBeforeAnyController |
| 403 | OrderControllerTest.Security.aCallerWithoutTheWriteScopeIsA403AndNothingRuns |
| 404 | OrderControllerTest.aMissingOrderIsA404WithItsCode |
| 409, a stale version | OrderControllerTest.aStaleVersionIsA409 |
| 409, a write conflict | OrderControllerTest.aWriteConflictInsideATransactionIsAlsoA409 |
| 422 | OrderControllerTest.aBrokenBusinessRuleIsA422WithItsCode |
| 503 | OrderControllerTest.anUnavailableDependencyIsA503 |
| 500, and no exception message in the body | OrderControllerTest.anUnexpectedErrorIsA500WithoutItsMessage |
| a traceId on Spring MVC's problems | the seven tests of OrderControllerTest.TraceId |
The settings of the web layer:
| Setting | Value in the platform | What it does |
|---|---|---|
| spring.jackson.deserialization.fail-on-null-for-primitives | false | a missing boolean or int gets its default value, instead of a 400 |
| spring.web.error.include-stacktrace | never, the default restated | Boot's own error response holds no stack trace, and the old server.error key is ignored |
| spring.web.error.include-message | never, the default restated | Boot's own error response holds no exception message, and the old server.error key is ignored |
| platform.logging.mdc-path-variables | set by each service, here orderId, lineId, noteId | the path variables that the interceptor copies into the MDC |
| spring.mvc.problemdetails.enabled | not set, so false | Boot's own problem advice, which backs off beside the platform's handler anyway |
The 20 exception types that ResponseEntityExceptionHandler handles in Spring
Framework 7.0.10:
| Group | Exception types |
|---|---|
| the request does not match | HttpRequestMethodNotSupportedException, HttpMediaTypeNotSupportedException, HttpMediaTypeNotAcceptableException, NoHandlerFoundException, NoResourceFoundException |
| a value is missing or wrong | MissingPathVariableException, MissingServletRequestParameterException, MissingServletRequestPartException, ServletRequestBindingException, TypeMismatchException, ConversionNotSupportedException |
| the body is wrong | HttpMessageNotReadableException, MethodArgumentNotValidException, HandlerMethodValidationException, MethodValidationException, MaxUploadSizeExceededException |
| other | HttpMessageNotWritableException, AsyncRequestTimeoutException, AsyncRequestNotUsableException, ErrorResponseException |
API and event contracts#
A contract is the shape of an API or an event that other teams build against. The order service generates its OpenAPI file from the code, commits it, and fails the build when the committed file differs. This chapter compares that code-first way with contract first, and says when each is right. It then covers the JSON schemas of events, versions, and tolerant readers.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | an OpenAPI file that a team keeps beside the code | contracts/openapi.yaml, generated from the code and committed | the file is a contract with the UI and other teams, so it must match what the code does Why |
| trap | a hand-written openapi.yaml that a review keeps in step | the same file, which nothing checks | nothing fails when the code and the file differ, so the file drifts with every change Why |
| twist | AddOpenApi() and MapOpenApi() | the dependency springdoc-openapi-starter-webmvc-ui, version 3.2.0 | springdoc reads the controllers, records and constraints, and serves the document at /v3/api-docs Why |
| twist | [ProducesResponseType] and XML comments | @Tag, @Operation, @ApiResponse, @Schema and @Parameter | the annotations add what the code cannot say, such as summaries, examples and error responses Why |
| twist | options.AddDocumentTransformer(...) | an OpenApiCustomizer bean in the platform library | it adds the security scheme, the Problem schema and the shared error responses to every service Why |
| trap | TypedResults.Created(...) is documented as 201 | ResponseEntity.created(...) is documented as 200 | springdoc cannot see the status inside the method, so a 201 needs @ApiResponse(responseCode = "201") Why |
| twist | OpenApiGenerateDocumentsOnBuild | OpenApiContractTest, which reads /v3/api-docs.yaml through MockMvc | the build writes the file with no port, fixes the server URL, and fails when the committed file differs Why |
| twist | the aspnetcore generator of openapi-generator | openapi-generator-maven-plugin 7.26.0 | the plugin generates interfaces and models in every Maven build, so a changed signature fails the compile Why |
| new | no equivalent | the option useRecords, which version 7.26.0 does not have | the generator's web page lists it, but 7.26.0 still writes classes with getters and setters Why |
| new | no equivalent | code first for an internal API, contract first for an API that others sign off first | the recommendation follows who must agree the API, and when Why |
| twist | Microsoft.AspNetCore.OpenApi, with OpenAPI 3.1 | springdoc, with OpenAPI 3.1 | both generate the document from the code, but only springdoc brings Swagger UI in the same dependency Why |
| new | a schema for each message, if the team writes one | contracts/events/OrderPlacedV1.json, beside the record OrderPlacedV1 | the schema is the contract for other teams, and a test checks each published event against it Why |
| twist | a version in the type name, such as OrderShippedV1 | OrderShippedV1, a record | within V1, fields are only added, and a breaking change is published as V2 beside V1 Why |
| twist | Guid.NewGuid() as the message ID | IntegrationEvent.newEventId(), called once when the event is created | a resend keeps the ID, so the consumer's inbox detects the duplicate Why |
| twist | JsonSerializer ignores an unknown property by default | @JsonIgnoreProperties(ignoreUnknown = true) on the consumer's record | Jackson 3 ignores them by default too, and the annotation keeps that true whatever the mapper's settings Why |
| twist | [JsonRequired] on each property | a compact constructor that refuses a missing field | a wrong subscription then fails loudly, and the message is not lost Why |
| new | a shared NuGet package of message classes | our own copy of a consumed event, a record and a schema with only the fields we use | each team owns its classes, and a contract change is checked by reading one file Why |
| new | no equivalent | an IntegrationEventMapper that returns Optional.empty() for internal events | every published event is a contract to keep, so the service publishes only what others need Why |
Why the service publishes contractsnew#
The shop's web application shows orders to customer-service agents. Another team builds it. The QA team tests the order service with its own tools. The stock service reads the events that the order service publishes. None of these teams reads the Java code. Each of them builds against a description of what the order service accepts and returns.
That description is called a contract. The order service has two kinds:
| Contract | Format | Who builds against it |
|---|---|---|
| the HTTP API | an OpenAPI 3.1 document, contracts/openapi.yaml | the web application, QA, and any other caller |
| each published event | a JSON Schema file, such as contracts/events/OrderPlacedV1.json | each service that consumes the event |
Both are files in the repository of the service, in the folder contracts:
order-service/
contracts/
openapi.yaml the HTTP API, generated from the code
events/
OrderPlacedV1.json the events that the service publishes
OrderShippedV1.json
OrderCancelledV1.json
consumed-PaymentReceivedV1.json the part of a consumed event that the service reads
src/A change to a contract is then a change to a file in contracts, and a pull
request shows it as a diff.
Teams share the contracts, and not a library of the order service's classes. A shared library of request records and event records has a real strength. Both sides compile against one definition, so some breaking changes are compile errors. It is a good choice when one team owns the producer and every consumer, and they always deploy together. But between teams, a shared library ties every consumer to Java, and to the same versions of Jackson and Spring. An upgrade of the library becomes a release that every team must take. And a library invites behaviour into the shared classes. A file in a standard format has none of these costs. Each team generates or writes its own classes from it.
The OpenAPI file also documents the records of the web layer. These records are called data transfer objects (DTOs). For why the API uses them and not the domain classes, see the section Request and response DTOs as records.
A hand-written contract driftstrap#
A team writes contracts/openapi.yaml itself, before it builds the endpoints.
It agrees the file with the UI team and QA. The pull request template has a line, "the
controller and openapi.yaml match". This is the careful version of a hand-written
contract, and it gets two things right. The API is agreed before anyone writes code, and the
file is reviewed like code.
Six months later, the file and the code differ, in ways like these:
- Several operations list no 401 and no 403, although every endpoint needs a token and a scope.
- An operation that takes an ID in the path lists no 400, although a malformed ID gets one.
- A list operation lists no 400 for a page size that is too large.
- A path parameter declares an
enumof allowed values, but the code accepts any value, and answers200 []for an unknown one.
Nothing fails when a hand-written contract and the code differ. The build compiles the code and never reads the file. A test calls the code, not the file. The pull request checklist line is ticked by a person, who checks what they remember to check. So each change that forgets the file adds one more difference, and nobody notices. The UI team then builds against a file that is wrong.
There are two standard fixes. Both make the build compare the file with the code:
| Fix | Where the truth is | What fails the build |
|---|---|---|
| code first | the controllers and DTOs, and the file is generated from them | a test, when the committed file differs from the generated one |
| contract first | the file, and code is generated from it | the compiler, when a controller no longer matches the generated interface |
The order service uses code first. The documentation of its order controller says so:
* <p>The OpenAPI annotations describe each operation and its errors. springdoc builds the
* document from them, and the build fails when {@code contracts/openapi.yaml} differs.The words the build fails are the difference from a checklist line.
Code first: springdoc reads the codenew#
The library springdoc-openapi builds an OpenAPI document from a running application of Spring model-view-controller (MVC). It reads the controllers, their mappings, and the records they take and return. It also reads the annotations of Jakarta Validation on those records. The platform library adds the dependency, so every service gets it:
<!-- The generated OpenAPI document at /v3/api-docs, and Swagger UI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>The dependency springdoc-openapi-starter-webmvc-ui has no version here. The
platform has a parent Project Object Model (POM), and that
file sets the version to 3.2.0. A POM that other POMs inherit from is called a
parent POM. For parent POMs, see the chapter The build: a parent POM and a platform
library. As of October 2026, 3.2.0 is the latest release. It was released on 24 October
2026, and its own parent POM is spring-boot-starter-parent 4.1.2. The springdoc
site's compatibility matrix maps Spring Boot 4.1.x to springdoc 3.1.x and 3.2.x, and Boot
4.0.x to springdoc 3.0.x.
A run of springdoc 3.2.0 on Boot 4.1.2 showed what it serves:
| Path | What it returns |
|---|---|
| /v3/api-docs | the document as JSON, starting with "openapi":"3.1.0" |
| /v3/api-docs.yaml | the same document as YAML |
| /swagger-ui.html | a redirect to /swagger-ui/index.html |
| /swagger-ui/index.html | Swagger UI, a page to read and call the API in a browser |
The run also showed what springdoc reads from the code:
| In the code | In the document |
|---|---|
| @NotBlank on a component | the field is listed under required |
| @Size(max = 20) | maxLength 20 and minLength 0 |
| @Min(1) @Max(99) | minimum 1 and maximum 99 |
| @RequestParam(defaultValue = "0") | default 0 |
| @Schema(example = ...) and @Operation(summary = ...) | copied as they are |
| an Instant | type string, with format date-time |
| a primitive int with no constraint | not listed in required |
| the media type of a response | */* unless the mapping declares produces |
The committed contract shows the same rules on the shop's records. This is the schema of a line of a new order:
LineRequest:
type: object
properties:
productCode:
type: string
example: MUG-001
minLength: 1
quantity:
type: integer
format: int32
example: 2
maximum: 999
minimum: 1
required:
- productCodeproductCode has @NotBlank, so it is listed under
required, and it has a minimum length of 1. quantity is a primitive
int. Its two constraints give it a minimum and a maximum, but it is not listed
under required.
The platform sets four springdoc settings for every service:
# The OpenAPI document lists properties in a fixed order, so the committed file changes only
# when the API changes.
springdoc.writer-with-order-by-keys=true
# The services answer JSON, so the document says so instead of "*/*".
springdoc.default-produces-media-type=application/json
# The OpenAPI document stays on: it needs a token like every other path, and the drift check
# reads it. Swagger UI is off, and the local profile turns it on.
springdoc.api-docs.enabled=true
springdoc.swagger-ui.enabled=falsespringdoc.writer-with-order-by-keys=trueThe document lists keys in alphabetical order. Without a fixed order, two builds of the same code could write the members in another order, and the drift check would fail for nothing.
springdoc.default-produces-media-type=application/jsonWithout it, every response would say
*/*. The committed contract saysapplication/json.springdoc.api-docs.enabled=trueThe document stays on in every environment. It is not public, because the security chain asks for a token on
/v3/api-docstoo.springdoc.swagger-ui.enabled=falseSwagger UI is off, except in the local profile. springdoc logs a warning at startup when the document or the UI is enabled by default. A setting that states the choice is clearer.
The local profile turns on Swagger UI, and makes the two paths public. A developer can then
open http://localhost:8080/swagger-ui.html without a token. For the public paths,
see the section The platform's filter chain.
Code first has one limit by its nature. The contract exists only after the code. A team that must agree an API before anyone builds it needs a way around that. See the section Choosing code first or contract first.
The OpenAPI annotations the service usestwist#
The code cannot say everything that a client needs. It cannot say why an operation exists,
or which business codes a 422 can carry. The service adds that with annotations that springdoc reads.
They come from the Swagger project, in the package
io.swagger.v3.oas.annotations. The springdoc dependency brings them:
[HttpPost("{orderId}/ship")]
[EndpointSummary("Ship an order")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public IActionResult Ship(string orderId) => NoContent();@OrdersWrite
@PostMapping("/{orderId}/ship")
@Operation(operationId = "shipOrder", summary = "Ship an order",
description = "The order must be paid, every line picked, and no note may need action.")
@ApiResponse(responseCode = "204", description = "Shipped")
@ApiResponse(responseCode = "400", ref = PlatformOpenApi.INVALID_REQUEST)
@ApiResponse(responseCode = "404", ref = PlatformOpenApi.NOT_FOUND)
@ApiResponse(responseCode = "409", ref = PlatformOpenApi.CONCURRENT_UPDATE)
@ApiResponse(responseCode = "422",
description = "Business rule violated: ORDER_FINISHED, ORDER_NOT_PAID, UNPICKED_LINES, ORDER_HAS_OPEN_ACTIONS",
content = @Content(mediaType = PlatformOpenApi.PROBLEM_JSON, schema = @Schema(ref = PlatformOpenApi.PROBLEM_SCHEMA)))@Operation(operationId = "shipOrder", summary = "Ship an order",operationIdnames the operation. A client generator uses it as the method name, so the service sets it, and never changes it.summaryis the one-line text, anddescriptionthe longer one.@ApiResponse(responseCode = "204", description = "Shipped")One annotation for each response. The annotation can be repeated on one method.
@ApiResponse(responseCode = "404", ref = PlatformOpenApi.NOT_FOUND)refpoints to a shared response, defined once for every service.PlatformOpenApi.NOT_FOUNDis the string#/components/responses/NotFound.description = "Business rule violated: ORDER_FINISHED, ORDER_NOT_PAID, UNPICKED_LINES, ORDER_HAS_OPEN_ACTIONS",The 422 lists the business codes that this operation can return. A client may rely on them.
content = @Content(mediaType = PlatformOpenApi.PROBLEM_JSON, schema = @Schema(ref = PlatformOpenApi.PROBLEM_SCHEMA)))The 422 body is a problem, of the media type
application/problem+json.
The C# attributes compile on .NET 10. This book did not compare the documents that they produce. The annotations the service uses are few:
| Annotation | Where | What it adds |
|---|---|---|
| @Tag(name = "orders") | on the controller class | groups the operations, by feature |
| @Operation(operationId, summary, description) | on each method | the operation's name and its texts |
| @ApiResponse(responseCode, description or ref, content) | on each method, once for each status | the responses, including the errors |
| @Schema(description, example, allowableValues) | on a record component | the texts, an example, and the allowed values of a String |
| @Parameter(schema = @Schema(allowableValues = ...)) | on a method parameter | the allowed values of a path or query parameter |
The service's status field is a String, so springdoc would not know its values.
@Schema(allowableValues = {"NEW", "PAID", "SHIPPED", "CANCELLED"}) on the response
record lists them as an enum in the document.
The parts that are the same for every operation are not written on each method. The service describes itself once, in one bean:
@Configuration(proxyBeanMethods = false)
class OpenApiConfig {
@Bean
OpenAPI orderServiceOpenApi() {
return new OpenAPI()
.info(new Info()
.title("order-service")
.version("1")
.description("""
The shop's orders, the notes customer-service agents keep on them, and the \
lookup values they use.
Every request needs a bearer token. Reads need the orders.read scope, and \
changes need orders.write.
Every error is an RFC 9457 problem (application/problem+json) with a traceId. \
A broken business rule (422) carries a stable code that clients may rely on."""))
.addServersItem(new Server().url("http://localhost:8080").description("Local (docker compose)"))
.addTagsItem(new Tag().name("orders").description("Orders, their lines and their shipping"))
.addTagsItem(new Tag().name("notes").description("Customer-service notes on an order"))
.addTagsItem(new Tag().name("reference-data").description("Lookup values"));
}
}@Configuration(proxyBeanMethods = false)A configuration class whose
@Beanmethods do not call each other. For whatproxyBeanMethodschanges, see the chapter Wiring the service: the container in depth.OpenAPI orderServiceOpenApi() {springdoc starts from this
OpenAPIobject, and adds the paths it finds in the controllers..description("""A text block. A backslash at the end of a line joins it to the next line, so the text has no line break there.
.addServersItem(new Server().url("http://localhost:8080").description("Local (docker compose)"))A fixed server URL. See the section Exporting the contract, and the drift check.
.addTagsItem(new Tag().name("orders").description("Orders, their lines and their shipping"))The tags are declared here, once, with their texts. The controllers only name them. springdoc then keeps this order of the tags.
The parts that every shop service shares come from the platform. An
OpenApiCustomizer bean changes the document after springdoc built it:
@Bean
OpenApiCustomizer platformOpenApiCustomizer() {
return openApi -> {
Components components = openApi.getComponents() == null ? new Components() : openApi.getComponents();
openApi.setComponents(components);
components.addSecuritySchemes("bearerAuth", new SecurityScheme()
.type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT"));
openApi.addSecurityItem(new SecurityRequirement().addList("bearerAuth"));
components.addSchemas("Problem", problemSchema());
PlatformOpenApi.SHARED_RESPONSES.forEach((name, description) ->
components.addResponses(name, problemResponse(description)));
if (openApi.getPaths() != null) {
openApi.getPaths().values().forEach(path -> path.readOperations().forEach(operation -> {
operation.getResponses().computeIfAbsent("401", code -> new ApiResponse().$ref(PlatformOpenApi.UNAUTHORIZED));
operation.getResponses().computeIfAbsent("403", code -> new ApiResponse().$ref(PlatformOpenApi.FORBIDDEN));
}));
}
};
}components.addSecuritySchemes("bearerAuth", new SecurityScheme()The document says once that the API takes a bearer token, a JSON Web Token (JWT).
openApi.addSecurityItem(new SecurityRequirement().addList("bearerAuth"));Every operation needs that token, so the requirement is set for the whole document.
components.addSchemas("Problem", problemSchema());The schema of every error body, with
code,traceIdanderrors.components.addResponses(name, problemResponse(description)));The shared responses, such as
NotFoundandConcurrentUpdate. A controller names them withref.operation.getResponses().computeIfAbsent("401", code -> new ApiResponse().$ref(PlatformOpenApi.UNAUTHORIZED));Every operation gets a 401 and a 403, because every endpoint needs a token and a scope. A controller never has to remember them.
The result is in the committed file. This is the start of it:
openapi: 3.1.0
info:
# ...
title: order-service
version: "1"
servers:
- description: Local (docker compose)
url: http://localhost:8080
security:
- bearerAuth: []The servers entry comes from OpenApiConfig. The
security entry comes from the platform's customizer.
The Problem schema documents the members of the RFC 9457 problems, plus the shop's own members:
Problem:
type: object
description: An RFC 9457 problem. Every error response has this shape.
properties:
code:
type: string
description: "A stable code (404, 409, 422), such as ORDER_NOT_PAID"
detail:
type: string
errors:
type: array
description: "The invalid fields, for a 400"
items:
type: object
properties:
field:
type: string
message:
type: stringThe members code and errors are the shop's own. Each error response
uses the media type application/problem+json and this schema. For the problems themselves, see the section One handler for
every problem.
A generated file looks different from a hand-written one in three places:
- Paged lists. A hand-written file often builds a page with
allOf. It combines a shared schema of the page numbers with a list of items. springdoc writes one complete schema for each item type instead.PageResponseOrderResponseis one of them. Each such schema repeats the five members ofPageResponse. The document is longer, and a client sees the same shape. - Shared parameters. A hand-written file often declares the paging parameters
once, under
components.parameters. Each operation then refers to them. springdoc writes them again in each operation instead, with the defaults of each@RequestParam. A change to the default then shows in every operation of the diff. - Fields that may be null.
shippedAthas no value until the order ships. The document writes it astype: string, and leaves it out ofrequired. In a run for this book, a plain Jackson 3 mapper wrote the missing value as"shippedAt": null. OpenAPI 3.1 allows a list of types for one field. The ASP.NET Core run in the section OpenAPI in ASP.NET Core 10 wrote such a list,["integer", "string"]. In another run, a JSON Schema validator acceptednullfor the type[string, "null"]. springdoc does not write that form for the shop's records. So a client must treat a field that is not required as one that can be missing or null.
A 201 that springdoc cannot seetrap#
springdoc reads the method's signature. It does not run the method. A C# developer who knows
TypedResults.Created expects the status to appear in the document by itself.
@OrdersWrite
@PostMapping
// ...
ResponseEntity<OrderPlacedResponse> place(@Valid @RequestBody PlaceOrderRequest request) {
PlacedOrder placed = orders.place(
new CustomerContact(request.customer().name(), request.customer().email()),
new DeliveryMethod(request.deliveryMethod()),
mapper.toItems(request),
mapper.toNewNotes(request));
return ResponseEntity.created(URI.create("/orders/" + placed.orderId())).body(mapper.toResponse(placed));
}Show the answer
springdoc sees the return type, not the status. The type is
ResponseEntity<OrderPlacedResponse>. The status is chosen inside the method,
at run time. springdoc cannot read that, so it writes its default, 200. A run for this
book showed "200": OK for such a method.
Declare the status with @ApiResponse(responseCode = "201", description = ...).
The order service does that on both methods that create something.
The real method carries the annotation, and the committed contract has the 201:
@ApiResponse(responseCode = "201", description = "Placed. The Location header names the new order.")The description names the Location header, which the document does not show
otherwise. In ASP.NET Core 10, TypedResults.Created(...) in a minimal API is
documented as 201 with no annotation. A controller action that returns
IActionResult is like the Spring method. A run of such an action with
Created(...) listed only 200 in the document.
The drift check cannot catch this mistake. It compares the file with what springdoc
generates, and springdoc generates the wrong status every time. So a test of the controller
must check the real status, as OrderControllerTest.placingReturns201WithTheLocation
does. A review of the diff of the contract is the second check. A new POST that
shows "200" is a sign that the annotation is missing.
Exporting the contract, and the drift checknew#
A developer adds a field to OrderResponse, and forgets the contract. The build
must fail, and say how to update the file. The order service does that with one test:
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("wiring")
class OpenApiContractTest {
private static final Path COMMITTED = Path.of("contracts/openapi.yaml");
private static final Path GENERATED = Path.of("target/openapi.yaml");
@Autowired
MockMvc mvc;
@Test
void theCommittedContractMatchesTheCode() throws Exception {
String generated = mvc.perform(get("/v3/api-docs.yaml").with(jwt()))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
write(GENERATED, generated);
if (Boolean.getBoolean("openapi.update")) {
write(COMMITTED, generated);
}
assertThat(COMMITTED).as("contracts/openapi.yaml is missing; run mvn verify -Dopenapi.update=true").exists();
assertThat(Files.readString(COMMITTED))
.as("contracts/openapi.yaml differs from the code. Review target/openapi.yaml, "
+ "then run mvn verify -Dopenapi.update=true and commit the file.")
.isEqualTo(generated);
}@SpringBootTestThe test starts the whole application, so springdoc sees every controller.
@AutoConfigureMockMvcThe test calls the application through MockMvc, with no server and no port.
@ActiveProfiles("wiring")The wiring profile gives every setting a value, and connects to nothing. So the test needs no database, no queue and no token issuer. For that profile, see the chapter Testing in depth.
mvc.perform(get("/v3/api-docs.yaml").with(jwt()))The document needs a token, as every path does.
jwt()gives the request a caller without a real token.write(GENERATED, generated);Every build writes the generated file to
target/openapi.yaml. A developer can compare it with the committed file.if (Boolean.getBoolean("openapi.update")) {Boolean.getBooleanreads a system property. With-Dopenapi.update=true, the test overwrites the committed file..isEqualTo(generated);The committed file must match the generated one exactly. The message says how to fix a failure.
So the workflow for an API change has three steps:
- Change the controller or a request or response record.
mvn verifyfails inOpenApiContractTest. - Read
target/openapi.yaml, and check that the change is the one you meant. - Run
mvn verify -Dopenapi.update=true, and commitcontracts/openapi.yamlwith the change.
The pull request then shows every change to the API as a diff of the contract. A reviewer sees a removed field or a renamed operation in that diff, even when the Java change looks harmless. The UI team can watch the file, and nobody has to remember a checklist line.
springdoc writes the server URL from the request, so a generated file changes with
the port. A run exported the file twice, once from a server on port 18089 and once
through MockMvc. The two files differed in exactly one line,
url: http://localhost:18089 against url: http://localhost. A drift check
would fail whenever the file is generated in another way. The service fixes the URL in
OpenApiConfig, with addServersItem. Then springdoc writes no server of
its own.
springdoc also has a plugin for Maven,
springdoc-openapi-maven-plugin, version 1.5 as of October 2026. Its README says that the application must be running. The plugin works in the
integration-test phase, together with the start and stop goals of the Spring Boot Maven
plugin. A run on Boot 4.1.2 started the application on a port, wrote the file, and stopped the
application. So it works. The order service uses the test instead, for three reasons:
- The test needs no port and no running service, so it runs in every plain
mvn verify. - The plugin needs the real settings of the service, or a profile like the wiring profile, to start. The test already has that profile.
- The test compares as well as exports. With the plugin, a second step must still compare the files.
The pages of springdoc that this book checked describe no way to export the document without starting the application. Both the plugin and the test start it.
In CI, the plain build runs the test. A pull request that changes the API without the
contract fails there. The contract test is listed in the README of the service as the
contract kind of test, next to the unit and slice tests.
Contract first: openapi-generatornew#
Contract first reverses the direction. The team writes the OpenAPI file first, and agrees it with the UI team or a partner. A generator then writes Java code from the file, at every build. The controller implements the generated code. If the controller no longer matches the file, the compiler fails.
The common tool for Spring is openapi-generator-maven-plugin, from the
OpenAPI Generator project. Its latest version is 7.26.0, as of October 2026. The same project
has a generator for ASP.NET Core, named aspnetcore. It writes controller classes
from the file. Its input is a file
like the shop's committed contract. This is one operation of it:
/orders/{orderId}/cancel:
post:
operationId: cancelOrder
parameters:
- in: path
name: orderId
required: true
schema:
type: string
responses:
"204":
description: CancelledFrom operations like this, the generator for Spring writes interfaces, with one method per
operation. With the option useTags, it writes one interface for each tag. The operationId becomes the name of the method.
A run of version 7.26.0 against Boot 4.1.2 generated this:
- An interface, such as
OrderControllerApi. Each method carries the mapping annotations and the parameter annotations. It also carries@Valid, and the constraints from the file, such as@Min. - The springdoc annotations on each method, such as
@Operationand@ApiResponse. - Each method returns a
ResponseEntity, because the optionuseResponseEntityis on by default. - One model class for each schema, such as
PlaceOrderRequestandOrderResponse.
The controller then implements the interface, and writes only the bodies of the methods. The mappings come from the interface, so the controller has none of its own.
The platform's parent POM has the generator ready, in a Maven profile that a service switches on:
<!--
Contract first: mvn verify -Dcontract-first generates Java interfaces and models from the
service's committed contracts/openapi.yaml, before the code compiles. A service that
writes its contract first implements the generated interfaces in its controllers, so the
compiler finds a controller that no longer matches the contract. The order service
writes its code first, so for it the profile only proves that its contract generates
code that compiles.
Both conditions must hold, so a module without the file, such as the platform, skips it.
-->
<profile>
<id>contract-first</id>
<activation>
<property>
<name>contract-first</name>
</property>
<file>
<exists>${basedir}/contracts/openapi.yaml</exists>
</file>
</activation>
<build>
<plugins>
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>${openapi-generator.version}</version>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>${project.basedir}/contracts/openapi.yaml</inputSpec>
<generatorName>spring</generatorName>
<apiPackage>com.acme.contract.api</apiPackage>
<modelPackage>com.acme.contract.model</modelPackage>
<configOptions>
<interfaceOnly>true</interfaceOnly>
<useSpringBoot4>true</useSpringBoot4>
<useJackson3>true</useJackson3>
<openApiNullable>false</openApiNullable>
<skipDefaultInterface>true</skipDefaultInterface>
<useTags>true</useTags>
</configOptions>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile><activation>The profile switches itself on when both conditions hold. Maven requires every condition of an activation. The property condition holds when the command line has
-Dcontract-first. The file condition holds only in a module that has the contract. So the platform's own modules skip the generator, even in a build of the whole companion.<inputSpec>${project.basedir}/contracts/openapi.yaml</inputSpec>The input is the committed contract of the service, the same file that the drift check compares.
<generatorName>spring</generatorName>The generator for Spring MVC. It writes interfaces with the mapping annotations, and models.
<apiPackage>com.acme.contract.api</apiPackage>The generated code goes into its own packages, outside the service's base package. The architecture tests import
com.acme.orderserviceonly, so they never check generated code.<configOptions>The options of the run described above. The table below explains each one.
A run of mvn verify -Dcontract-first on the order service, in October 2026,
generated three interfaces from the contract. OrdersApi had six methods,
NotesApi three and ReferenceDataApi one. It also generated 16 model
classes, such as PlaceOrderRequest and Problem, into
target/generated-sources/openapi. All of it compiled with the service, and the tests
passed. The generator also printed a warning, "OpenAPI 3.1 support is still in beta". The
committed contract is an OpenAPI 3.1 file, because springdoc writes that version. So a team that
starts contract first should review the generated code of each new generator version. The operation
above became the method ResponseEntity<Void> cancelOrder(@PathVariable("orderId")
String orderId), with an @Operation annotation that lists the five problem
answers of the contract.
The order service writes its code first, so no controller implements these interfaces. For this service, the profile proves only that the committed contract generates code that compiles. A service that writes its contract first switches the profile on in every build, and its controllers implement the interfaces.
The plugin has many options. These are the ones that matter for a Boot 4 service, with their defaults in 7.26.0. The last column says what the help of the generator or the run showed:
| Option | Default | In the run | What the help or the run showed |
|---|---|---|---|
| interfaceOnly | false | true | the run generated only the interfaces and the models |
| useSpringBoot3 | true | not set | the help says that it also turns on useJakartaEe |
| useSpringBoot4 | false | true | the help says "Generate code and provide dependencies for use with Spring Boot 4.x" |
| useJackson3 | false | true | the help says that it is allowed only when useSpringBoot4 is set |
| useJakartaEe | false | not set | turned on by useSpringBoot3, as the help of that option says |
| useJspecify | false | not set | without it, the models import the deprecated org.springframework.lang.Nullable |
| openApiNullable | true | false | its effect was not checked for this book |
| skipDefaultInterface | false | true | with it, a controller that misses a method does not compile |
| useBeanValidation | true | not set | the generated interface carried @Valid and @Min from the file |
| useResponseEntity | true | not set | every generated method returned a ResponseEntity |
The run also set useTags, so the interface is named after the tag. With these
options, the controller compiled against Boot 4.1.2.
Then the run changed the contract. The path parameter orderId got
format: uuid. The next compile failed:
rw.cf.OrderController is not abstract and does not override abstract method get(java.util.UUID,...) in rw.cf.api.OrderControllerApi#UUID
parameter. Or change the contract back, if the change was not meant.So the compiler catches a changed signature. It does not catch every change. An added field in a response model, or a different status code, leaves the Java signature as it was. A controller that returns 200 where the file says 201 still compiles. This conclusion comes from reading the generated code, and was not run. So contract first still needs controller tests that check the statuses, as code first does.
Version 7.26.0 has no option useRecords. The generator's web
page lists useRecords, "Generate models as Java records where possible". That page
documents a newer build. The help of 7.26.0 does not contain the word "record". A run with
<useRecords>true</useRecords> still generated classes, such as
public class PlaceOrderRequest {, with getters, setters and fluent setters.
Contract first has real costs, besides the options to tune:
- The generated code shapes the controllers. Every method returns a
ResponseEntity. A query parameter with a default becomes a boxedInteger. Aformat: uuidbecomes aUUIDparameter, so the conversion runs before the permission check. The order service avoids that with a text parameter, which it turns into a value object inside the method. See the section IDs in the path: text in, value objects inside. - The DTOs are generated classes. They are mutable classes with setters, not records, as of version 7.26.0. A team that wants records must write its own and map, or wait for a newer generator.
- The mappings are out of sight. A reader of the controller does not see its
paths. They are in generated code under
target. - A generator upgrade can change the generated code. A new version of the plugin is then a change to every controller's supertype, and needs a careful review.
Choosing code first or contract firstnew#
Both ways stop the drift. They differ in where the truth is, and when the contract exists. The right one depends on who must agree the API, and when:
| Question | Code first, as in the order service | Contract first |
|---|---|---|
| Where is the truth? | the controllers and DTOs | the OpenAPI file |
| When does the contract exist? | after the controller and DTOs are written | before any code |
| What catches a difference? | the drift test, at every build | the compiler, for a changed signature only |
| What does an API change look like? | a Java change, plus a diff of the generated file | a diff of the file, plus a Java change |
| Who can write the change? | any developer of the service | anyone who can edit YAML, including other teams |
| What does the code look like? | the team's own records and controllers | generated interfaces and model classes |
| What needs tuning? | a few springdoc settings and annotations | the generator's options, and its upgrades |
The order service records its choice in the decisions of its README:
**The contract is generated, committed and checked.** springdoc builds `contracts/openapi.yaml`
from the controllers. `OpenApiContractTest` writes it to `target/openapi.yaml` in every build,
and fails when the committed file differs; `mvn verify -Dopenapi.update=true` updates it. Every
API change is then a visible diff in the pull request. To agree an API early, write the
controller and DTOs with stub methods first.The words stub methods first answer the main objection to code first. The paragraphs
below explain it.
The recommendation for an internal service is code first. Generate the file with springdoc, commit it, and let CI fail when the regenerated file differs. Every API change is then a visible diff in the pull request. The developers keep writing plain records and controllers, which this part teaches.
Code first seems to prevent an early agreement on the API. It does not. To agree an API before you build it, write the controller and the DTOs first, with methods that do nothing yet. Generate the file, and open a pull request with it. The UI team reviews the diff of the contract, as it would review a hand-written file. The use cases follow in later pull requests.
Choose contract first in two cases. The first is a UI or partner team that must sign off the API before any code exists. That happens when the teams work in parallel from the first day, or when the API is part of an agreement. The second is an API shared outside the team or the company. There, the contract is the product, and its owners may not be developers of the service. In both cases the file comes first, and code that follows it is the safer direction.
The strict rule. Design the API first. Write the contract, agree it with every consumer, and only then build the service.
What we do. The order service writes its controllers and DTOs first.
springdoc generates contracts/openapi.yaml from them, and
OpenApiContractTest fails the build when the committed file differs.
Why. The consumers of the API are teams of the same company, and they can review a pull request. The generated file can never drift from the code. The team writes plain records and controllers, not generated ones. An early agreement is still possible, with a controller whose methods do nothing yet.
When to stop. Stop when a consumer must sign off the API before any code exists, or when the API is published outside the company. Then switch to contract first, with the generator and its options set as in the section Contract first: openapi-generator.
OpenAPI in ASP.NET Core 10twist#
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapOpenApi();<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>ASP.NET Core 10 has OpenAPI built in, in the package Microsoft.AspNetCore.OpenApi.
AddOpenApi() registers it, and MapOpenApi() serves the document. In
Spring, a library adds it, and the dependency alone switches it on. The two differ in the
details:
| Question | ASP.NET Core 10 | springdoc 3.2.0 |
|---|---|---|
| Where is the document? | /openapi/v1.json, for the document named v1 | /v3/api-docs, and /v3/api-docs.yaml |
| Which OpenAPI version? | 3.1 by default, and the run wrote 3.1.1 | 3.1.0 |
| YAML | only when the route is mapped with a .yaml suffix | always, at its own path |
| A UI | none, so Swagger UI or Scalar is added separately | Swagger UI in the same dependency |
| Export at build time | the property OpenApiGenerateDocumentsOnBuild | the Maven plugin, or a test such as OpenApiContractTest |
| How the export works | it starts the app's entry point with a mock server | the plugin starts the app on a port, and the test uses MockMvc |
| A 201 from a result helper | TypedResults.Created is documented as 201 | ResponseEntity.created needs @ApiResponse |
Both platforms start the application to export the file. ASP.NET Core does it with a mock
server, and its run printed that the entry point ran. A C# record needs one more detail. A data
annotation on a positional record parameter must target the property, as in
[property: MaxLength(20)]. Without property:, the run's schema had no
maxLength. A Java record component needs no such target.
The build of the ASP.NET Core 10 run warned NU1903: Package 'Microsoft.OpenApi' 2.0.0
has a known high severity vulnerability. That version comes in with
Microsoft.AspNetCore.OpenApi 10.0.8, as of October 2026.
Event contracts: a JSON schema for each eventnew#
When an order is placed, the order service publishes OrderPlacedV1. The stock
service reads it to keep its hold on the products. The stock team writes its code against a
description of the event, so the event needs a contract too. The service publishes it through a
transactional outbox. For how, see the
chapter Publishing events: the outbox.
The event is a record in the service:
@Externalized(OrderTopics.ORDER_EVENTS)
public record OrderPlacedV1(String eventId, Instant occurredAt, String orderId, String deliveryMethod,
List<Line> lines) implements IntegrationEvent {
public record Line(String productCode, int quantity) {
}
}It holds IDs and facts in plain types, such as String, Instant and
int. It holds no domain class, so the domain can change without changing the event.
It also holds nothing about the customer, because the stock service does not need it. The
contract for other teams is a JSON Schema file beside the code:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "order-service/OrderPlacedV1",
"title": "OrderPlacedV1",
"description": "An order was placed. Published to the order-events SNS topic. Within V1, fields may be added but never removed or renamed, so consumers must accept fields they do not know.",
"type": "object",
"required": ["eventId", "occurredAt", "orderId", "deliveryMethod", "lines"],
"properties": {
"eventId": { "type": "string", "format": "uuid", "description": "Unique per event, and the same on a resend: use it to detect duplicates." },
"occurredAt": { "type": "string", "format": "date-time" },
"orderId": { "type": "string", "format": "uuid" },
"deliveryMethod": { "type": "string", "description": "A DELIVERY_METHOD code, for example STANDARD." },
"lines": {
"type": "array",
"minItems": 1,
"maxItems": 50,
"items": {
"type": "object",
"required": ["productCode", "quantity"],
"properties": {
"productCode": { "type": "string", "pattern": "^[A-Z0-9-]{1,30}$" },
"quantity": { "type": "integer", "minimum": 1, "maximum": 999 }
},
"additionalProperties": true
}
}
},
"additionalProperties": true
}"$schema": "https://json-schema.org/draft/2020-12/schema",The schema follows JSON Schema, draft 2020-12. Validators in many languages read it, so the stock team needs no Java.
"$id": "order-service/OrderPlacedV1",The ID names the owner, the order service, and the event with its version.
"required": ["eventId", "occurredAt", "orderId", "deliveryMethod", "lines"],Every event carries these five fields. A consumer may rely on them.
"eventId": { "type": "string", "format": "uuid", "description": "Unique per event, and the same on a resend: use it to detect duplicates." },format: uuiddescribes the text. See the next paragraph for what a validator does with it."additionalProperties": trueAn event may carry fields that the schema does not list. So a consumer that validates an event does not reject a field that V1 adds later.
A run for this book validated events against this schema with a JSON Schema validator. These were the results:
| The event | The result |
|---|---|
| every required field, with the right types | valid |
| an extra field, giftWrap | valid, because additionalProperties is true |
| an extra field, with additionalProperties set to false | refused, giftWrap was unexpected |
| deliveryMethod removed | refused, deliveryMethod is a required property |
| deliveryMethod set to null | refused, because null is not a string |
| orderId set to not-a-uuid | valid, unless the validator checks formats |
The last row matters. A validator does not always check format. The run's
validator refused the bad universally unique identifier (UUID) only with its format checker switched on. So a consumer that relies on a UUID should switch the format
check on, or check the value itself.
The service's README lists every event with its direction and its schema:
## Events
| Direction | Event | Where | Schema |
|---|---|---|---|
| out | `OrderPlacedV1` | SNS topic `order-events` | `contracts/events/OrderPlacedV1.json` |
| out | `OrderShippedV1` | SNS topic `order-events` | `contracts/events/OrderShippedV1.json` |
| out | `OrderCancelledV1` | SNS topic `order-events` | `contracts/events/OrderCancelledV1.json` |
| in | `PaymentReceivedV1` | SQS queue `order-service-payment-received` | `contracts/events/consumed-PaymentReceivedV1.json` |A developer who adds an event adds a row here, and a schema file. The table answers three questions. Which events does the service publish? Which does it consume? Where is the contract of each one?
The schema and the record describe the same event twice, and nothing compares them by itself. This is
the drift problem again, for events. Suppose a document of the team says that
OrderShippedV1 holds shippedAt, the record holds
occurredAt, and the schema says something else again. A consumer then cannot know
which one is right. The cure is the same as for the API. Make one of them the source, and let
the build compare.
The order service makes the record the source, and the schema the contract that a test checks. The test turns each domain event into its integration event with the real mapper. It writes the event with the application's JSON mapper, which the sender for Amazon Simple Notification Service (SNS) uses too. Then it validates the JSON against the committed schema:
@JsonTest
class PublishedEventSchemaTest {
/** Draft 2020-12 treats "format" as a note only. The setting makes "uuid" and "date-time" checks. */
private static final SchemaRegistry SCHEMAS = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12,
registry -> registry.schemaRegistryConfig(SchemaRegistryConfig.builder().formatAssertionsEnabled(true).build()));
@Autowired
JsonMapper json;
static Stream<DomainEvent> publishedDomainEvents() {
OrderId id = OrderId.newId();
return Stream.of(new OrderPlaced(id, STANDARD, TWO_MUGS, NOW), new OrderShipped(id, NOW),
new OrderCancelled(id, NOW));
}
@ParameterizedTest
@MethodSource("publishedDomainEvents")
void eachPublishedEventMatchesItsSchema(DomainEvent domainEvent) throws IOException {
IntegrationEvent event = new OrderIntegrationEventMapper().toIntegrationEvent(domainEvent).orElseThrow();
List<Error> errors = schemaOf(event).validate(json.writeValueAsString(event), InputFormat.JSON);
assertThat(errors).isEmpty();
}
// ...
/**
* The schemas have a relative {@code $id}, such as {@code order-service/OrderPlacedV1}. The
* validator refuses a relative ID without a base, so the file's own location is the base.
*/
private static Schema schemaOf(IntegrationEvent event) throws IOException {
Path file = Path.of("contracts/events", event.getClass().getSimpleName() + ".json");
return SCHEMAS.getSchema(SchemaLocation.of(file.toUri().toString()), Files.readString(file), InputFormat.JSON);
}
}@JsonTestA slice test that starts only Jackson's part of Spring Boot. The injected
JsonMapperis the one that Boot configures for the application, with the platform's defaults. So the test writes the JSON that SNS would receive.SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12,The validator is
com.networknt:json-schema-validator, version 3.0.8 as of October 2026. Version 3 reads its input with Jackson 3, like Boot 4. The parent POM gives it to every service with the test scope. The registry reads schemas of draft 2020-12, the draft that the files name in$schema.formatAssertionsEnabled(true)In draft 2020-12,
formatis a note by default, not a check. This option turns it into a check, so anorderIdthat is not a UUID fails. The testanOrderIdThatIsNotAUuidBreaksItsSchemashows it.@MethodSource("publishedDomainEvents")The test runs once for each domain event that leaves the service. These are the events of a placed, a shipped and a cancelled order.
schemaOf(event).validate(json.writeValueAsString(event), InputFormat.JSON);validatereturns the list of errors. An empty list means that the JSON matches the schema.SchemaLocation.of(file.toUri().toString())The schemas have a relative
$id, such asorder-service/OrderPlacedV1. The first run of the test failed withInvalidSchemaException: 'order-service/OrderPlacedV1' is not a valid $id. A relative ID needs a base, so the test gives the file's own location as the base.
Another test, anEventWithoutARequiredFieldBreaksItsSchema, removes
orderId from the JSON of a shipped order, and expects an error that names the
field. It proves that the check can fail. A test that cannot fail records nothing.
Together, the two tests catch a renamed or removed field in the record, in the mapper or in
the schema, in a plain mvn verify. The consumed event works the other way. Its
test, PaymentReceivedV1Test, reads JSON that the payment service would send. Its
schema file records the part of that contract that the order service relies on.
Versions in names: what may change within V1twist#
public record OrderShippedV1(string EventId, DateTimeOffset OccurredAt, string OrderId);/**
* A versioned message that crosses the service boundary, such as {@code OrderShippedV1}. It is
* a contract with other teams, so it changes carefully:
*
* <ul>
* <li>The version is in the name. Within a version, add fields freely, but never remove or
* rename one. For a breaking change, publish {@code V2} next to {@code V1} until every
* consumer has moved.</li>
* <li>It holds IDs and facts in plain types, and no more personal data than a consumer needs.</li>
* <li>The event ID is created once, with the event. A resend keeps it, so a consumer's inbox
* can detect the duplicate.</li>
* </ul>
*/
public interface IntegrationEvent {
String eventId();
Instant occurredAt();The C# record has the version in its name too. The Java side states the rules in the
interface that every published event implements. The interface requires
eventId() and occurredAt(). A consumer reads the version from the name of
the event, so a consumer of V1 knows which fields it can rely on. The rules for a change are
these:
| The change | Allowed within V1 | Why |
|---|---|---|
| add a field | yes | a tolerant reader ignores it, and the schema allows extra fields |
| remove a field | no | a consumer that reads it would get nothing |
| rename a field | no | a rename is a removal and an addition, for the consumer |
| change the type of a field | no | a consumer's parser would fail |
| make an optional field required | no | an event from an older producer, or one already in a queue, may lack it |
| change what a field means | no | the consumer would compute the wrong thing, and nothing fails |
A change that is not allowed needs a new event, OrderShippedV2. The service
publishes V2 next to V1, and every consumer moves to V2 in its own time. When no consumer reads
V1 any more, the service stops publishing it. That needs a list of who consumes what, which is
one more reason for the events table in the README.
The eventIdtwist#
var eventId = Guid.NewGuid().ToString();/** Returns a new, random event ID. Call it once, when the event is created. */
static String newEventId() {
return UUID.randomUUID().toString();
}The two lines do the same. What matters is when the ID is made. The mapper calls
newEventId() once, when it turns a domain event into an integration event. The
outbox stores the event with its ID. If the service must send the event again, it sends the
stored event, with the same ID. For the resend, see the chapter Publishing
events: the outbox.
Every consumer can then detect a duplicate by its eventId. The schema says so in
the description of the field, so a consumer in another language knows it too. The order
service does the same for the events it consumes. For the inbox, see the chapter
Consuming events: SQS and the inbox.
An ID made again at each send would defeat this. Each resend would look like a new event, and a consumer would process it twice.
Tolerant readerstwist#
public record PaymentReceivedV1(
[property: JsonRequired] string EventId,
[property: JsonRequired] string OrderId);@JsonIgnoreProperties(ignoreUnknown = true)
record PaymentReceivedV1(String eventId, Instant occurredAt, String orderId, String paymentReference,
BigDecimal amount, String currency, Instant paidAt) implements IntegrationEvent {
PaymentReceivedV1 {
Objects.requireNonNull(eventId, "eventId");
Objects.requireNonNull(occurredAt, "occurredAt");
Objects.requireNonNull(orderId, "orderId");
Objects.requireNonNull(paymentReference, "paymentReference");
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(currency, "currency");
Objects.requireNonNull(paidAt, "paidAt");
}
}@JsonIgnoreProperties(ignoreUnknown = true)Jackson ignores a field that the record does not have. The annotation comes from the package
com.fasterxml.jackson.annotation, which Jackson 3 still uses.record PaymentReceivedV1(String eventId, Instant occurredAt, String orderId, String paymentReference,The record is package-private. Only the payment listener of the order feature reads it.
Objects.requireNonNull(eventId, "eventId");The compact constructor refuses a missing field.
requireNonNullthrows aNullPointerExceptionwith the field's name as the message.
A consumer that ignores what it does not know is called a tolerant reader. The payment
service may add a field to PaymentReceivedV1 at any time, within V1. The order
service must keep working when that happens.
Jackson 3 already ignores unknown fields by default, and so does the mapper that Boot builds.
The annotation still has a purpose. It makes the rule part of the record, so it holds whatever
the settings of the mapper are. A plain Jackson 2 mapper refused unknown fields by default. In
.NET, JsonSerializer ignores an unknown property by default, and
[JsonRequired] makes a missing one fail. A run on .NET 10 refused a payment without
its orderId in this way.
The other half of a tolerant reader is strict. A field that the consumer needs must be present. The record's documentation explains why the check matters here:
* <p>The compact constructor refuses missing fields. A wrong subscription, without SNS raw
* message delivery, delivers the SNS envelope instead of the event; Jackson would then build a
* record whose fields are all {@code null}, and the message would be acknowledged and lost. With
* the check, the message fails, is retried, and ends in the dead-letter queue, where someone
* sees it.The SNS envelope is the wrapper that Amazon SNS puts around a message, with members such as
Type and MessageId. None of its members matches the record. Because
the reader ignores unknown fields, Jackson would read the envelope without an error. Every
field would then be null. The compact constructor turns that silent loss into a
failure. For raw message delivery and the
dead-letter queue, see the chapter
Consuming events: SQS and the inbox.
The tests check both halves:
@Test
void readsTheEventAndIgnoresFieldsItDoesNotUse() {
PaymentReceivedV1 event = json.readValue("""
{ "eventId": "e-1", "occurredAt": "2026-01-15T09:00:00Z", "orderId": "o-1",
"paymentReference": "PAY-1", "amount": 12.50, "currency": "GBP",
"paidAt": "2026-01-15T09:00:00Z", "cardScheme": "VISA" }
""", PaymentReceivedV1.class);
assertThat(event.paymentReference()).isEqualTo("PAY-1");
}The first test sends a field the record does not have, cardScheme, and the event
is read. The second test, refusesAnSnsEnvelopeInsteadOfTheEvent, sends an envelope.
It expects a NullPointerException with the message eventId as the root
cause. Both use a plain JsonMapper from tools.jackson.databind.json,
so they need no Spring.
Our copy of a consumed contractnew#
The payment service owns PaymentReceivedV1. Its schema is in the payment
service's repository. The order service needs to know which part of that contract it relies on.
And when the payment team changes the event, someone must check whether the order service is
affected.
The order service keeps its own copy of the part it uses:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "payment-service/PaymentReceivedV1",
"title": "PaymentReceivedV1 (consumed)",
"description": "Owned by the payment service. This is the part of their contract that order-service relies on, kept here so a change on their side is easy to check. Received on the order-service-payment-received SQS queue, with raw message delivery.",
"type": "object",
"required": ["eventId", "occurredAt", "orderId", "paymentReference", "amount", "currency", "paidAt"],
"properties": {
"eventId": { "type": "string" },
"occurredAt": { "type": "string", "format": "date-time" },
"orderId": { "type": "string", "format": "uuid" },
"paymentReference": { "type": "string", "maxLength": 50 },
"amount": { "type": "number", "minimum": 0, "multipleOf": 0.01 },
"currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
"paidAt": { "type": "string", "format": "date-time" }
}
}"$id": "payment-service/PaymentReceivedV1",The ID names the owner, the payment service. The file name starts with
consumed-, so nobody mistakes it for a contract of the order service."required": ["eventId", "occurredAt", "orderId", "paymentReference", "amount", "currency", "paidAt"],These are the fields the order service reads. The payment service's own schema may have more.
"paymentReference": { "type": "string", "maxLength": 50 },The limits are the ones the order service relies on. The domain's
PaymentReferencechecks the same limit.
The file has no additionalProperties. A run validated an event with an extra
member, cardScheme, against this file, and the event was valid. That suits a
tolerant reader. The record in the code holds the same seven fields and
no more. So three things describe the same part of the contract. They are the consumed schema,
the record, and the record's test.
When the payment team announces a change, a developer compares their new schema with this
file. If the change touches none of the seven fields, nothing needs to change. If it removes or
renames one of them, the payment service must publish PaymentReceivedV2, and the
order service moves to it.
The alternative is to read the producer's schema directly, or to use a class from the producer. That gives one definition, and it can be the better choice inside one team. But the consumer then depends on fields it never reads. A copy of only the used part makes the dependency visible and small.
Publish as few events as needednew#
The order aggregate raises five domain events, from OrderPlaced to
OrderCancelled. Each of them is useful inside the service. But every event that
leaves the service is a contract, with a schema, a version and consumers to look after. One
class decides which events leave:
/**
* Decides which order events leave the service. Placed, shipped and cancelled orders are
* published; picked lines and recorded payments stay internal. Every published event is a
* contract to maintain, so publish only what another service needs.
*/
@Component
class OrderIntegrationEventMapper implements IntegrationEventMapper {
@Override
public Optional<IntegrationEvent> toIntegrationEvent(DomainEvent event) {
return switch (event) {
case OrderPlaced placed -> Optional.of(new OrderPlacedV1(newEventId(), placed.occurredAt(),
placed.orderId().value(), placed.deliveryMethod().code(),
placed.items().stream()
.map(item -> new OrderPlacedV1.Line(item.product().value(), item.quantity()))
.toList()));
case OrderShipped shipped -> Optional.of(new OrderShippedV1(newEventId(), shipped.occurredAt(),
shipped.orderId().value()));
case OrderCancelled cancelled -> Optional.of(new OrderCancelledV1(newEventId(), cancelled.occurredAt(),
cancelled.orderId().value()));
default -> Optional.empty();
};
}
}return switch (event) {A switch expression with type patterns. Each
casetests the type of the domain event.placed.orderId().value(), placed.deliveryMethod().code(),The mapper turns value objects into plain types. The event holds the text of the ID, not an
OrderId.default -> Optional.empty();Every other event stays inside the service.
LinePickedandPaymentRecordedare internal.
An event is published when another service needs it. The stock service needs
OrderPlacedV1 to keep its hold on the products. The shipped and cancelled events
tell other services that an order is finished.
No service needs to know that a line was picked, so LinePicked has no integration
event. If a service needs it one day, the team adds LinePickedV1, with a schema and
a row in the README. Adding an event is easy. Removing one that consumers use is hard.
The test keepsInternalEventsInternal records the decision. It passes a
LinePicked to the mapper and expects an empty result. For how the published events
reach SNS, and how each feature registers its mapper, see the chapter Publishing
events: the outbox.
1An expert asksThe order service generates its OpenAPI file with springdoc. Why does it commit
the file and check it in a test, instead of serving it only at /v3/api-docs?
/v3/api-docs?2An expert asksWhen is contract first the better choice for a Spring service?
3The order service's OpenApiConfig sets a fixed server URL. What would happen
without it?
OpenApiConfig sets a fixed server URL. What would happen
without it?http://localhost, and one exported from a running server would name its port.
The drift test would then fail whenever the file was generated in another way.4Within OrderPlacedV1, which changes may the order service make, and what does it
do for any other change?
OrderPlacedV1, which changes may the order service make, and what does it
do for any other change?OrderPlacedV2 next
to V1, until every consumer has moved.5TranslateWrite this C# event as the Java record that the order service publishes. It
must go to the SNS topic order-events:
public record OrderShippedV1(string EventId, DateTimeOffset OccurredAt, string OrderId);
order-events:
public record OrderShippedV1(string EventId, DateTimeOffset OccurredAt, string OrderId);@Externalized(OrderTopics.ORDER_EVENTS)
public record OrderShippedV1(String eventId, Instant occurredAt, String orderId) implements IntegrationEvent {
}Instant takes the place of DateTimeOffset. IntegrationEvent
requires eventId() and occurredAt(), which the record's components
provide. @Externalized tells Spring Modulith to send the event to the topic after the
transaction commits.
Quick reference#
The springdoc settings of the platform and the local profile:
| Setting | Value | What it does |
|---|---|---|
| springdoc.writer-with-order-by-keys | true | writes keys in a fixed order, so the file changes only when the API changes |
| springdoc.default-produces-media-type | application/json | writes application/json instead of */* for each response |
| springdoc.api-docs.enabled | true | serves /v3/api-docs, which needs a token |
| springdoc.swagger-ui.enabled | false, and true in the local profile | serves Swagger UI at /swagger-ui.html |
| springdoc.api-docs.path | not set, so /v3/api-docs | the path of the document |
| springdoc.swagger-ui.path | not set, so /swagger-ui.html | the path of Swagger UI |
The versions, as of October 2026:
| Tool | Version | Note |
|---|---|---|
| springdoc-openapi-starter-webmvc-ui | 3.2.0 | its parent is Boot 4.1.2, and 3.1.x also supports Boot 4.1 |
| springdoc-openapi-maven-plugin | 1.5 | it needs the application running, on a port |
| openapi-generator-maven-plugin | 7.26.0 | set useSpringBoot4 and useJackson3 for a Boot 4 service |
| Microsoft.AspNetCore.OpenApi | 10.0.8 | brings Microsoft.OpenApi 2.0.0, which has a known vulnerability |
The contracts of the order service:
| File | What it describes | Who owns it |
|---|---|---|
| contracts/openapi.yaml | the HTTP API, generated by springdoc | the order service |
| contracts/events/OrderPlacedV1.json | the event of a placed order | the order service |
| contracts/events/OrderShippedV1.json | the event of a shipped order | the order service |
| contracts/events/OrderCancelledV1.json | the event of a cancelled order | the order service |
| contracts/events/consumed-PaymentReceivedV1.json | the part of the payment event that the order service reads | the payment service |
Security in depth#
The order service trusts a caller only after it checks a signed JSON Web Token (JWT). Spring Security checks the token in a chain of servlet filters. Each controller method then names the scope it needs. This chapter follows one request through every check. It explains where a permission check belongs, and covers messages with no user, the service's own token, audit, tests and a local token issuer.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | [Authorize(Policy = "orders.write")] on each action | @OrdersWrite on each controller method | the service checks scopes where users enter, so its application services carry no permission annotation Why |
| twist | AddAuthentication().AddJwtBearer() in each service | one SecurityFilterChain bean from the platform library | every service gets the same chain, and a service may still replace it with a bean of its own Why |
| new | no equivalent | ProblemAuthenticationEntryPoint | the filter chain answers 401 before Spring MVC runs, so the platform writes the problem body there Why |
| new | no equivalent | @AutoConfiguration(beforeName = ...) | Boot's own chains back off when a chain exists, so the library's chain must be created first Why |
| twist | o.Authority = "https://..." | issuer-uri, used at the first token | Spring fetches the discovery document and the keys on first use, so startup does not need the issuer Why |
| trap | a ClockSkew of 5 minutes | a clock skew of 60 seconds | a token that expired 90 seconds ago is refused by Spring and accepted by ASP.NET Core Why |
| trap | a token without exp is refused | a token without exp is accepted | Spring checks exp only when the claim is present, so the issuer must always set it Why |
| trap | a typ header of at+jwt is accepted | a typ header of at+jwt is refused with 401 | Spring Security 7 accepts only JWT or no typ header, so an RFC 9068 issuer needs a custom validator Why |
| twist | ValidateAudience is on by default | audiences: order-service | Spring checks the aud claim only when this setting lists audiences, so set it in every service Why |
| twist | p.RequireClaim("scope", "orders.write") | hasAuthority('SCOPE_orders.write') | Spring splits a scope claim at its spaces, but RequireClaim compares the whole string Why |
| new | no equivalent | the authority FACTOR_BEARER | Spring Security 7 adds it to every JWT login, but the jwt() test helper does not add it Why |
| twist | AddControllers() and AddJwtBearer(), with no antiforgery setup | csrf(disable) and SessionCreationPolicy.STATELESS | bearer requests already skip CSRF and create no session, so the two lines matter only for requests without a token Why |
| twist | IHttpContextAccessor and HttpContext.User | SecurityContextHolder.getContext() | the caller of the request, stored for the thread that handles the request Why |
| trap | HttpContext.User flows into Task.Run | the security context stays on its thread | a new thread, a thread pool or an @Async method sees no caller unless a wrapper copies it Why |
| new | a decorator class that you register yourself | @EnableMethodSecurity and a proxy | an interceptor checks @PreAuthorize before the method runs, and outside its transaction Why |
| twist | no equivalent | @PostFilter, @AuthorizeReturnObject and @HandleAuthorizationDenied | further method annotations that the order service does not need Why |
| twist | [Authorize(Policy = "orders.write")] | @OrdersWrite, which carries @PreAuthorize | a meta-annotation, so the scope string is written once Why |
| trap | [Authorize(Policy)] on the action answers 403 before 400 | @PreAuthorize on the controller answers 400 first | Spring MVC reads and validates the body before the method's check runs Why |
| twist | IAuthorizationService.AuthorizeAsync inside the action | @PreAuthorize on the application service | both run after the body is read, so an invalid body gets 400 before 403 Why |
| twist | RequireAuthorization("orders.write") on an endpoint | requestMatchers(...).hasAuthority(...) in the chain | a URL rule answers 403 before the body is read, but it is a second list of paths Why |
| new | no equivalent | the place of the check, chosen by the shape of the service | one entry point for users means a check there, and several mean a check on the use case Why |
| trap | no equivalent | a helper that runs one call with extra scopes | even a careful one grants rights that the token lacks, because a check is on a method that internal callers use Why |
| twist | AuthorizationHandler<BelongsToOrder, Note> | a domain method such as Note.requireBelongsTo | a rule about one record is written with the record, and a wrong order answers 404 Why |
| new | no equivalent | InboxMessageHandler empties the security context | a message or a job acts as the system actor, and calls use cases with no check Why |
| twist | a ClaimsPrincipal read through IHttpContextAccessor | CurrentActor, read by SecurityContextCurrentActor | the audit log asks one small interface who acts, and gets the token's subject or system Why |
| twist | a DelegatingHandler that adds a token | OAuth2ClientHttpRequestInterceptor with client credentials | the service sends its own token to the stock service, not the user's token Why |
| trap | no equivalent | the interceptor's default principal resolver | it caches the client token per signed-in user, so each user causes a new token request Why |
| twist | AuthenticationHandler<AuthenticationSchemeOptions> written for tests | jwt() from spring-security-test | it puts an authentication into the context and never calls the decoder Why |
| trap | no equivalent | @WithMockUser in a resource server test | it passes scope checks, but code that reads JWT claims gets a user object instead of a Jwt Why |
| twist | no equivalent | issuer-uri set to empty, and a jwk-set-uri that nothing serves | the full application starts with no token issuer, because jwt() never calls the decoder Why |
| new | no equivalent | ArchitectureRules.everyEndpointHasAPermission() | the build fails when an endpoint has no permission annotation Why |
| twist | o.MetadataAddress and ValidIssuer set apart | issuer-uri for the host, jwk-set-uri for the Docker network | the local token's iss is the host URL, but the service fetches the keys by container name Why |
| twist | no equivalent | no AWS keys in application.yml | the service gets AWS credentials from its IAM role, and the role needs sns:CreateTopic too Why |
Who calls the order service, and what each caller may donew#
A customer-service agent opens an order in the shop's web application. The browser sends the agent's token with the request. A minute later, the payment service reports a payment on a queue. No user takes part in that. When an agent places an order, the order service also calls the stock service, and that call needs a token too.
So the service has four kinds of caller. Each kind answers the question "who acts, and may they do this?" in its own way:
| Caller | How it proves who it is | What it may do |
|---|---|---|
| an agent, over HTTP | a JWT from the shop's token issuer, in the Authorization header | what the scopes in the token allow |
| the payment service, with a message on a queue | it proves nothing in the message, because the service trusts the queue | record a payment, as the system actor |
| the orchestrator's health probes | no token at all | read /actuator/health and /actuator/info, and nothing else |
| the order service, calling the stock service | its own token, from the client-credentials grant | reserve stock, with the scope stock.reserve |
A scope is a word in the token that names one permission. The order service knows two
scopes. Reads need orders.read, and changes need orders.write. The
quick reference at the end of the chapter lists every endpoint with its scope.
Each controller method says which scope it needs. Here is the method that reads one order:
@OrdersRead
@GetMapping("/{orderId}")
// ...
OrderResponse get(@PathVariable String orderId) {
return mapper.toResponse(orders.get(OrderId.of(orderId)));
}@OrdersRead is an annotation of the
service's own. It means that the caller needs the orders.read scope. The method
itself contains no security code. The platform library checks the token before the method
runs. Spring checks the annotation when the method is called. The sections below follow
this one request through every check, from the token to the audit record.
The platform's filter chaintwist#
Every service of the shop needs the same rule. Each request needs a valid token, except the
requests of the health probes. If every service wrote its own security code, one service would
forget a line at some point. So the platform library, shop-platform, declares
the chain once, as an auto-configuration. Each
service gets it by adding the library. For how a library adds beans to a service, see the
chapter Writing the platform library.
The chain is a bean of type
SecurityFilterChain. It is built with the
domain-specific language (DSL) of
HttpSecurity, which the chapter Security introduces:
@Bean
@ConditionalOnMissingBean(SecurityFilterChain.class)
SecurityFilterChain platformSecurityFilterChain(HttpSecurity http, PlatformSecurityProperties properties) {
http.authorizeHttpRequests(requests -> requests
.requestMatchers(properties.getPublicPaths().toArray(String[]::new)).permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(resourceServer -> resourceServer
.jwt(Customizer.withDefaults())
.authenticationEntryPoint(new ProblemAuthenticationEntryPoint()))
// Tokens, not cookies, prove who the caller is: no session, and so no CSRF risk.
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(AbstractHttpConfigurer::disable);
return http.build();
}@ConditionalOnMissingBean(SecurityFilterChain.class)Spring creates this bean only when the service has no
SecurityFilterChainof its own. So a service that needs other rules declares its own chain, and the platform's chain is not created..requestMatchers(properties.getPublicPaths().toArray(String[]::new)).permitAll()The public paths come from a setting.
permitAll()lets a request on those paths through without a token.String[]::newis a method reference. Here it tellstoArrayhow to create an array of the right size..anyRequest().authenticated())Every other path needs a valid token. This rule checks no scope. The scopes are checked on the controller methods.
.jwt(Customizer.withDefaults())The chain accepts bearer tokens that are JWTs.
withDefaults()takes the settings fromapplication.yml. For those settings, see the section Where the signing keys come from..authenticationEntryPoint(new ProblemAuthenticationEntryPoint()))This object writes the 401 response. See the section One request through the checks.
.csrf(AbstractHttpConfigurer::disable);This line and the line above it switch off sessions and the protection against cross-site request forgery (CSRF). For what the two lines really change, see the section No session, and no CSRF token.
The public paths are a setting with a default. The default is the two Actuator paths that the orchestrator calls without a token:
/**
* Paths that need no token. The default is the health probes, which the orchestrator calls
* without a token. Every other path needs a valid JWT.
*/
private List<String> publicPaths = new ArrayList<>(List.of("/actuator/health/**", "/actuator/info"));The field publicPaths is bound from the setting
platform.security.public-paths. The local profile adds the
OpenAPI document and Swagger UI, so a developer can open them in a browser without a
token:
platform:
# ...
security:
# Swagger UI and the OpenAPI document need no token locally.
public-paths: /actuator/health/**, /actuator/info, /v3/api-docs/**, /swagger-ui/**, /swagger-ui.htmlThe list in public-paths replaces the default as a whole. So the local profile
repeats the two Actuator paths. Without them, the probes would need a token on a developer's machine.
In ASP.NET Core, each service calls AddAuthentication().AddJwtBearer() and
AddAuthorization() in its own Program.cs. The closest match for a
shared chain is a shared library on NuGet that registers the same setup. In Spring, the library declares
the bean, and the service writes no line of security setup.
One request through the checksnew#
An agent with only the orders.read scope sends POST /orders with a
body that is not valid. Several parts of Spring look at that request, one after the other. The
filter chain comes first. Spring model-view-controller (MVC) comes after it. Each
part can stop it, and each part answers with its own status. The order of the parts decides
which answer the agent gets.
The filter chain runs before Spring MVC sees the request. A run for this book printed the 13 filters of a chain like the platform's. The quick reference lists them in order. Five of them matter here:
| Filter | What it does in the order service |
|---|---|
| SecurityContextHolderFilter | gives the request a security context, and clears the thread's context when the request ends |
| BearerTokenAuthenticationFilter | reads the bearer token, checks it, and stores the caller for this request only |
| AnonymousAuthenticationFilter | puts an anonymous authentication into the context when the request has no caller |
| ExceptionTranslationFilter | turns a missing login into 401, and a refusal into 403 |
| AuthorizationFilter | checks the URL rules in order, and it is the last filter by default |
The chain of Spring Security 7.1.2 also has a new filter,
OAuth2ProtectedResourceMetadataFilter. It belongs to the protected resource
metadata of RFC 9728, which the 401 header points to. The order service does not use it.
The 401 needs extra work. It is decided in the filter chain, before Spring MVC runs. So the platform's exception handler, which writes every other problem response, never sees it. The platform adds the problem body in an authentication entry point. Spring Security uses an authentication entry point when a request needs a login and has none:
public class ProblemAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final AuthenticationEntryPoint bearer = new BearerTokenAuthenticationEntryPoint();
private final JsonMapper json = JsonMapper.builder().build();
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
bearer.commence(request, response, authException);
ProblemDetail problem = ProblemDetails.problem(HttpStatus.UNAUTHORIZED, "Unauthorized",
"A valid bearer token is required");
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
json.writeValue(response.getOutputStream(), asMap(problem));private final AuthenticationEntryPoint bearer = new BearerTokenAuthenticationEntryPoint();This is the entry point that Spring Security uses by default for bearer tokens. It sets the status 401 and the
WWW-Authenticateheader, and writes no body.bearer.commence(request, response, authException);The platform's class calls the default first, so the status and the header stay as Spring Security writes them.
ProblemDetails.problem(HttpStatus.UNAUTHORIZED, "Unauthorized",This is the platform's helper for a ProblemDetail. It adds the
traceId, as in every other problem response. For the problem format, see the chapter The web layer in depth.json.writeValue(response.getOutputStream(), asMap(problem));The class writes the body itself, with a Jackson
JsonMapper. No Spring MVC message converter runs here, because the request never reached Spring MVC.
The header of a 401 names the problem. These are the values that Spring Security 7.1.2 sent in a run for this book:
| Case | Status | WWW-Authenticate header |
|---|---|---|
| no token | 401 | Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource" |
| a token that fails a check | 401 | Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: ...", plus error_uri and resource_metadata |
| a valid token without the scope | 403 | Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token." |
The test OrderControllerTest.aRequestWithoutATokenIsA401ProblemBeforeAnyController
checks the 401 response. It expects the header, the title "Unauthorized" and the status in the
body, and it checks that the controller's service was never called.
A 403 can come from two places. A URL rule in the chain refuses the request in
AuthorizationFilter. Then ExceptionTranslationFilter writes the 403,
and the body was empty in the run. A permission annotation refuses the call inside Spring MVC.
Then the platform's exception handler writes a 403 problem with a traceId. The
section How method security works shows that handler.
The authorization filter checks every dispatch, not only the first one.
One pass of a request through the filters is called a dispatch. An error page or a forward is a
second dispatch. So a rule can refuse the error page of a request that has already failed. For
the trap this causes with CSRF and the /error page, see the section
Two chain traps: rule order and CSRF.
Why the platform's chain is created before Boot'snew#
Spring Boot also brings security chains of its own. Each of them is created only when the
application has no SecurityFilterChain yet. So when two libraries offer a chain,
only the first one that Spring creates is used. The platform makes sure that its chain is
first:
@AutoConfiguration(beforeName = {
"org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration",
"org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration",
"org.springframework.boot.security.autoconfigure.actuate.web.servlet.ManagementWebSecurityAutoConfiguration",
"org.springframework.boot.security.oauth2.server.resource.autoconfigure.web.OAuth2ResourceServerWebSecurityAutoConfiguration",
"org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration"})
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@EnableMethodSecurity
@EnableConfigurationProperties(PlatformSecurityProperties.class)
public class PlatformSecurityAutoConfiguration {@AutoConfiguration(beforeName = {This annotation marks the class as an auto-configuration.
beforeNamelists other auto-configurations by their class names. Spring Boot processes this class before each of them."org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration",This Boot class creates the default chain, with form login and HTTP Basic. The class
ManagementWebSecurityAutoConfigurationcreates a chain for the Actuator, andOAuth2ResourceServerWebSecurityAutoConfigurationcreates a JWT chain when aJwtDecoderbean exists."org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration"})This Boot class creates a chain of its own, named
oauth2SecurityFilterChain, with OAuth2 login. It applies when the classes of the OAuth2 client are present and anOAuth2AuthorizedClientServicebean exists. The service has both, because it calls the stock service with the OAuth2 client. So the platform's class must come before this class too.@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)The class applies only to a servlet web application. A worker with no web server gets no chain.
@EnableMethodSecurityThis switches on the permission annotations. See the section How method security works.
Each Boot chain carries the condition @ConditionalOnDefaultServletWebSecurity.
Inside, the condition is @ConditionalOnMissingBean(SecurityFilterChain.class), plus
a check that the classes are present. SecurityAutoConfiguration creates no chain.
The platform names it anyway, which does no harm.
A condition such as "no bean of this type exists yet" is checked in the order in which Spring Boot processes the auto-configurations. A run for this book offered a "platform" chain with that condition, next to Boot's chains, and changed only the ordering and the package name:
| The platform's auto-configuration | The chain that was created |
|---|---|
| with beforeName, in a package named aaa.platform | the platform's chain |
| with beforeName, in a package named zzz.platform | the platform's chain |
| no ordering, in aaa.platform | the platform's chain |
| no ordering, in zzz.platform, with issuer-uri set | Boot's jwtSecurityFilterChain |
| no ordering, in zzz.platform, without issuer-uri | Boot's defaultSecurityFilterChain |
Without an ordering, Spring Boot starts from the alphabetical order of the class names. A
library in a com. package sorts before org.springframework, so it
works by accident. The same code in another package loses, and nothing warns you.
beforeName makes the order the same in every package.
The service tests this. ApplicationWiringTest.theContextStartsWithThePlatformsBeans
starts the whole application and expects exactly one chain, named
platformSecurityFilterChain.
Boot 4.1.2 deprecated the condition annotation @ConditionalOnDefaultWebSecurity
for removal. Its replacement is @ConditionalOnDefaultServletWebSecurity, new in
4.1.2. A library that writes its own condition with the old name gets a deprecation warning on
4.1.2.
Where the signing keys come fromtwist#
The chain checks each token with settings from application.yml. The issuer's URL
comes from an environment variable, so each environment can name its own issuer:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.Authority = "https://login.shop.example/default";
o.Audience = "order-service";
});security:
oauth2:
resourceserver:
jwt:
# Spring Security downloads the issuer's signing keys and checks the "iss" claim.
# It also checks the token's "typ" header: "JWT", or no typ header, passes. "at+jwt" is refused.
issuer-uri: ${OAUTH_ISSUER_URI}
# Tokens must be issued for this service.
audiences: order-serviceissuer-uri: ${OAUTH_ISSUER_URI}This is the URL of the token issuer.
${OAUTH_ISSUER_URI}reads an environment variable.issuer-uriis likeAuthorityin the JwtBearer options. Spring uses the URL twice. It finds the signing keys from it, and it compares every token'sissclaim with it.audiences: order-serviceEvery token must name
order-servicein itsaudclaim. This setting is likeAudiencein the JwtBearer options.
With issuer-uri, Spring Boot does not contact the issuer at startup. It
registers a decoder that builds the real decoder when the first token arrives. A run for this
book counted the requests to the issuer. There were none after startup, and none after a
request without a token. The first valid token caused two requests:
Each step has a measured fact behind it:
- A JSON Web Key Set (JWKS) is a JSON document with the issuer's public keys. Each key has an
ID, called the
kid. Each token names thekidof the key that signed it. - Spring tries the discovery document at
{issuer}/.well-known/openid-configuration. The reference names two other places that an issuer may use instead. - Five more requests with the same key made no further request to the issuer. The library that checks the signatures is called Nimbus. Its default cache time is 300,000 milliseconds, which is 5 minutes.
- A token signed with a key that the issuer had not published yet got 401, after exactly one new fetch of the key set. Once the issuer published the key, the next token caused one more fetch and got 200. So the issuer can rotate its keys while the service runs.
- The calls to the issuer time out after 30 seconds by default.
If the issuer cannot be reached, the first token fails with an exception, not with
a 401. A test with an issuer host that does not exist got
JwtDecoderInitializationException: Failed to lazily resolve the supplied JwtDecoder
instance. Even a header such as Bearer abc, which is not a JWT, started the
discovery. The status that a real client sees in this case was not measured.
A second setting, jwk-set-uri, gives the URL of the key set directly. With both
settings, Spring fetches no discovery document. It reads the keys from
jwk-set-uri, and it still compares the iss claim with
issuer-uri. A run confirmed both. It also showed that Spring then builds the
decoder at startup, but still fetches no keys before the first token. The local profile uses
this pair. See the section A local token issuer, and the issuer-versus-keys
trick.
Use issuer-uri alone in production. Add jwk-set-uri only when the
service cannot reach the discovery document at the issuer's own URL.
A JwtDecoder bean of your own replaces Boot's decoder, with its
checks. A decoder that you build yourself does not get Boot's issuer and audience
checks, unless you add them. And a decoder built with
JwtDecoders.fromIssuerLocation calls the issuer at once, so the service then needs
the issuer at startup. Prefer the settings. Declare a decoder only in tests. See the section
Testing security in a controller slice.
What Spring checks in each tokentrap#
A token can be refused for many reasons. A C# developer expects the checks of
AddJwtBearer. Several of the Spring checks differ. Spring Boot builds the list of
checks from these parts:
- The default checks of Spring Security. They check the
typheader, the time claimsexpandnbf, and tokens that are bound to a certificate. - The issuer check, when
issuer-uriis set. - The audience check, when
audiencesis set. - Every bean of type
OAuth2TokenValidator<Jwt>in the application.
The signature is checked too, with the key that the token's kid names. A run
for this book sent one bad token for each check. The WWW-Authenticate header of
the 401 ends with the reason. For an expired token, it looked like this:
WWW-Authenticate: Bearer error="invalid_token",
error_description="An error occurred while attempting to decode the Jwt: Jwt expired at ...",
error_uri="https://tools.ietf.org/html/rfc6750#section-3.1",
resource_metadata="http://localhost/.well-known/oauth-protected-resource"The reason is the end of error_description. These are the reasons the run
printed:
| The token | Spring Security 7.1.2 | The reason in the header |
|---|---|---|
| signed with a key the issuer does not publish | 401 | Signed JWT rejected: Another algorithm expected, or no matching key(s) found |
| signed with the wrong key | 401 | Signed JWT rejected: Invalid signature |
| not a JWT at all | 401 | Malformed token |
| iss is another issuer | 401 | The iss claim is not valid |
| aud does not name order-service, or is missing | 401 | The aud claim is not valid |
| exp was 30 seconds ago | 200 | none, because the clock skew is 60 seconds |
| exp was 90 seconds ago | 401 | Jwt expired at ... |
| nbf is 90 seconds in the future | 401 | Jwt used before ... |
| no exp claim at all | 200 | none, because exp is checked only when present |
| typ header at+jwt | 401 | the given typ value needs to be one of [JWT] |
The clock skew is how far the clocks of the issuer and the service may differ. Spring allows
60 seconds. A token with an aud list passes when one entry matches one entry of
audiences. A token whose exp is before its iat fails even
earlier, while Spring builds the token object, with the message expiresAt must be after
issuedAt.
Spring Security accepts a token with no exp claim. ASP.NET
Core refuses the same token with "The token has no expiration". A token without an expiry is
valid for ever. The shop's issuer always sets exp, so the service relies on it. If
you do not trust an issuer to set it, add a validator bean that requires the claim.
Spring Security 7 refuses a token whose typ header is
at+jwt. The check is new in Spring Security 6.5. It is on by default in
version 7. It accepts JWT, or no typ header. An issuer that follows
RFC 9068 sends at+jwt. Such tokens need a custom validator in every Spring
service. ASP.NET Core accepted at+jwt in the same
run. The local token issuer sends typ: JWT, so the local tokens pass.
The defaults of ASP.NET Core differ in several places. A run of
Microsoft.AspNetCore.Authentication.JwtBearer 10.0.12 printed its defaults and sent
the same kinds of token:
| Check | ASP.NET Core 10, AddJwtBearer | Spring Security 7.1.2 |
|---|---|---|
| clock skew | 5 minutes, so exp 4 minutes ago is accepted | 60 seconds, so exp 90 seconds ago is refused |
| a token with no exp | refused, because RequireExpirationTime is true | accepted |
| typ header at+jwt | accepted, because ValidTypes is null | refused with 401 |
| audience | checked by default, because ValidateAudience is true | checked only when audiences is set |
| claim names | MapInboundClaims renames sub to a long URI claim type | the claims keep their names from the token |
| a 401 without a token | the header WWW-Authenticate holds only the word Bearer | the header adds resource_metadata |
| a 403 | no WWW-Authenticate header | error="insufficient_scope" in the header |
The audience check matters most in practice. A Spring service without
audiences accepts any token from its issuer, including a token meant for the stock
service. Set audiences in every service.
From scopes to authoritiestwist#
Spring Security does not check scopes directly. It turns each scope into an authority. An authority is a plain string that a rule can ask for. The local token issuer gives an agent this token:
{
"requestParam": "client_id",
"match": "agent",
"claims": {
"sub": "local-agent",
"aud": ["order-service"],
"scope": "orders.read orders.write"
}
},The scope claim holds two scopes, separated by a space. Spring Security 7 gives
the agent these authorities:
| Authority | Where it comes from |
|---|---|
| SCOPE_orders.read | the first scope, with the prefix SCOPE_ |
| SCOPE_orders.write | the second scope, with the prefix SCOPE_ |
| FACTOR_BEARER | added by Spring Security 7 to every JWT login |
The default converter reads the claim scope, as a string with spaces, or the
claim scp, as a list. A run gave the same two authorities for both forms. The converter
reads no other claim. A roles claim gives no authority until you map it. See the section
Three ways a method rule fails.
FACTOR_BEARER is new in Spring Security 7. Its class is
FactorGrantedAuthority. Its name says that the caller proved who they are with a
bearer token. The order service never checks it. But a test that compares the whole list
of authorities gets a different answer in production, because the test helper
jwt() does not add it.
The caller's name is the sub claim. For the token above,
authentication.getName() returns local-agent. The principal object is
a Jwt. With a real token, its class is JwtAuthenticatedPrincipal, a
private subclass of Jwt. Code that declares a Jwt works with both.
Code that compares the exact class does not.
Boot builds the converter from settings, unless the application declares a
JwtAuthenticationConverter bean. The settings change the claim, the prefix, the
separator and the claim used for the name. The quick reference lists them. The order service
keeps the defaults.
In ASP.NET Core, a policy that requires a scope needs code that splits the claim:
builder.Services.AddAuthorizationBuilder()
.AddPolicy("orders.write", p => p.RequireAssertion(c =>
c.User.FindAll("scope")
.SelectMany(claim => claim.Value.Split(' '))
.Contains("orders.write")));A run showed why. RequireClaim("scope", "orders.write") passed for the scope
string orders.write. It failed with 403 for orders.read orders.write,
because it compared the whole string. A token with the claim scp failed too,
because MapInboundClaims had renamed the claim. Spring splits the scopes and reads
both claim names, so hasAuthority('SCOPE_orders.write') needs no extra code.
No session, and no CSRF tokentwist#
Two lines of the platform's chain switch off sessions and CSRF protection:
// Tokens, not cookies, prove who the caller is: no session, and so no CSRF risk.
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(AbstractHttpConfigurer::disable);A C# developer reads these lines as "without them, the API would use sessions and need CSRF tokens". Runs for this book showed that this is not true for requests with a bearer token:
- The resource server already switches CSRF protection off for every request that carries a bearer token. With CSRF protection on, a POST with a bearer token and no CSRF token got 201.
- A POST to a public path with no bearer token got 403 with CSRF protection on. So the
csrfline changes only requests without a bearer token. - A bearer request creates no session, with or without
STATELESS. The filter stores the caller for the one request only, and no response had aSet-Cookieheader. STATELESSalso stops every other part of the chain from using a session. It replaces the store of the security context and the cache of saved requests with versions that store nothing.
The service keeps both lines, for two reasons. First, the lines make the design visible in
the code. No part of the service keeps state for a caller between requests. Second, the lines
let a public POST work without a CSRF token. A run with CSRF protection on and without STATELESS showed the difference
in the chain. It had a CsrfFilter after the HeaderWriterFilter, and no
SessionManagementFilter. For what a CSRF attack is, see the section
Two chain traps: rule order and CSRF.
Keep CSRF protection on for anything that a browser calls with cookies. The order service has no such endpoint.
In ASP.NET Core, an API built with AddControllers() and
AddJwtBearer() needs no line about antiforgery. The ASP.NET Core documentation says
that AddControllers() does not switch on antiforgery tokens. It also says that CSRF
is a concern when a cookie carries the token. It is not a concern when the client keeps the
token in local storage and sends it as a bearer token. Spring works in the opposite way. CSRF
protection is on by default, and the resource server skips it for each request with a bearer
token.
The security context: one per threadnew#
The audit log must record who placed an order. The use case does not receive the caller as a parameter. Spring Security stores the caller where any code of the request can read it. That place is called the security context. The platform reads it in one small class:
public class CurrentActor(IHttpContextAccessor accessor)
{
public string Id() =>
accessor.HttpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system";
}public class SecurityContextCurrentActor implements CurrentActor {
@Override
public String id() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()
|| authentication instanceof AnonymousAuthenticationToken) {
return SYSTEM;
}
return authentication.getName();
}
}accessor.HttpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.ValueIn ASP.NET Core,
MapInboundClaimsrenamessubto the claim typeClaimTypes.NameIdentifier. So the C# code reads that name.SecurityContextHolder.getContext().getAuthentication();SecurityContextHolderis a class with static methods.getContext()returns the security context of the current thread.getAuthentication()returns the caller, or null when the thread has none.authentication instanceof AnonymousAuthenticationToken) {On a request without a caller, such as a probe on a public path, the anonymous filter puts an
AnonymousAuthenticationTokeninto the context. Its name isanonymousUser, andisAuthenticated()returns true for it. So the class must check the type, and it treats the token as no caller.return authentication.getName();For a JWT, the name is the token's
subclaim.
Three objects are involved, one inside the other:
| Object | What it holds | ASP.NET Core |
|---|---|---|
| SecurityContextHolder | the strategy that stores one context for each thread | IHttpContextAccessor |
| SecurityContext | one Authentication, or none | HttpContext, for this purpose |
| Authentication | the caller's name, its authorities and the principal, here a Jwt | ClaimsPrincipal |
By default, SecurityContextHolder keeps the context in a
ThreadLocal. A ThreadLocal holds one value
for each thread. The holder has three strategies, named by constants:
| Strategy | What it does | Use it |
|---|---|---|
| MODE_THREADLOCAL | one context for each thread, and the default | always, in a web service |
| MODE_INHERITABLETHREADLOCAL | a new thread starts with a copy of its parent's context | never with a thread pool, because a pool thread keeps its first copy |
| MODE_GLOBAL | one context for the whole JVM | only in a desktop application with one user |
The system property spring.security.strategy selects the strategy. Spring reads
it once, when the class is loaded.
Think of the context as a name badge that one worker wears. Any task the worker does carries the badge. A second worker does not wear it, even when the first worker passes a task on. The comparison stops at the badge itself. A Java thread can wear a context that belongs to no person. And code can swap the badge for another one.
The bearer token filter puts the caller into the context of the thread that handles the
request. It also stores the context for this one request only, in a request attribute. When
the request ends, SecurityContextHolderFilter clears the thread's context in a
finally block. So the next request on the same pool thread starts with no
caller.
Read the context through a small interface, such as CurrentActor. Do not call
SecurityContextHolder from the domain or the application layer. The reference also
advises one thing for code that sets a caller. Build a new context with
SecurityContextHolder.createEmptyContext(), and do not change the shared one with
getContext().setAuthentication(...). This avoids race conditions between
threads.
The caller does not follow work to other threadstrap#
ASP.NET Core and Spring behave in opposite ways here. In ASP.NET Core, the caller flows with
the ExecutionContext, so it follows an await and a
Task.Run:
[HttpGet("/flow"), Authorize]
public async Task<string> Flow()
{
string Name() => accessor.HttpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "none";
var before = Name();
var inTaskRun = await Task.Run(Name); // the caller flows into Task.Run
return before + inTaskRun;
}A run of the same idea in ASP.NET Core 10 found the caller in four places. They were before
the await, after it, inside Task.Run and on a new Thread.
Only
ThreadPool.UnsafeQueueUserWorkItem and ExecutionContext.SuppressFlow()
lost it.
Spring's default ThreadLocal does the opposite. Nothing follows the work unless a
wrapper copies it. A run with the user alice signed in gave these results:
| Where the code ran | What getAuthentication() saw |
|---|---|
| a new platform thread | null |
| a new virtual thread | null |
| CompletableFuture.supplyAsync | null |
| a task on a plain fixed thread pool | null |
| an @Async method, on Boot's default executor | null |
| an @Async method, with spring.threads.virtual.enabled=true | null |
| a DelegatingSecurityContextRunnable on a new thread | alice |
| a DelegatingSecurityContextExecutorService over a fixed pool | alice, and the next plain task on that pool thread saw null |
| the same wrapper over Executors.newVirtualThreadPerTaskExecutor() | alice |
| an @Async method, on a DelegatingSecurityContextAsyncTaskExecutor | alice |
A wrapper copies the caller's context into the task, runs it, and clears the context
afterwards. A virtual thread behaves like any
other thread here. For @Async, the wrapper is set in an
AsyncConfigurer bean. For more about @Async, see the section
@Async.
The inheritable mode copies the caller once, when a pool creates its thread.
With MODE_INHERITABLETHREADLOCAL, a pool thread created while alice was
signed in kept alice. A later task from bob ran on that thread and
still saw alice. So that mode gives one user's identity to another user's work.
Never use it with a thread pool.
The order service starts no threads in its use cases. If a use case must run work on another
thread, pass the actor's ID to that work as a plain value. A plain value cannot leak to another
task. Use a wrapper only for code that must check permissions on the other thread. The
ASP.NET Core documentation gives the same advice for HttpContext. It says to pass
the required data to background work, not the context itself.
The order service has no background work today. The platform's test
ActorOnAnotherThreadTest shows both ways side by side, with alice
signed in on the test's thread. The first way reads the actor before the work starts:
/** The recommended way: read the actor on the request's thread, and pass the work a value. */
@Test
void passTheActorToTheWorkAsAPlainValue() throws Exception {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
String actor = currentActor.id();
Future<String> actorSeenByTheWork = executor.submit(() -> actor);
Future<String> actorOfTheOtherThread = executor.submit(currentActor::id);
assertThat(actorSeenByTheWork.get()).isEqualTo("alice");
assertThat(actorOfTheOtherThread.get()).isEqualTo(CurrentActor.SYSTEM);
}
}String actor = currentActor.id();This line runs on the caller's thread, where the security context holds
alice. The work receives only the string.Future<String> actorOfTheOtherThread = executor.submit(currentActor::id);The same
CurrentActor, called on the other thread, finds no authentication there. So it answerssystem. Code that read the context on the other thread would record the wrong actor, with no error.
The second way wraps the executor:
/**
* Only for work that must check permissions itself: the wrapper copies the caller's context
* into each task when it is submitted, and clears it when the task ends.
*/
@Test
void wrapTheExecutorWhenTheWorkMustCheckPermissions() throws Exception {
try (ExecutorService executor = new DelegatingSecurityContextExecutorService(
Executors.newVirtualThreadPerTaskExecutor())) {
Future<String> actorOfTheOtherThread = executor.submit(currentActor::id);
assertThat(actorOfTheOtherThread.get()).isEqualTo("alice");
}
}DelegatingSecurityContextExecutorService is a wrapper from Spring Security. It
takes the caller's context when a task is submitted, sets it on the thread that runs the task,
and clears it afterwards. So the task sees alice. Choose it only when the work
checks permissions itself, for example with @PreAuthorize on a method it calls. A
plain value is easier to read and to test, and it cannot carry more than the work needs.
How method security worksnew#
The controller method carries @OrdersWrite, and no line of the method checks
anything. Something must read the annotation and refuse the call. That something is method
security. The annotation @EnableMethodSecurity on the platform's
auto-configuration switches it on. Without it, every permission annotation is ignored, and
nothing warns you.
Method security works through a proxy, as @Transactional does. For what a proxy is, see the chapter How Spring actually works. Spring creates a subclass of the controller with the Code Generation Library (CGLIB). Spring MVC calls the subclass, and the subclass runs a list of interceptors before it calls your method:
Each annotation has its own interceptor. For @PreAuthorize, the interceptor is
AuthorizationManagerBeforeMethodInterceptor. It asks a
PreAuthorizeAuthorizationManager to evaluate the expression of the annotation. The
expression is written in the Spring Expression Language
(SpEL).
Each interceptor has an order, and the lower number runs first. These are the values in Spring Security 7.1.2:
| Interceptor | Order | When it runs |
|---|---|---|
| @PreFilter | 100 | before the method |
| @PreAuthorize | 200 | before the method |
| @Secured | 300 | before the method |
| JSR-250 annotations such as @RolesAllowed | 400 | before the method |
| @AuthorizeReturnObject | 450 | after the method, and it wraps the result |
| @PostAuthorize | 500 | after the method returns |
| @PostFilter | 600 | after the method, and it filters the returned collection |
| @Transactional | Integer.MAX_VALUE | around the method, inside every security interceptor |
So the permission check runs outside the transaction. A run with a transaction manager that
recorded its events showed it. A refused call began no transaction. An allowed call began one
and committed it. The offset attribute of @EnableMethodSecurity moves
all security interceptors at once.
In the order service, the check is on the controller, and the controller has no transaction. The use case starts its transaction later. So the order matters only when one bean has both annotations.
A refusal by @PostAuthorize comes after the commit.
@PostAuthorize checks the return value, after the method has run. Its order, 500, is
lower than the transaction's. So the transaction has already committed when the check refuses
the call. The reference names this case. If the refusal must roll back the change, it says to
put @EnableTransactionManagement before @EnableMethodSecurity. This
case was not run for this book.
A refusal throws AuthorizationDeniedException. That class is a subclass of
AccessDeniedException, so a handler for AccessDeniedException catches
it. The platform's controller advice turns it
into a 403 problem:
@ExceptionHandler(AccessDeniedException.class)
ResponseEntity<ProblemDetail> accessDenied(AccessDeniedException ex) {
return respond(ProblemDetails.problem(HttpStatus.FORBIDDEN, "Forbidden", "You are not allowed to do this"));
}The same advice has a handler for Exception, which answers 500. The handler for
the more specific type wins, so a refusal still gets 403. The test
OrderControllerTest.aCallerWithoutTheWriteScopeIsA403AndNothingRuns expects the
title "Forbidden". OrderJourneyIT.aCallerWithoutTheWriteScopeIsForbidden expects a
traceId in the body.
Two more facts about the proxy matter for the service:
- The controller class and its methods are package-private. The check still runs on them. The 403 tests of the controller prove it.
- A call from inside the same object skips the proxy, so it skips the check. This is called
self-invocation. A run for this book confirmed it
for
@PreAuthorize. A controller method is called only by Spring MVC, through the proxy. So a check on a controller cannot be skipped this way. For the trap in full, see the section Self-invocation: a call from inside the class.
ASP.NET Core has no proxy for this. An [Authorize] attribute on an action is
evaluated before model binding, so before the action runs. The closest match for a permission check on any class is a decorator that you write and register
yourself.
Other method security annotations#
The order service needs only @PreAuthorize. The other annotations solve other
problems. Most of them were run for this book:
| Annotation | What it does | What the run showed |
|---|---|---|
| @PostFilter | removes elements from the returned collection that fail the expression | with filterObject.startsWith(authentication.name) as alice, [alice-1, bob-1, alice-2] became [alice-1, alice-2] |
| @AuthorizeReturnObject | returns a proxy of the result, so the result's own annotated methods are checked | it works on a plain class, but a record result failed with ClassCastException |
| @HandleAuthorizationDenied | names a handler that returns a fallback value instead of throwing | new in Spring Security 6.3, read from the source, and not run |
| annotation templates | let a meta-annotation take a value, as in @HasScope("orders.write") | needs a bean of type AnnotationTemplateExpressionDefaults, new in Spring Security 6.4 |
A record is final, so Spring cannot subclass it. For a record, @AuthorizeReturnObject
makes an interface proxy, and that proxy is not of the record's type. Use the annotation only on
methods that return a class that is not final, or an interface.
Spring can also weave method security into the bytecode with AspectJ, instead of using
proxies. The setting is @EnableMethodSecurity(mode = AdviceMode.ASPECTJ). The weaving makes the build more complex, so the
shop does not use it.
Permission annotations: each scope string written oncetwist#
[HttpPost("/orders"), Authorize(Policy = "orders.write")]
public IActionResult Place(PlaceOrder body) => StatusCode(201);/** The caller needs the {@code orders.write} scope. Put it on each controller method that changes data. */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@PreAuthorize("hasAuthority('SCOPE_orders.write')")
public @interface OrdersWrite {
}@Target({ElementType.METHOD, ElementType.TYPE})@Targetsays where the annotation may be written. Here it is a method or a class. On a class, it applies to every method of the class.@Retention(RetentionPolicy.RUNTIME)The annotation stays in the compiled class, so Spring can read it while the program runs. Without it, Spring would never see the annotation. See the section Retention: the concept C# lacks.
@DocumentedThe annotation becomes part of the public contract of each method that carries it. So Javadoc shows it on those methods.
@PreAuthorize("hasAuthority('SCOPE_orders.write')")An annotation on an annotation is called a meta-annotation. Spring Security finds the
@PreAuthorizeon@OrdersWrite, and applies it to each method that carries@OrdersWrite. A run for this book confirmed that a meta-annotation refuses a read-only caller.public @interface OrdersWrite {@interfacedeclares an annotation type. For more, see the section Declaring one.
The C# attribute names a policy, and the policy holds the rule. The Java annotation holds the
rule itself. In both, the rule is written once. In Java, a misspelled annotation name does not
compile. A misspelled scope inside @PreAuthorize compiles, and the rule then never
matches. So the scope string is in one file for each scope, and the controllers use the
names.
@OrdersRead is the same, with orders.read. Both annotations are in
the shared.security package, because every feature uses them. For the rules about
the shared package, see the chapter Features, layers and the
rules.
A service with many scopes can use an annotation template instead. One annotation, such as
@HasScope("orders.write"), then serves every scope. For what it needs, see the section
Other method security annotations. With two
scopes, two named annotations are easier to read and to search for.
Three places to check a permission, measuredtrap#
A permission check can be in three places. It can be a URL rule in the filter chain, an annotation on the controller method, or an annotation on the application service. The place decides which status a caller gets when two things are wrong at once. A C# developer expects an attribute on the action to answer 403 first.
@OrdersWrite
@PostMapping
// ...
ResponseEntity<OrderPlacedResponse> place(@Valid @RequestBody PlaceOrderRequest request) {
PlacedOrder placed = orders.place(
new CustomerContact(request.customer().name(), request.customer().email()),
new DeliveryMethod(request.deliveryMethod()),
mapper.toItems(request),
mapper.toNewNotes(request));
return ResponseEntity.created(URI.create("/orders/" + placed.orderId())).body(mapper.toResponse(placed));
}Show the answer
Spring MVC reads and validates the body before the method is called. The
token is valid, so the filter chain lets the request through. Spring MVC then resolves the
method's arguments. It reads the JSON and runs @Valid. Validation fails, so Spring
MVC throws before it calls the method. The proxy's check would run only at the call, so it
never runs.
In ASP.NET Core, [Authorize(Policy = ...)] on the action gave 403 in the same
case. Authorization runs before model binding there. If 403 must come first in Spring, use a
URL rule in the filter chain.
A run for this book measured the three places with the same endpoint. The body failed
@NotBlank and @Min(1) when it was invalid. The table also shows the
two ASP.NET Core forms from a run on .NET 10:
| Where the check is | write scope, invalid body | read scope, invalid body | read scope, valid body | read scope, broken JSON | no token |
|---|---|---|---|---|---|
| a URL rule in the filter chain | 400 | 403 | 403 | 403 | 401 |
| @PreAuthorize on the controller method | 400 | 400 | 403 | 400 | 401 |
| @PreAuthorize on the service method the controller calls | 400 | 400 | 403 | 400 | 401 |
| ASP.NET Core, [Authorize(Policy)] on the action | 400 | 403 | 403 | 403 | 401 |
| ASP.NET Core, AuthorizeAsync inside the action | 400 | 400 | 403 | 400 | 401 |
A path variable behaves like the body. With @PathVariable UUID, a read-only
caller who sent not-a-uuid got 400 for the controller check and for the service
check. A valid universally unique identifier (UUID) got 403.
So only the URL rule answers 403 before the body is read. The C# attribute behaves like the
Spring URL rule. The C# check in code behaves like @PreAuthorize. A missing or bad
token is always 401 first, because the filter chain checks the token before anything else.
The order service records its own behaviour in two tests:
/**
* Records what a caller without the write scope gets when the body is also invalid.
* Spring MVC reads and validates the body while it resolves the method's arguments, which
* is before the method, and its permission check, runs. So the answer is 400, not 403.
*/
@Test
void aCallerWithoutTheScopeWhoSendsAnInvalidBodyGets400() throws Exception {
mvc.perform(post("/orders").with(READ_ONLY).contentType(MediaType.APPLICATION_JSON).content("""
{ "customer": { "name": "", "email": "x" }, "deliveryMethod": "", "lines": [] }
"""))
.andExpect(status().isBadRequest());
verifyNoInteractions(orders);
}
/**
* Records the same case for an invalid ID in the path. The controller turns the text into
* an {@code OrderId} inside the method, after the permission check, so the answer is 403.
*/
@Test
void aCallerWithoutTheScopeWhoSendsAnInvalidPathIdGets403() throws Exception {
mvc.perform(post("/orders/not-a-uuid/ship").with(READ_ONLY))
.andExpect(status().isForbidden());
}.andExpect(status().isBadRequest());The first test records the 400 for an invalid body. It is a test of the trade-off, so a change to the behaviour fails the build and is noticed.
verifyNoInteractions(orders);ordersis a mock of the use case. This line proves that no use case ran. The 400 tells the caller about the body, but the caller changed nothing.mvc.perform(post("/orders/not-a-uuid/ship").with(READ_ONLY))The controller takes the order ID as a
String, and converts it inside the method. So the check runs first, and a bad ID from a read-only caller gets 403.
The three places differ in more than the status:
| Question | URL rule in the chain | Annotation on the controller | Annotation on the service |
|---|---|---|---|
| When the check runs | before Spring MVC reads anything | after the arguments are read and validated | when the use case is called |
| A read-only caller with an invalid body | 403 | 400 | 400 |
| The body of the 403 | empty, unless the chain also writes a problem | the platform's problem, with a traceId | the platform's problem, with a traceId |
| Where the rule is written | in a list of paths, apart from the controllers | on the method it protects | on the use case it protects |
| A new endpoint that nobody protected | it gets only the authenticated rule | the architecture rule fails the build | depends on whether the endpoint calls a protected method |
| Other features and message listeners | not affected | not affected | checked too, so they need a caller with scopes |
A URL rule for the shop would look like
requestMatchers(HttpMethod.POST, "/orders/**").hasAuthority("SCOPE_orders.write").
The rules are matched in order, and the first match decides. So every path of every controller
must appear in the right place in that list. A renamed path in a controller does not change the
list, and nothing warns you.
Choosing the place by the shape of the servicenew#
No place is right for every service. The right place depends on where users enter. A door for users is called an entry point here. It is a way for a user's request to reach a use case. An HTTP controller is an entry point. So is a GraphQL resolver, or a queue that carries commands from users with their tokens.
A message from another service, a scheduled job and a call from another feature are not user entry points. No user stands behind them. The service itself decided to do the work.
One entry point for users. Users reach the use cases only through the HTTP API. This is the order service, and many microservices. Check the permission at the entry point:
- Put a permission annotation on each controller method. This is the default.
- Add an architecture rule that fails the build for an endpoint without one.
- Put no permission annotation on the application services. Other features, listeners and jobs then call them directly.
- If a caller must get 403 before 400, use URL rules in the chain instead. Choose this for an API that callers outside the company use. For the cost, see the section Three places to check a permission, measured.
Several entry points for users. Users reach the same use cases through two or more doors, such as REST and GraphQL. Then each door needs its own check, and the checks must be kept in step. Move the check to the use case, the application service method that the users call. Each new door is then protected without a new line. A check on the use case has a second strength. A reader of the use case sees its permission next to its steps. Three conditions keep this safe:
- Internal callers never call a method that has a permission check. A listener or a job calls a use case of its own, written for the system.
- A use case for the system is a different business action with its own name, such as
recordPayment. It is not a copy of a user's use case without the annotation. - An architecture rule stops the entry points from calling the use cases that are meant for the system.
If the same action must be started by a user and by the system, these conditions cannot
hold. Then put the check on each entry point after all, with the same permission annotation on
each one. @PreAuthorize works on the method of any bean, not only on a
controller.
A check on both the controller and the service has one strength. If one check is forgotten, the other one still refuses the caller. But it brings back the problem of the service checks, because the internal callers still meet them. A URL rule together with controller annotations is safe, but it is two lists to keep in step. Choose one place for each service, and let an architecture rule guard it.
The order service shows the first shape in its code. The application service says so in its own documentation:
* <p>The class has no permission annotation. Permissions are checked on the controllers, where
* users enter. Every method here can therefore be called by another feature or by a message
* listener without a second, unprotected copy of the method.
*
* <p>With about ten use cases, split the class by area, such as {@code OrderFulfilmentService},
* not into a read side and a write side.
*/
@Service
public class OrderService {The class carries @Service and nothing more. The note feature calls
OrderService.requireExists. The order feature calls
NoteService.addNotes and NoteService.countNeedingAction. The payment
listener calls OrderService.recordPayment. None of these calls needs a caller with
scopes, because no service method checks one.
The strict rule. In a layered Domain-Driven Design (DDD) service, the application layer is the boundary of the use cases. A strict reading says that it also decides who may run them.
What we do. Each controller method carries @OrdersRead or
@OrdersWrite. The application services carry no permission annotation. An
architecture rule fails the build for an endpoint without one.
Why. Users enter the order service through one door, the HTTP API. Every other caller of a use case is the service itself, such as another feature, the payment listener or a job. A check at the door runs once for each user request. The internal calls need no identity.
When to stop. Stop when users reach the same use cases through a second door, such as GraphQL or a queue of user commands. Then choose again, by the rules of the section Choosing the place by the shape of the service.
The strict rule. Refuse a caller without the permission before you tell it anything about its request.
What we do. A caller without the scope who sends an invalid body gets 400, with the names of the fields that failed. Two tests record this.
Why. Every caller already has a valid token from the shop's issuer. The errors describe only the shape of the API, and the published OpenAPI contract shows that shape anyway. A URL rule would be a second list of paths to keep in step with the controllers.
When to stop. Stop when callers outside the company use the API, or when a validation message would reveal data, such as "this email address is already registered". Then add URL rules for the paths that change data.
Why an identity-swapping helper is a warning signtrap#
Picture the order service with its checks on the use cases instead. A developer puts
@OrdersWrite on OrderService.recordPayment, because "every use case
needs a permission". The payment listener calls that method. The shop's listener is short:
@Override
protected void handle(PaymentReceivedV1 event) {
Payment payment = new Payment(new PaymentReference(event.paymentReference()),
new Money(event.amount(), event.currency()), event.paidAt());
orders.recordPayment(OrderId.of(event.orderId()), payment);
}The listener's thread has no security context. So the check on
recordPayment finds no caller. A run for this book showed what happens then.
Method security does not refuse with 403. It throws another exception:
AuthenticationCredentialsNotFoundException: An Authentication object was not found in the SecurityContext#The message fails, the queue delivers it again, and in the end it moves to the dead-letter queue. For that path, see the chapter Consuming events: SQS and the inbox.
Developers often try one of two quick fixes. Both make the problem worse.
The first quick fix is a helper that swaps the identity for one call. A careless version grants every scope to a made-up user. A careful developer writes a better one, and the book must answer that one. In the shop, it would look like this:
- It grants only the scope that the inner call needs. For
recordPayment, that isorders.write, and nothing else. - Its
getName()returns the real caller. So in a request, the audit log still names the agent. In the payment listener, it namessystem. - It builds a new context, and restores the old one in a
finallyblock, even after an exception. - It runs the call on the same thread, so the call joins the outer transaction.
- It has one name, so a search finds every place that uses it.
That version gets a lot right. The audit stays true, the extra rights are small, and the context cannot leak to the next task on the thread. A careless version fails on each of these points. But the careful version is still a warning sign, for six reasons:
- It is an elevation of privilege that your own code grants. The security check exists to refuse callers without the scope. The helper decides, in code, to give the scope anyway. That decision is made in every place that calls the helper, not in one place.
- A caller gets more than its token allows. Suppose a read use case, reached
with
orders.read, calls a write use case through the helper. An agent with onlyorders.readcannot call the write use case directly. Yet code inside the agent's request now runs withorders.write. - The inner check protects nothing that a user can reach directly. It says
that a caller with
orders.writemay record a payment. But no user ever records a payment. The check guards a door that users cannot open. The helper only lets the internal callers pass the check. - It fails without a warning when the work moves. The helper sets the
context of the current thread only. If the inner call later moves to another thread, or becomes
@Async, it runs with no caller. See the section The caller does not follow work to other threads. The call then throws, or a careless fix grants more. - Every caller and every test must know about it. A new listener must use the helper, or it fails. A test of the listener must set up the same identity.
- It exists only because of where the check is. Without the check on a method that internal callers use, the helper would have nothing to do.
The second quick fix is a twin. A copy of the method without the annotation serves the internal callers. The twin is public, so a later controller can call it, and skip the check. The compiler cannot tell the twins apart, and a review may miss it.
Both fixes have one cause. The check is on a method that internal callers use too. So the
real fix is to move the check to the door. In the order service, recordPayment
carries no annotation, because the controllers carry them. The listener calls it directly. The
audit log records system, which is the truth.
A helper that empties the context is different. The platform's message handler clears the context before it calls the use case, and restores it afterwards. It invents no identity and grants no scope. See the section Messages and jobs: the system actor.
Rules about one record: in the domain, with the actor passed innew#
A scope says which kind of action a caller may take. It cannot say which records. An agent
with orders.write may resolve notes, but only a note of the order in the URL. A
rule like this is about one record. It needs the record, so it can run only after the record is
loaded.
The shop's note checks that it belongs to the order in the URL. ASP.NET Core offers an authorization handler for a rule about one resource. The order service writes the rule in the note class itself:
public class NoteOwnerHandler : AuthorizationHandler<BelongsToOrder, Note>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, BelongsToOrder requirement, Note note)
{
if (note.OrderId == requirement.OrderId) context.Succeed(requirement);
return Task.CompletedTask;
}
}/**
* Refuses a note that belongs to another order than the one in the URL, as not found.
* Answering 403 would tell the caller that the note exists under another order.
*/
public void requireBelongsTo(String expectedOrderId) {
if (!orderId.equals(expectedOrderId)) {
throw new NoteNotFoundException(id);
}
}The C# handler calls context.Succeed(requirement) when the note belongs to the
order, and the controller answers 403 otherwise. The Java method requireBelongsTo
is part of the note class, and it throws when the order differs. The use case loads the note and
asks the note to check itself, before it changes anything:
@Transactional
public void resolveAction(String orderId, String noteId) {
Note note = notes.findById(noteId).orElseThrow(() -> new NoteNotFoundException(noteId));
note.requireBelongsTo(orderId);
note.resolveAction(currentActor.id(), clock);
saveAndPublish(note, AuditActions.NOTE_ACTION_RESOLVED);
}The line note.requireBelongsTo(orderId) comes before
note.resolveAction(currentActor.id(), clock). A note of another order gets 404, the same answer as a note that does not exist. A 403 would
tell the caller that the note exists under another order. In ASP.NET Core, the controller
calls Forbid() when the handler does not succeed, and Forbid() answers
403. A run for this book measured that. The Java rule throws a "not found" exception instead, and the platform turns it
into 404.
Some rules depend on who acts. The note has one. The agent who wrote a note that needs action
may not resolve it, so a second person checks the work. Write such a rule in the domain too, and
pass the actor in as a plain value. The application service gets the actor from
CurrentActor, and passes the actor's ID to the domain method. That is the
currentActor.id() in the use case above. The note compares it with the author that
it stored when it was written:
/**
* Resolves the action. The agent who wrote the note may not resolve it, so a second person
* checks the work. The use case passes the actor's ID in as a plain value, so the note never
* reads the security context.
*/
public void resolveAction(String actorId, Clock clock) {
if (!actionRequired) {
throw new NoteHasNoActionException(id);
}
if (actionResolvedAt != null) {
throw new NoteActionAlreadyResolvedException(id);
}
if (actorId.equals(createdBy)) {
throw new NoteResolvedByItsAuthorException(id);
}
actionResolvedAt = clock.instant();
events.add(new NoteActionResolved(id, orderId, actionResolvedAt));
}The note never reads SecurityContextHolder. It gets a String, like any
other value, so a plain unit test can check the rule with two made-up agents:
/** A rule that depends on who acts: the actor comes in as a plain value, like any other. */
@Test
void theAgentWhoWroteTheNoteCannotResolveItsAction() {
Note note = Note.create(ORDER_ID, "Call back", "DELIVERY", true, AUTHOR, CLOCK);
assertThatThrownBy(() -> note.resolveAction(AUTHOR, CLOCK)).isInstanceOf(NoteResolvedByItsAuthorException.class);
assertThat(note.needsAction()).isTrue();
}NoteResolvedByItsAuthorException is a business rule violation, so the platform
answers 422 with the code NOTE_RESOLVED_BY_ITS_AUTHOR. The journey test
OrderJourneyIT shows it end to end. The agent agent-1 places an order
with a note. The same agent then tries to resolve the note, and gets 422. A second agent resolves
it, and gets 204.
Keep the security types out of the domain. The architecture rule domainIsPlainJava
fails the build if a class in a domain package uses a class from
org.springframework. It also refuses several other technical libraries. The note is a
light-style entity with Spring Data annotations. It is in a model package, not in a
domain package, so that rule does not check it. The note still takes the actor as a
value. So the rule about who acts reads the same in both styles.
Another form is a guard bean, called from @PreAuthorize. For that form, see the
section Method-level authorisation. The guard loads the record again, outside the use case, and keeps
the rule outside the domain. It is good enough for a simple feature. For the order service, the
rule is with the record. For where each rule belongs, see the chapter
Where each rule goes.
Messages and jobs: the system actornew#
A payment arrives from a queue. No user sent it, and the message carries no token. The platform's base class for listeners makes this explicit. It empties the security context for the call:
protected final void process(T event) {
Map<String, String> context = new HashMap<>(logContext(event));
context.put("eventId", event.eventId());
SecurityContext callerContext = SecurityContextHolder.getContext();
SecurityContextHolder.clearContext();
try (LogContext ignored = LogContext.with(context)) {
inbox.process(consumer, event, this::handle);
} finally {
SecurityContextHolder.setContext(callerContext);
}
}SecurityContext callerContext = SecurityContextHolder.getContext();The method keeps the thread's current context, whatever it is.
SecurityContextHolder.clearContext();The thread now has no caller. Any code that asks who acts gets the system actor.
try (LogContext ignored = LogContext.with(context)) {This is a try-with-resources block. It puts the event's IDs into the logging context, and removes them at the end. For the logging context, see the chapter Logging, tracing and health.
SecurityContextHolder.setContext(callerContext);The
finallyblock puts the old context back, even after an exception. So the thread is as it was before.
A listener thread has no caller anyway. Clearing the context makes the rule hold for every
caller of process. Even on a thread with a signed-in user, the use case runs as
the system.
The use case that the message starts carries no permission check, and says why:
/**
* Records a payment from the payment service. A message starts this use case, so no user
* acts, and the audit log records the system actor.
*
* <p>It joins the transaction of the inbox that called it. The one {@code if} here is not a
* business rule: the domain decided to ignore the payment, so there is nothing to save.
*/
@Transactional
public void recordPayment(OrderId orderId, Payment payment) {Who may send a payment, then? The order service trusts the queue, not the message. Only the payment service should be able to send to that queue. That is a setting in AWS, not in the code.
A scheduled job behaves the same way. The platform's outbox resender runs on a scheduler
thread, which has no security context. Any audit record it causes names system.
For the resender, see the chapter Publishing events: the outbox.
The current actor, and the audit recordnew#
Each change to an order writes an audit record. The record says what happened, to which order, and who acted. The audit log asks one small interface who acts:
public interface CurrentActor {
/** The actor for work that no user started, such as handling a message from a queue. */
String SYSTEM = "system";
/** Returns the actor's ID: the token's subject, or {@link #SYSTEM}. */
String id();
}SYSTEM is a constant of the interface, the actor for work that no user started.
The platform registers the implementation that reads the security context. For that class, see
the section The security context: one per thread. The bean is
replaceable, like every platform bean:
@Bean
@ConditionalOnMissingBean
CurrentActor currentActor() {
return new SecurityContextCurrentActor();
}@ConditionalOnMissingBean lets a service define its own CurrentActor
instead. The audit log asks for the actor each time it writes a record:
@Override
public void record(String action, String resourceType, String resourceId) {
Document entry = new Document("action", action)
.append("resourceType", resourceType)
.append("resourceId", resourceId)
.append("actor", currentActor.id())
.append("at", clock.instant());
String traceId = MDC.get(ProblemDetails.TRACE_ID);
if (traceId != null) {
entry.append("traceId", traceId);
}
mongo.insert(entry, COLLECTION);
}.append("actor", currentActor.id())The actor is the token's
subclaim for a request, andsystemfor a message or a job.String traceId = MDC.get(ProblemDetails.TRACE_ID);The record also gets the trace ID from the mapped diagnostic context (MDC), so support can find the request's log lines.
mongo.insert(entry, COLLECTION);The record is written in the use case's transaction, so it is stored only with the change. For audit records in full, see the chapter Logging, tracing and health.
The interface has three uses. The application services and the audit log never touch Spring Security. A test can supply a fixed actor. And a service with another identity model can replace the bean.
The journey test checks the actors of a whole order's life. An agent places, picks and ships the order. The payment arrives as a message in between:
// The audit log: every change once, by the agent, except the payment, which the system recorded.
List<Document> orderAudit = mongo.find(query(where("resourceId").is(orderId)).with(Sort.by("at")),
Document.class, "audit_log");
assertThat(orderAudit).extracting(entry -> entry.getString("action"))
.containsExactly("ORDER_PLACED", "PAYMENT_RECORDED", "LINE_PICKED", "ORDER_SHIPPED");
assertThat(orderAudit).extracting(entry -> entry.getString("actor"))
.containsExactly("agent-1", "system", "agent-1", "agent-1");The test's token has the subject agent-1. The payment came from the queue, so
its actor is system. With an identity-swapping helper, the second entry would name
the made-up identity, and the test could not tell a person from the system.
The sub claim of a user's token is the user's ID at the issuer. Keep it as an ID.
Do not put a name or an email address in the audit record, because the audit log keeps its
records for a long time.
The service's own token for the stock servicenew#
Placing an order calls the stock service, which also requires a token. The local stub of the stock service answers 401 to a request without one. The order service has two choices. It can forward the agent's token, or it can send a token of its own.
The shop sends a token of its own. The agent's token names order-service as its
audience, so a stock service that checks its audience refuses it. A payment message has no
user token to forward. And the stock service should trust the order service, not every agent.
A service gets its own token with the client-credentials grant. The service sends its client
ID and secret to the issuer, and gets a token back. No user takes part.
The registration is in application.yml:
client:
# The service's own identity, for its calls to the stock service (client credentials).
registration:
stock-service:
provider: shop-identity
client-id: ${STOCK_CLIENT_ID:order-service}
client-secret: ${STOCK_CLIENT_SECRET}
authorization-grant-type: client_credentials
scope: stock.reserve
provider:
shop-identity:
token-uri: ${OAUTH_TOKEN_URI}stock-service:This is the registration ID. The stock client names it to get its token.
client-id: ${STOCK_CLIENT_ID:order-service}The client ID comes from an environment variable. After the colon is the default,
order-service.client-secret: ${STOCK_CLIENT_SECRET}The secret has no default, so it must come from the environment. A secret never goes into this file.
scope: stock.reserveThe service asks for this scope only. It needs nothing more from the stock service.
token-uri: ${OAUTH_TOKEN_URI}This is the issuer's token endpoint, where the service asks for its token.
The client registration needs the starter spring-boot-starter-security-oauth2-client
on Boot 4.1.2. The old name, spring-boot-starter-oauth2-client, still exists in
4.1.2, and its description says that it is deprecated. The platform's
HttpClientFactory adds the token to each call of a client whose settings name a
registration:
/**
* Adds a client-credentials token to each request.
*
* <p>The token belongs to this service, not to the user of the current request. So the
* principal is fixed to the registration ID: every request shares one cached token, and a
* call works on a thread with no user, such as a message listener.
*/
private OAuth2ClientHttpRequestInterceptor tokenInterceptor(String registrationId) {
Authentication thisService = new AnonymousAuthenticationToken(registrationId, registrationId,
AuthorityUtils.createAuthorityList("ROLE_SERVICE"));
OAuth2ClientHttpRequestInterceptor interceptor = new OAuth2ClientHttpRequestInterceptor(tokenManager.get());
interceptor.setClientRegistrationIdResolver(request -> registrationId);
interceptor.setPrincipalResolver(request -> thisService);
return interceptor;
}new OAuth2ClientHttpRequestInterceptor(tokenManager.get());This interceptor of
RestClientadds a bearer token to each request. The token manager fetches the token from the issuer, and keeps it.interceptor.setClientRegistrationIdResolver(request -> registrationId);Every request of this client uses the registration
stock-service.interceptor.setPrincipalResolver(request -> thisService);The token manager stores each token under a principal, and this line fixes the principal. The next paragraphs explain why it matters.
Authentication thisService = new AnonymousAuthenticationToken(registrationId, registrationId,The fixed principal is an object that names the service. It is never put into a security context. It is only the key under which the token is stored.
The token manager is an AuthorizedClientServiceOAuth2AuthorizedClientManager,
which the platform builds. A run for this book showed that this manager gets a token on any
thread, including a thread with no security context. Boot created no token manager of its own
in that run.
By default, the interceptor caches the token for each signed-in user. The
default principal resolver reads the caller from the security context. A run made three calls
with no caller, and they shared one token, stored for anonymousUser. Then a call as
alice and a call as bob each caused a new token request. Each user caused a
token request of their own, for the same service. Under load, that is one call to the issuer for each user.
Only setPrincipalResolver fixed it. An attribute on the request alone changed
nothing.
The stock adapter's test checks the fix against a real HTTP server, with a token endpoint. The server is part of the Java Development Kit (JDK):
@Test
void twoDifferentUsersShareOneServiceToken() {
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("agent-1", null));
adapter.reserve(OrderId.newId(), TWO_MUGS);
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("agent-2", null));
adapter.reserve(OrderId.newId(), TWO_MUGS);
assertThat(reservationCalls).hasValue(2);
assertThat(tokenCalls).hasValue(1);
}Two agents call, and tokenCalls is 1, so the issuer saw one token request. Another test,
sendsTheServicesTokenAndTheOrderIdAsTheIdempotencyKey, checks that the stock service
receives Bearer token-1. The test sets the context directly, because it runs on one
thread and clears the context after each test.
In ASP.NET Core, the closest match is a DelegatingHandler on the
HttpClient that adds the token. For timeouts, retries and the rest of the stock
client, see the chapter Calling other services.
Testing security in a controller slicenew#
A controller test should run the real chain, the real permission annotations and the real error handling, without a token issuer. The platform supplies an annotation for this:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@WebMvcTest
@ImportAutoConfiguration({
PlatformCoreAutoConfiguration.class,
PlatformWebAutoConfiguration.class,
PlatformSecurityAutoConfiguration.class,
PlatformMongoWebAutoConfiguration.class})
@Import(TestJwtDecoderConfiguration.class)
public @interface PlatformWebMvcTest {
/** The controllers to test. */
@AliasFor(annotation = WebMvcTest.class, attribute = "controllers")
Class<?>[] value() default {};
}@WebMvcTestThis starts a test slice with Spring MVC and the controllers only. For slices, see the section Test slices.
PlatformSecurityAutoConfiguration.class,The slice imports the platform's chain, so the test runs the same security as production.
@Import(TestJwtDecoderConfiguration.class)This adds a decoder for tests. The next example shows it.
@AliasFor(annotation = WebMvcTest.class, attribute = "controllers")@AliasForpasses the value of this annotation to@WebMvcTest. So@PlatformWebMvcTest(OrderController.class)means@WebMvcTest(controllers = OrderController.class). A run for this book confirmed that such a slice loads only the named controller.
The slice needs a JwtDecoder, because the chain accepts JWTs. Without an
issuer-uri and without a decoder, a run of a slice failed at startup:
Method api in sec.SecurityConfig required a bean of type 'org.springframework.security.oauth2.jwt.JwtDecoder' that could not be found.#TestJwtDecoderConfiguration, and authenticate with jwt().The platform's decoder for tests refuses every real token:
@TestConfiguration(proxyBeanMethods = false)
public class TestJwtDecoderConfiguration {
@Bean
JwtDecoder jwtDecoder() {
return token -> {
throw new BadJwtException("Tests authenticate with SecurityMockMvcRequestPostProcessors.jwt()");
};
}
}JwtDecoder has one method, so a lambda
can implement it. A run showed both sides. A test with jwt() got 200, and a real
bearer token got 401 with the decoder's message. So the slice starts without an issuer, needs
no network, and a stray real token fails as it would in production.
The tests then authenticate with jwt(), from spring-security-test.
It puts an authentication straight into the security context of the
MockMvc request, and never calls the decoder. The
controller test defines two callers:
/** A customer-service agent's token: a subject and both scopes. */
private static final JwtRequestPostProcessor AGENT = jwt().jwt(token -> token.subject("agent-1"))
.authorities(new SimpleGrantedAuthority("SCOPE_orders.read"), new SimpleGrantedAuthority("SCOPE_orders.write"));
private static final JwtRequestPostProcessor READ_ONLY = jwt()
.authorities(new SimpleGrantedAuthority("SCOPE_orders.read"));jwt().jwt(token -> token.subject("agent-1"))The inner
jwt(...)changes the claims of the token. Here the subject isagent-1, which the audit log would record..authorities(new SimpleGrantedAuthority("SCOPE_orders.read"), new SimpleGrantedAuthority("SCOPE_orders.write"));authorities(...)replaces the authorities. Write them with theSCOPE_prefix, as the converter would produce them.
Without changes, jwt() gives the name user and the one authority
SCOPE_read. Its principal is a Jwt. A scope claim set with
jwt(t -> t.claim("scope", "orders.read")) goes through the same converter as in
production. It gave SCOPE_orders.read in a run. jwt() does not add
FACTOR_BEARER.
The security tests of the controller check the 401 and the 403, and that nothing ran:
@Test
void aRequestWithoutATokenIsA401ProblemBeforeAnyController() throws Exception {
mvc.perform(post("/orders").contentType(MediaType.APPLICATION_JSON).content(VALID_ORDER))
.andExpect(status().isUnauthorized())
.andExpect(header().exists("WWW-Authenticate"))
.andExpect(jsonPath("$.title").value("Unauthorized"))
.andExpect(jsonPath("$.status").value(401));
verifyNoInteractions(orders);
}
@Test
void aCallerWithoutTheWriteScopeIsA403AndNothingRuns() throws Exception {
mvc.perform(post("/orders").with(READ_ONLY).contentType(MediaType.APPLICATION_JSON).content(VALID_ORDER))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.title").value("Forbidden"));
mvc.perform(post("/orders/" + ORDER_ID + "/ship").with(READ_ONLY))
.andExpect(status().isForbidden());
verifyNoInteractions(orders);
}Both tests end with verifyNoInteractions(orders), so no use case ran. Each
permission test has a partner. One caller without the scope gets 403, and one caller with it
succeeds. The test theReadScopeIsEnoughToRead is the other half for
reads.
@WithMockUser passes a scope check, but gives no Jwt.
@WithMockUser(authorities = "SCOPE_orders.write") made a POST pass
hasAuthority('SCOPE_orders.write') with 201 in a run. But its authentication is a
UsernamePasswordAuthenticationToken, and its principal is a User. Code
that reads a claim from the Jwt gets the wrong type. In a resource server, use
jwt().
In Boot 4.1.2, @WebMvcTest includes the resource server auto-configuration. So a
plain slice with issuer-uri in application.yml builds the lazy decoder.
Tests with jwt() pass without a network. But a request with a real
Authorization: Bearer header makes the slice try to reach the issuer.
For the general rules of test slices and of the platform's test annotations, see the chapter Testing in depth.
Security in the journey testtwist#
The journey test starts the whole service, with a real database and a real queue. It still needs no token issuer:
@SpringBootTest(properties = {
"spring.security.oauth2.resourceserver.jwt.issuer-uri=",
"spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:1/jwks","spring.security.oauth2.resourceserver.jwt.issuer-uri=",The issuer setting is empty. So Spring adds no issuer check, and the environment variable of
application.ymlis not needed."spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:1/jwks",Nothing listens on port 1. But with
jwk-set-uri, Spring fetches keys only for a real token, andjwt()never sends one. So the URL is never called.
The journey test uses jwt() as the slice tests do. Its security tests check what
only the whole service can show:
@Test
void aCallerWithoutTheWriteScopeIsForbidden() throws Exception {
mvc.perform(post("/orders").with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_orders.read")))
.contentType(MediaType.APPLICATION_JSON)
.content("{ %s, \"deliveryMethod\": \"STANDARD\", %s }".formatted(CUSTOMER, VALID_LINES)))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.traceId").exists());
}Here the 403 carries a traceId, which the tracing of the full service sets. The
test aRequestWithoutATokenIsRejectedButTheProbesArePublic checks the other side of
the chain. A request without a token gets 401, and the liveness and readiness probes get 200
without one. The wiring test uses the same two settings in its profile
application-wiring.yml.
A rule that every endpoint names its permissionnew#
With checks on the controllers, the danger is a new endpoint without a permission annotation. Any caller with a valid token could then call it. A review can miss that. So the platform states the rule as an ArchUnit rule, which fails the build:
/**
* Every endpoint names the permission it needs, with an annotation that wraps
* {@code @PreAuthorize}, such as {@code @OrdersRead}. A new endpoint without one fails the build.
*/
public static ArchRule everyEndpointHasAPermission() {
return methods().that().areDeclaredInClassesThat().areAnnotatedWith(RestController.class)
.and().areMetaAnnotatedWith(RequestMapping.class)
.should().beMetaAnnotatedWith(PreAuthorize.class)
.as("Every endpoint has a permission annotation");
}methods().that().areDeclaredInClassesThat().areAnnotatedWith(RestController.class)The rule looks at the methods of every
@RestControllerclass..and().areMetaAnnotatedWith(RequestMapping.class)It keeps the methods that handle requests.
@GetMappingand@PostMappingcarry@RequestMapping, so "meta-annotated" finds them..should().beMetaAnnotatedWith(PreAuthorize.class)Each such method needs
@PreAuthorize, directly or through an annotation such as@OrdersRead.
The service calls the rule in ArchitectureTest.everyEndpointHasAPermission. A
second test proves that the rule fails for code that breaks it:
@RestController
static class ForgetfulController {
@GetMapping("/forgotten")
String forgotten() {
return "anyone with a token can call this";
}
}
// ...
@Test
void anEndpointWithoutAPermissionFailsTheBuild() {
JavaClasses classes = new ClassFileImporter().importClasses(ForgetfulController.class);
assertThatThrownBy(() -> ArchitectureRules.everyEndpointHasAPermission().check(classes))
.isInstanceOf(AssertionError.class)
.hasMessageContaining("forgotten");
}The test expects an AssertionError whose message contains
forgotten. A rule that never fails proves nothing. This test shows that the rule names the endpoint it
catches.
The rule also covers endpoints that seem harmless. The lookup lists of reference data, such
as the delivery methods, need orders.read too. Without the rule, it is easy to leave such an endpoint open to any token.
The rule checks that an annotation exists, not that it is the right one. A write method
with @OrdersRead passes the rule. The pair of 403 and success tests in each
controller test catches that. For ArchUnit and the other rules, see the chapter
Testing in depth.
A local token issuer, and the issuer-versus-keys tricktwist#
A developer needs real tokens on their own machine, for curl, Swagger UI and the end-to-end tests. The local environment runs a token issuer in Docker. It is mock-oauth2-server, version 6.1.3, the latest release as of October 2026:
# Issues JWTs for local calls, for Playwright, and for the services' own client-credentials calls.
mock-oauth2:
image: ghcr.io/navikt/mock-oauth2-server:6.1.3
ports: ["8090:8080"]
environment:
JSON_CONFIG_PATH: /config/config.json
volumes: ["./mock-oauth2:/config"]ports: ["8090:8080"]The server listens on port 8080 in its container. The host reaches it on port 8090.
JSON_CONFIG_PATH: /config/config.jsonThis environment variable names the configuration file, mounted from the folder
mock-oauth2.
The configuration gives each client ID its own subject and scopes. The first mapping gives
agent both scopes. See the section From scopes to
authorities. The second mapping gives read-only one scope:
{
"interactiveLogin": false,
"tokenCallbacks": [
{
"issuerId": "default",
"tokenExpiry": 3600,
"requestMappings": [
// ...
{
"requestParam": "client_id",
"match": "read-only",
"claims": {
"sub": "local-read-only",
"aud": ["order-service"],
"scope": "orders.read"
}
},"issuerId": "default",The issuer's URL ends in
/default."tokenExpiry": 3600,The token is valid for 3,600 seconds, one hour. A run confirmed that the value is in seconds.
"match": "read-only",The mapping applies when the request's
client_idis exactlyread-only. The server also accepts"*"and regular expressions."aud": ["order-service"],The token names the order service as its audience, so it passes the
audiencescheck.
The third mapping is for the order service itself. It gives order-service the
scope stock.reserve and the audience stock-service, for the
client-credentials call. In a run, the server ignored the scope that the client asked for, and
used the mapping's claims. The token's header was typ: JWT, so the token passes
Spring's typ check.
A developer gets a token with curl:
curl -s -X POST localhost:8090/default/token \
-d grant_type=client_credentials -d client_id=agent -d client_secret=any | jq -r .access_tokenThe command asks for a client-credentials token as the client agent. The secret
can be anything. jq -r .access_token prints only the token from the JSON answer.
The token is not a real user's token. Locally, a client ID stands for a kind of user, and that
is enough to test the scopes.
One detail makes the local setup harder. The server puts the URL that the caller used into
the token's iss claim. A run showed it. A token asked for through
localhost had a localhost issuer. A token asked for through
127.0.0.1 had an issuer with that address.
The developer's curl calls localhost:8090. So the token's iss is
http://localhost:8090/default. The order service runs in a container. Inside a
container, localhost is the container itself, so the service cannot fetch keys
from that URL. The local profile solves this with two URLs:
security:
oauth2:
resourceserver:
jwt:
# The mock OAuth2 server puts the URL the caller used into the token's "iss" claim.
# Get tokens from http://localhost:8090/default/token, as curl and Playwright on your
# machine do. The service then checks that issuer, and reads the signing keys over the
# docker network.
issuer-uri: http://localhost:8090/default
jwk-set-uri: http://mock-oauth2:8080/default/jwksThe issuer-uri is the URL that the developer's tools call. The
jwk-set-uri is the URL that the container can reach, by the container name
mock-oauth2.
With both settings, Spring never calls issuer-uri. It only compares the token's
iss with it. It fetches the keys from jwk-set-uri, by the container's
name on the Docker network. A run reproduced the mechanism on one machine, with
localhost and 127.0.0.1 as the two names.
A run of the whole local environment in Docker, on 25 October 2026, confirmed it. A token from
http://localhost:8090/default/token had the iss
http://localhost:8090/default, and the order service container accepted it with
200 and 201. A token asked for at http://127.0.0.1:8090 had another
iss. The container refused it with 401. Its WWW-Authenticate header
said "The iss claim is not valid". So the service compares the issuer and fetches the
keys from the container name, as described.
The run did not use the image ghcr.io/navikt/mock-oauth2-server:6.1.3. The
registry refused the pull with 403 Forbidden on that machine, also without stored credentials.
So the same version ran from its jar on Maven Central, in a Java container under the same name
and port. The compose file keeps the pinned image.
The service's own token request goes to http://mock-oauth2:8080/default/token.
So the token's iss is the container URL. That is fine, because the stock service
stub does not check the issuer. When the service runs in an IDE on the host, the README of the
service overrides jwk-set-uri and the token URI with localhost:8090.
For the whole local environment, see the chapter Running locally and
shipping.
The JwtBearer options of ASP.NET Core have two settings for the same idea.
MetadataAddress names where to fetch the metadata, and
TokenValidationParameters.ValidIssuer names the expected issuer. This book did not
run that pair against the local issuer.
AWS credentials: a role, never a keytwist#
The service publishes to Amazon Simple Notification Service (SNS) and reads from Amazon Simple Queue Service (SQS). Both need AWS credentials. The settings name only the region:
cloud:
aws:
# Credentials come from the task's or pod's IAM role, never from configuration.
region:
static: ${AWS_REGION:eu-west-2}The line static: ${AWS_REGION:eu-west-2} sets the region, with a default. Identity
and Access Management (IAM) is the AWS service that decides who may call which AWS
action. In production, the deployment gives the service's task or pod an IAM role, and the role
holds the permissions. So no access key is in a file, in an environment variable or in the
image. Only the local profile sets keys, the dummy values local, for the AWS
emulator.
Spring Cloud AWS 4.1.0 builds the credentials provider in its class
CredentialsProviderAutoConfiguration. The source shows the choice. Apart from
connection details, which a test can supply, each of these adds a provider:
- a key pair, in
spring.cloud.aws.credentials.access-keyandsecret-key - the instance profile, when
spring.cloud.aws.credentials.instance-profileis true - a named profile, in
spring.cloud.aws.credentials.profile.name - a web identity token file, named in the settings or in the environment variable
AWS_WEB_IDENTITY_TOKEN_FILE. This provider is added only when the SDK'sstsmodule is present.
When none of these applies, the class returns the AWS SDK's
DefaultCredentialsProvider. The comment of that class, in
the SDK version 2.47.4 that the service uses, lists where it looks, in this order:
| Place | What it reads |
|---|---|
| Java system properties | aws.accessKeyId and aws.secretAccessKey |
| environment variables | AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY |
| a web identity token | the token file named in a system property or an environment variable |
| the profile file | ~/.aws/credentials, shared with the AWS command-line tool |
| the container credentials | the credentials that Amazon ECS delivers, when AWS_CONTAINER_CREDENTIALS_RELATIVE_URI is set |
| the instance profile | the credentials from the EC2 instance metadata service |
So the production service, with no credential settings, finds the role of its task or pod
through this chain. The first place that has credentials wins. A forgotten
AWS_ACCESS_KEY_ID in the environment would therefore win over the role.
A web identity token file needs a module that the service does not have.
Both places that can read the file need the SDK's sts module. Spring Cloud AWS adds
its web identity provider only when a class of that module is present. The web identity step of
DefaultCredentialsProvider needs the module too. Without it, the SDK logs that web
identity tokens need the sts module on the
class path. Then the chain goes on to the next place.
The order service's dependencies have no software.amazon.awssdk:sts. So a
deployment that hands the role over as a web identity token file needs that dependency. Without
it, the service never reads the file. The version of sts needs no entry of its own.
The list of versions that Spring Cloud AWS publishes imports the list of versions of the AWS
SDK. The container credentials and the instance profile are in the SDK's auth
module, which the service already has.
The role needs one permission that surprises people:
/**
* Spring Cloud AWS turns this name into the topic's ARN by calling {@code CreateTopic}, which
* returns the ARN of an existing topic. So the service's IAM role needs {@code sns:CreateTopic}
* as well as {@code sns:Publish}, and a typo here silently creates a new, empty topic.
*/
public static final String ORDER_EVENTS = "order-events";An ARN is the full Amazon Resource Name of the topic. Spring Cloud AWS finds it by calling
CreateTopic, which returns the ARN of a topic that exists already. The service's
README lists the role's permissions. For the other way, an ARN in the settings, see the chapter
Publishing events: the outbox.
1An expert asksThe order service checks permissions on its controllers. Why can the payment
listener then record a payment without any identity?
recordPayment directly, on a thread with
an empty security context. Nothing asks for a scope, so no identity is needed. The audit log
records system, which is the truth, because no user acted.2An expert asksA developer puts @OrdersWrite on a use case that a message
listener calls. Then they write a helper that runs the call as a made-up user with every scope.
Why is the helper a warning sign?
@OrdersWrite on a use case that a message
listener calls. Then they write a helper that runs the call as a made-up user with every scope.
Why is the helper a warning sign?3A token's exp was 30 seconds ago. Does Spring Security 7 accept it? Does
ASP.NET Core with the defaults of AddJwtBearer?
exp was 30 seconds ago. Does Spring Security 7 accept it? Does
ASP.NET Core with the defaults of AddJwtBearer?4The stock client adds a client-credentials token with
OAuth2ClientHttpRequestInterceptor. Two agents place orders, one after the other.
How many token requests does the default principal resolver cause, and what does the platform
change?
OAuth2ClientHttpRequestInterceptor. Two agents place orders, one after the other.
How many token requests does the default principal resolver cause, and what does the platform
change?setPrincipalResolver with a fixed
principal for the service. Then all callers share one token.
StockAdapterTest.twoDifferentUsersShareOneServiceToken checks it.5TranslateIn C#, this code reads the caller inside work that runs on another
thread. It works, because the caller flows into Task.Run:
string seen = await Task.Run(() => currentActor.Id());
Write the Java for work on a virtual thread from an ExecutorService, so that the
work sees the caller.
Task.Run:
string seen = await Task.Run(() => currentActor.Id());ExecutorService, so that the
work sees the caller.try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
String actor = currentActor.id();
Future<String> actorSeenByTheWork = executor.submit(() -> actor);
// ...
}Read the actor on the request's thread, and give the work the plain value. A call of
currentActor.id() inside the work would answer system, because the
security context stays on its own thread. Wrap the executor in
DelegatingSecurityContextExecutorService only when the work must check permissions
itself.
Quick reference#
The endpoints of the order service, and the scope each one needs:
| Method and path | Scope | Success |
|---|---|---|
| POST /orders | orders.write | 201, with the first notes of the order |
| GET /orders?status=&page=&size= | orders.read | 200, one page of orders |
| GET /orders/{orderId} | orders.read | 200, the order |
| POST /orders/{orderId}/lines/{lineId}/pick | orders.write | 204, after the line is picked |
| POST /orders/{orderId}/ship | orders.write | 204, after the order is shipped |
| POST /orders/{orderId}/cancel | orders.write | 204, after the order is cancelled |
| POST /orders/{orderId}/notes | orders.write | 201, with the ID of the new note |
| GET /orders/{orderId}/notes | orders.read | 200, one page of notes |
| POST /orders/{orderId}/notes/{noteId}/resolve | orders.write | 204, after the action is resolved |
| GET /reference-data/{type} | orders.read | 200, the values of one lookup list |
The settings of the resource server and the client, as Boot 4.1.2 reads them:
| Setting | Default | What it does |
|---|---|---|
| spring.security.oauth2.resourceserver.jwt.issuer-uri | none | finds the keys by discovery, and checks the iss claim |
| spring.security.oauth2.resourceserver.jwt.jwk-set-uri | none | fetches the keys from this URL, with no discovery |
| spring.security.oauth2.resourceserver.jwt.audiences | an empty list | when set, the aud claim must share one entry with this list |
| spring.security.oauth2.resourceserver.jwt.authority-prefix | SCOPE_ | the prefix that each scope gets as an authority |
| spring.security.oauth2.resourceserver.jwt.authorities-claim-name | scope, then scp | the claim that holds the authorities |
| spring.security.oauth2.resourceserver.jwt.authorities-claim-delimiter | a space | the separator inside a string claim |
| spring.security.oauth2.resourceserver.jwt.authorities-claim-expressions | none | SpEL expressions that read authorities, and they cannot be combined with the two settings above |
| spring.security.oauth2.resourceserver.jwt.principal-claim-name | sub | the claim that getName() returns |
| spring.security.oauth2.client.registration.{id}.authorization-grant-type | none | client_credentials, for a service's own token |
| spring.security.oauth2.client.provider.{id}.token-uri | none | the issuer's token endpoint |
| platform.security.public-paths | /actuator/health/**, /actuator/info | the paths that need no token |
The filters of a stateless resource server chain in Spring Security 7.1.2, in order, as a run printed them:
| Position | Filter | Note |
|---|---|---|
| 1 | DisableEncodeUrlFilter | in every chain, and the service does not configure it |
| 2 | WebAsyncManagerIntegrationFilter | in every chain, and the service does not configure it |
| 3 | SecurityContextHolderFilter | gives the request a security context, and clears it at the end |
| 4 | HeaderWriterFilter | adds protective headers, and CsrfFilter follows it when CSRF is on |
| 5 | LogoutFilter | in the chain by default, though the service has no logout |
| 6 | OAuth2ProtectedResourceMetadataFilter | new, and it belongs to the metadata of RFC 9728 |
| 7 | BearerTokenAuthenticationFilter | reads and checks the bearer token |
| 8 | RequestCacheAwareFilter | in the chain by default, and STATELESS gives it a cache that stores nothing |
| 9 | SecurityContextHolderAwareRequestFilter | in the chain by default, and the service does not use it |
| 10 | AnonymousAuthenticationFilter | puts an anonymous authentication into a context without a caller |
| 11 | SessionManagementFilter | missing in the run without STATELESS |
| 12 | ExceptionTranslationFilter | turns a missing login into 401, and a refusal into 403 |
| 13 | AuthorizationFilter | checks the URL rules, and it is last by default |
Storing aggregates in DocumentDB#
The order service stores each order as one document, with its lines inside. Notes have a
collection of their own. The order feature keeps its stored shape in a separate document class
with a mapper. The note feature stores the entity itself. This chapter covers both styles,
optimistic locking with @Version, the types to store, the queries, and the
connection to Amazon DocumentDB.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | no equivalent | one document for each order, with its lines and its last payment inside | the aggregate is what a use case loads and saves, so one read returns all of it Why |
| twist | a separate collection for notes, read with its own Find | the collection notes, where each note holds its order's ID | notes have no limit, and embedding them would rewrite the whole order for each new note Why |
| twist | a class with [BsonElement] attributes on its properties | OrderDocument, a package-private class with package-private fields | the full style keeps the stored shape inside the persistence package, apart from the domain Why |
| twist | a hand-written method that copies a document into an entity | OrderDocumentMapper implements DocumentMapper<Order, OrderDocument> | loading goes through Order.rehydrate, so it applies no rules and raises no events Why |
| new | no equivalent | OrderPersistenceAdapter extends MongoAggregateRepository | the platform's base class implements findById, save and exists, so the adapter adds only its own queries Why |
| twist | an entity class that the driver maps directly | Note with @Document, @PersistenceCreator and a @Transient list of events | the light style stores the entity itself, and the service uses the Spring Data repository directly Why |
| twist | an enum, stored as a number by default | the status, stored as the string "PAID" | a string stays readable in the database, and the enum class can be renamed freely Why |
| twist | [BsonRepresentation(BsonType.Decimal128)] on a decimal | @Field(targetType = FieldType.DECIMAL128) on a BigDecimal | the amount stays exact, and the platform also sets this representation for every BigDecimal Why |
| twist | a property named Id in a nested class, stored as _id | a field named id in an embedded class, stored as _id | Spring rewrites only its own queries, so the shop names the field lineId Why |
| twist | ReplaceOneAsync with a filter on Id and Version, then a check of MatchedCount | @Version private Long version | Spring Data adds the version to the filter itself, and throws when nothing matched Why |
| trap | order.Version = expected + 1 on the same object, before ReplaceOneAsync | the order passed to save keeps its old version | a second save of that object fails, as an insert with a duplicate key or as a conflict Why |
| trap | catch DbUpdateConcurrencyException for every conflict | OptimisticLockingFailureException, or a write conflict as DataIntegrityViolationException | inside a transaction, the conflict usually arrives as a write conflict, so the platform answers both with 409 Why |
| twist | Find(o => o.Status == status) | Page<OrderDocument> findByStatus(String status, Pageable pageable) | Spring Data builds the query from the method name when the application starts Why |
| twist | Skip and Limit, then CountDocumentsAsync | a Pageable in, and a Page out | Spring Data sends the count query only when it needs it, and never on the last page Why |
| twist | UpdateOneAsync with Builders<T>.Update.Set | MongoTemplate with Criteria, Update and org.bson.Document | for writes that do not store an aggregate, such as the inbox key and the audit record Why |
| new | no equivalent | a missing field handled on load, or a migration that writes a default | stored documents keep their old shape, so every change of shape needs a plan Why |
| trap | no equivalent | spring.mongodb.uri, not spring.data.mongodb.uri | Boot 4 ignores the old key without a warning, and connects to localhost instead Why |
| trap | readPreference=secondaryPreferred | replicaSet=rs0&readPreference=primary | the service often reads its own writes, so it reads from the primary instance Why |
| twist | settings.RetryWrites = false | a MongoClientSettingsBuilderCustomizer that calls retryWrites(false) | it runs after the connection string is applied, so a copied URI cannot switch retryable writes on again Why |
| trap | tlsCAFile in the connection string | an SSLContext built from the CA bundle file | the Java driver ignores tlsCAFile, so the platform sets the trusted certificates in code Why |
| trap | tests against a MongoDB container only | a MongoDB container for most tests, and one test stage against a real cluster | the container accepts features that DocumentDB lacks, as of October 2026 Why |
| trap | no equivalent | new MongoDBContainer(image).withReplicaSet() from org.testcontainers.mongodb | the new class starts no replica set by default, so a changed import turns off transactions in tests Why |
| new | no equivalent | two copies of one order loaded, both changed, and the second save fails | every aggregate gets this test of optimistic locking Why |
One aggregate, one documentnew#
An agent opens an order. The service must check the shipping rule, so it needs the order, every line and the last payment. With a relational database, that is three tables and a join. In the order service, it is one read of one document.
The rule of the service is short. One aggregate is stored as one document. The aggregate is the unit that a use case loads, changes and saves. So the document holds exactly the data that the rules of the aggregate need. A read returns the whole aggregate, and a write replaces the whole aggregate. For what an aggregate is, see the chapter Finding the aggregates. For documents and collections in general, see the chapter Document data: Amazon DocumentDB and the MongoDB API.
The service and its platform library write to nine collections. A collection has the same purpose as a table in a relational database. Each collection has one owner, and only the owner writes to it:
| Collection | Owner | What one document holds |
|---|---|---|
| orders | the order feature | one order, with its lines and its last payment |
| notes | the note feature | one note, with the ID of its order |
| reference_data | the reference feature | one lookup value, such as the delivery method EXPRESS |
| audit_log | the platform's audit log | one record of who changed what, and when |
| event_publication | Spring Modulith, the outbox | one event for one listener, until it is sent |
| processed_messages | the platform's inbox | the key of one event that was processed |
| schema_migrations | the platform's migration runner | the ID of one migration that ran |
| schema_migrations_lock | the platform's migration runner | the lock, while one instance migrates |
| scheduler_locks | ShedLock, for the outbox resender | the lock of one scheduled job |
Only the first three collections hold aggregates or lookup data. The others belong to the platform. For the outbox, see the chapter Publishing events: the outbox. For the inbox, see the chapter Consuming events: SQS and the inbox. For the migration runner, see the chapter Migrations, indexes and seed data.
The service writes no code to connect. The platform library depends on one starter:
<!-- DocumentDB, through the MongoDB API -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>With spring-boot-starter-data-mongodb on the classpath, Spring Boot creates a
MongoTemplate bean. MongoTemplate
is the class that sends commands to the database. The Spring Data repositories use it too. The
platform adds only what DocumentDB needs on top. For that part, see the section
Driver settings that DocumentDB needs.
Embed the lines, keep the notes aparttwist#
An order has lines and notes. Both belong to one order. The service embeds the lines in the order's document, and stores the notes in a collection of their own. The document class says why in its comment:
/**
* How an order is stored: one document per aggregate, with its lines and last payment embedded.
*
* <p>The lines are embedded because they are few (at most 50) and every order rule needs them.
* Notes are not: they have no limit, and embedding them would rewrite the whole order for each
* new note. A document must stay under 16 MB.
*
* <p>Only plain types are stored. Enums and codes are strings, so renaming a Java class never
* breaks stored data. Money is DECIMAL128, so it is exact. The class and its fields are
* package-private: nothing outside this package knows the storage shape.
*/The comment gives the reasons in short. It names two limits, 50 lines for an order and 16 MB
for the whole document. It also says why money is DECIMAL128. Three questions decide
whether to embed something:
| Question | Lines | Notes |
|---|---|---|
| Do the rules of the order need it? | yes, picking and shipping check every line | no, only the count of open actions matters |
| Is the number limited? | yes, at most 50 lines, checked by Order.place | no, an agent can add notes for years |
| Does it change on its own? | no, a line changes only through the order | yes, an agent adds and resolves notes without changing the order |
Embed what passes all three questions. A document on Amazon DocumentDB may hold at most 16 MiB, as of October 2026. Fifty lines are far below that limit. An unlimited list of notes is not.
A save replaces the whole document. So with embedded notes, each new note would rewrite the order and every earlier note. The note would also take the order's version. An agent who adds a note would then conflict with a warehouse worker who picks a line at the same moment.
Embedding the notes has a careful version, and it has real strengths. One read returns the order with its notes. One write stores both, with no transaction. A limit on notes, such as 100, would keep the document small. But the strengths do not help the order service. The shipping rule needs only a count, which an indexed query returns. The notes would still share the order's version, so unrelated work would conflict. Embed notes when they are few, limited, and always read and changed with the order. For why the note is an aggregate of its own, see the section Why order and note are separate aggregates.
The MongoDB .NET driver embeds a nested object or a list in the same way. For the basic rule, see the section Embed or reference.
The full style: a document classtwist#
The order feature uses the full style. The domain class Order holds the rules
and knows nothing about storage. A second class, OrderDocument, holds what is
stored:
@Document("orders")
class OrderDocument extends BaseDocument {
String customerName;
String customerEmail;
String deliveryMethod;
String status;
Instant placedAt;
Instant shippedAt;
Instant cancelledAt;
List<LineDocument> lines = new ArrayList<>();
PaymentDocument lastPayment;
// ...
static class PaymentDocument {
String reference;
@Field(targetType = FieldType.DECIMAL128)
BigDecimal amount;
String currency;
Instant paidAt;
}
}@Document("orders")This annotation from Spring Data MongoDB names the collection. Each
OrderDocumentis one document inorders.class OrderDocument extends BaseDocument {The class has no access modifier, so it is package-private. Only the classes of the persistence package can use it.
BaseDocumentis the platform's base class. It adds the ID and the version. See the section Optimistic locking with @Version.String customerName;The fields are package-private too, and they have no getters or setters. Spring Data reads and writes fields directly. So the mapper in the same package can set them, and nothing else can. Each field is stored under its own name, such as
customerName.String status;The status is a
String, not the enumOrderStatus. See the section The types to store.List<LineDocument> lines = new ArrayList<>();A list of nested objects becomes an array of embedded documents. The class
LineDocumentis shown in the section An embedded field named id.static class PaymentDocument {A
staticnested class does not need an instance of the outer class. Spring Data can create it on its own when it reads a document.
The stored shape and the domain can now change apart from each other. The domain can rename
a field, and the stored name stays the same. The domain keeps its Money type with
a currency, and the document stores two plain values. The cost is a second class and a mapper
for every aggregate.
An architecture rule keeps every @Document class in a persistence package or a
model package. The model package is the home of a light-style entity. For the rule and the
three levels, see the chapter How much DDD: three levels in one service.
In C#, the MongoDB driver maps a class with attributes such as [BsonElement] on its
properties. The same split works in C#, with a document class in an infrastructure project. In
Java, the package-private class keeps the document inside its package.
The mapper: from the aggregate to the document and backtwist#
The platform defines the shape of every mapper. A mapper converts one aggregate into one document, and back:
/**
* Converts between an aggregate and its stored document.
*
* <p>{@link #toDocument} copies the aggregate's data; {@link MongoAggregateRepository} sets the
* ID and the version. {@link #toDomain} rebuilds the aggregate with its {@code rehydrate}
* factory, passing {@code getId()} and {@code getVersion()}, so loading applies no rules and
* raises no events.
*
* @param <A> the aggregate
* @param <D> the document
*/
public interface DocumentMapper<A, D extends BaseDocument> {
D toDocument(A aggregate);
A toDomain(D document);
}D extends BaseDocument limits the type parameter. A mapper can only produce a
document that has an ID and a version. In C#, the same limit is written
where D : BaseDocument.
The order's mapper implements both directions:
@Component
class OrderDocumentMapper implements DocumentMapper<Order, OrderDocument> {
@Override
public OrderDocument toDocument(Order order) {
OrderDocument document = new OrderDocument();
document.customerName = order.customer().name();
document.customerEmail = order.customer().email();
document.deliveryMethod = order.deliveryMethod().code();
document.status = order.status().name();
document.placedAt = order.placedAt();
document.shippedAt = order.shippedAt();
document.cancelledAt = order.cancelledAt();
document.lines = order.lines().stream().map(OrderDocumentMapper::lineDocument).toList();
document.lastPayment = paymentDocument(order.lastPayment());
return document;
}
@Override
public Order toDomain(OrderDocument document) {
return Order.rehydrate(
OrderId.of(document.getId()),
document.getVersion(),
new CustomerContact(document.customerName, document.customerEmail),
new DeliveryMethod(document.deliveryMethod),
document.placedAt,
document.lines.stream().map(OrderDocumentMapper::line).toList(),
OrderStatus.valueOf(document.status),
payment(document.lastPayment),
document.shippedAt,
document.cancelledAt);
}@ComponentThe mapper is a bean, so Spring injects it into the adapter. It is package-private, like the document.
document.status = order.status().name();name()returns the name of the enum constant, such asPAID. Every Java enum has this method.OrderStatus.valueOf(document.status),valueOfturns the stored name back into the constant. An unknown name throwsIllegalArgumentException. So renaming an enum constant needs a migration of the stored data.return Order.rehydrate(rehydrateis the factory for a stored order. It applies no rules and raises no events, because loading is not a business action. New orders come fromOrder.place, which applies the rules. For both factories, see the chapter The domain model in plain Java.document.getVersion(),The mapper passes the stored version into the aggregate. It never sets the ID or the version of a document. The platform's base class does that. See the section The adapter and the platform's base class.
The mapper handles a missing payment too. An order that is not paid stores no
lastPayment, so the field is null when the order is read:
private static Payment payment(OrderDocument.PaymentDocument document) {
return document == null ? null : new Payment(new PaymentReference(document.reference),
new Money(document.amount, document.currency), document.paidAt);
}payment returns null for a missing payment, and the domain accepts an
order with no payment. The developer writes the whole mapper. A mapping library, such as
MapStruct, could generate the copying of fields. The
service does not use one, because the domain is built through a factory with checked value
objects, not through setters. A hand-written mapper shows each conversion where a reviewer
reads it.
The adapter and the platform's base classnew#
A use case loads an order through the port OrderRepository. The port is a
plain Java interface in the domain. It names findById, save and
exists, plus the order's own queries. Something must implement the port with
MongoDB. That class is called the persistence adapter. Without help, every aggregate's adapter
would write the same load and save code. So the platform writes it once:
public abstract class MongoAggregateRepository<A extends AggregateRoot<ID>, ID extends Identifier, D extends BaseDocument>
implements Repository<A, ID> {
protected final MongoRepository<D, String> documents;
protected final DocumentMapper<A, D> mapper;
protected MongoAggregateRepository(MongoRepository<D, String> documents, DocumentMapper<A, D> mapper) {
this.documents = documents;
this.mapper = mapper;
}
@Override
public Optional<A> findById(ID id) {
return documents.findById(id.value()).map(mapper::toDomain);
}
/**
* Saves the aggregate and returns it rebuilt from the stored document, with the new version.
*
* <p>The version is copied from the aggregate into the document. Without that copy, every
* save would be sent as an insert. The service assigns the IDs, so the insert of a stored
* aggregate would fail with {@code DuplicateKeyException} instead of a version conflict.
*/
@Override
public A save(A aggregate) {
D document = mapper.toDocument(aggregate);
document.setId(aggregate.id().value());
document.setVersion(aggregate.version());
return mapper.toDomain(documents.save(document));
}
@Override
public boolean exists(ID id) {
return documents.existsById(id.value());
}public abstract class MongoAggregateRepository<A extends AggregateRoot<ID>, ID extends Identifier, D extends BaseDocument>The class has three type parameters.
Ais the aggregate root,IDits typed identifier, andDits document. The limits make sure that each one has what the class needs, such as a version.protected final MongoRepository<D, String> documents;This is a Spring Data repository for the document. Its ID type is
String, because the typedOrderIdis stored as its text.return documents.findById(id.value()).map(mapper::toDomain);findByIdreturns an Optional.mapconverts the document into an aggregate only when one was found.mapper::toDomainis a method reference to the mapper's method.document.setVersion(aggregate.version());The base class copies the ID and the version from the aggregate into the new document. So Spring Data knows whether to insert or to update, and which version to check.
return mapper.toDomain(documents.save(document));documents.savewrites the document and sets its new version. The base class then rebuilds the aggregate from that document. So the caller gets a newOrderwith the new version.
The comment above save says what a missing copy of the version would do. The
order's ID is assigned by the service, so a save with no version is an insert of an existing ID.
That insert fails with a duplicate key, not with a version conflict. The section
The order that was passed to save shows the run. Only an aggregate
whose ID the database generates would get a second document without an error.
The order's adapter extends the base class, and adds the two list queries of the port:
@Component
class OrderPersistenceAdapter extends MongoAggregateRepository<Order, OrderId, OrderDocument> implements OrderRepository {
private static final Sort NEWEST_FIRST = Sort.by(Sort.Direction.DESC, "placedAt");
private final OrderMongoRepository orderDocuments;
OrderPersistenceAdapter(OrderMongoRepository orderDocuments, OrderDocumentMapper mapper) {
super(orderDocuments, mapper);
this.orderDocuments = orderDocuments;
}
@Override
public PageResult<Order> findAll(PageQuery page) {
return toPageResult(orderDocuments.findAll(pageable(page, NEWEST_FIRST)));
}
@Override
public PageResult<Order> findByStatus(OrderStatus status, PageQuery page) {
return toPageResult(orderDocuments.findByStatus(status.name(), pageable(page, NEWEST_FIRST)));
}
}class OrderPersistenceAdapter extends MongoAggregateRepository<Order, OrderId, OrderDocument> implements OrderRepository {The adapter inherits load, save and exists, and implements the port. It is package-private, so a use case can only reach it through
OrderRepository.private static final Sort NEWEST_FIRST = Sort.by(Sort.Direction.DESC, "placedAt");Sortis Spring Data's description of a sort order. Every list query names its order, because Amazon DocumentDB guarantees no order of results without one, as of October 2026.super(orderDocuments, mapper);The constructor passes the Spring Data repository and the mapper to the base class. It also keeps the repository with its own type, because only
OrderMongoRepositoryhasfindByStatus.
The port declares only what a use case calls. It has no saveAll, because no use
case saves several orders at once. A method that no one calls still needs code, tests and care
in every adapter. The same rule keeps the light style small. Its repository declares only the
two queries that the note service uses.
The same thinking applies to an annotation that repeats a default. The stock client's response
record keeps @JsonIgnoreProperties(ignoreUnknown = true), although the JSON setup of
Spring Boot already ignores unknown fields. It stays, because it states the rule where a reader
looks. For that record, see the chapter Calling other services.
The same base class can be written in C#, with generics and constraints. The important part is the mapper. The base class never stores the aggregate itself. It stores a document that a mapper built.
The light style: the note is its own documenttwist#
The note feature uses the light style. The class Note holds the rules, and it
is also the stored document. It carries the Spring Data annotations itself. Its constructor is
marked @PersistenceCreator, and its list of events is @Transient. For the class and each annotation, see the section
The light level: the note feature.
Storage needs four things from a light-style entity:
| Need | How Note meets it | Why storage needs it |
|---|---|---|
| a way to create a stored note | the constructor marked @PersistenceCreator | Spring Data calls it with the stored values, and it applies no rules |
| a version that can change | @Version private Long version, not final | Spring Data writes the new version into the object after each save |
| nothing stored that is not data | @Transient on the list of events | the events are published after the save, and never stored |
| a stable stored name for each field | the field names, which are the stored names | renaming a field of the entity renames the stored field |
The last row is the price of the light style. In the full style, a field of the domain can
be renamed freely, because the document keeps its own names. In the light style, a renamed
field stores new documents under the new name, and reads old documents with the field missing.
Keep the stored name with @Field("oldName") on the renamed field.
@Field stores a field under another name. For the annotation, see the section
What the stored document holds.
A test checks that the events are not stored:
@Test
void storesTheNoteButNotItsEvents() {
Note note = Note.create(ORDER_ID, "Call back", "DELIVERY", true, "agent-1", CLOCK); // one event pending
notes.save(note);
Document stored = mongo.findById(note.id(), Document.class, "notes");
assertThat(stored).doesNotContainKey("events").containsEntry("orderId", ORDER_ID);
Note loaded = notes.findById(note.id()).orElseThrow();
assertThat(loaded.pullEvents()).isEmpty();
assertThat(loaded.needsAction()).isTrue();
}Document stored = mongo.findById(note.id(), Document.class, "notes");This line reads the stored note as a raw
org.bson.Document, not as aNote. ADocumentis a map of field names to values. So the test sees exactly what is in the database.assertThat(stored).doesNotContainKey("events").containsEntry("orderId", ORDER_ID);The stored note has no field
events. It does have the order's ID.assertThat(loaded.pullEvents()).isEmpty();A note that is loaded has no pending events. Only a change raises events.
The note service injects the Spring Data repository NoteRepository directly. It
has no port and no adapter. So the service can call every method that Spring Data offers, such
as deleteAll. The light style accepts that risk in exchange for less code. Code
review guards it.
The reference feature goes one step further. ReferenceItemDocument is a
record with its own @Id, and it does not extend
BaseDocument. Lookup values are only read by the service, and the migrations write
them. Nothing can change a lookup value at the same time, so it needs no version. For that
level, see the section The simple level: the reference feature.
The types to storetwist#
A document outlives the code that wrote it. In a year, a new version of the service reads documents that the old version wrote. Another team may read them with a script. So the service stores plain types whose meaning does not depend on a Java class:
| Value in the domain | Stored as | Why |
|---|---|---|
| OrderStatus, an enum | a string, such as "PAID" | the value is readable, and the enum class can move or be renamed |
| DeliveryMethod and ProductCode, value objects | a string, the code | the code is the business value, and the Java wrapper is not stored |
| OrderId, LineId and PaymentReference | a string | the ID is text in every service that refers to it |
| Money | a Decimal128 amount and a string currency | the amount stays exact, and the currency stays with it |
| Instant, a point in time | a BSON date | a BSON date is a point in time in UTC, so it matches Instant |
| a calendar date, such as a birthday | an ISO string, such as "2026-10-25" | a calendar date has no time zone, so it must not become a point in time |
BSON is the binary format of MongoDB documents. It has types that JSON lacks, such as dates and Decimal128.
The enum is stored by its name. The MongoDB .NET driver stores a C# enum as a number by
default. In a run for this book, it stored "Status": 0. A number breaks when
someone reorders the constants. A name breaks only when someone renames a constant, and then
valueOf fails loudly.
Money uses Decimal128, which keeps every decimal digit exactly. A double cannot
hold 0.1 exactly. The document says so on its field, with
@Field(targetType = FieldType.DECIMAL128). The platform also sets the
representation for every BigDecimal, and for every
universally unique identifier (UUID):
# How UUIDs are stored. The driver has no default, so writing a UUID fails without this.
# Spring Modulith's outbox stores each publication's ID as a UUID.
spring.mongodb.representation.uuid=standard
# Store every BigDecimal as DECIMAL128, so money stays exact even where a field has no
# @Field(targetType = DECIMAL128), for example in an event stored in the outbox.
spring.data.mongodb.representation.big-decimal=decimal128spring.mongodb.representation.uuid=standardA UUID can be stored in more than one binary form, and
standardnames one of them. The service's own IDs are strings. The outbox of Spring Modulith stores real UUIDs. Spring Boot's upgrade notes for Boot 4 say that an explicit configuration is expected for both representations.spring.data.mongodb.representation.big-decimal=decimal128Boot 4 sets no representation for a
BigDecimal, and a run stored it as a Decimal128 anyway. The platform states the choice, so a later default cannot change it. An event in the outbox has no@Field, so only this setting covers its amounts.
A run for this book checked the comment. It used Boot 4.1.2 with the MongoDB driver 5.8.1,
and no representation set. It inserted a record with a UUID field through
MongoTemplate. Then it inserted a raw Document with a UUID value. Both
inserts failed before anything reached the database:
org.bson.codecs.configuration.CodecConfigurationException: The uuidRepresentation has not been specified, so the UUID cannot be encoded.#spring.mongodb.representation.uuid=standard, as the platform does. Use
another value only for data that older code wrote in another form.So the platform's setting is not a detail. Without it, the outbox could not store its first
event, because each publication has a UUID as its ID.
Boot 3 stored a BigDecimal as a string. For what an upgrade from Boot 3 means
for stored amounts, see the section Money: how a BigDecimal is
stored.
A calendar date needs care. A run for this book stored the LocalDate
3 October 2026 as 2026-10-02T23:00:00Z, on a machine in British summer time. Spring
Data converted the date through the time zone of the machine. Another machine, in another time
zone, would read it as another date. So store a LocalDate as its ISO text, with
date.toString(), and read it with LocalDate.parse(text). In the full
style, the mapper does that, and the document has a String field. In the light
style, the entity keeps the String field and returns a LocalDate from
its method. The order has no calendar date, so the companion has no code for it.
An Instant has no such problem. It is a point in time, and a BSON date is one
too. The order stores placedAt and paidAt as dates, so queries can
sort and compare them.
An embedded field named idtwist#
Each line of an order has an ID, because warehouse staff pick one line at a time. The
obvious name for that field is id. The service names it lineId
instead:
/**
* An embedded line. Its ID field is named {@code lineId}, not {@code id}: Spring Data maps a
* property named {@code id} to {@code _id}, even in an embedded document, so queries written
* outside Spring Data, and tools such as mongosh, would not find {@code lines.id}.
*/
static class LineDocument {
String lineId;
String productCode;
int quantity;
Instant pickedAt;
}Spring Data stores a field named id as _id, also inside an embedded
document. The MongoDB .NET driver does the same with a property named Id of a
nested class. A run for this book measured what this means, with a line class whose field was
named id:
| Who asks | The query | Found the order |
|---|---|---|
| Spring Data, with where("lines.id").is("L1") | sent {"lines._id": "L1"} | yes |
| Spring Data, the derived findByLinesId("L1") | sent {"lines._id": "L1"} | yes |
| the raw driver, or mongosh, or another service | {lines.id: "L1"} | no, the count was 0 |
| a document written by another program as {lines: [{id: "L9"}]} | read through Spring Data | the field was dropped, and the line's id was null |
| a line id of 24 hexadecimal characters | stored as an ObjectId, not as a string | Spring's own queries match, but a query with the string does not |
So Spring's own code works, and everything else fails without an error. That includes an
index that a migration names directly as lines.id. An
ObjectId is the binary ID type of MongoDB.
There are two fixes. Give the field another name, as the shop does with lineId.
Or keep the name and write @Field("id") on it. The run tested
it on a list named marks. With @Field("id"), the field was stored as
id. The query of Spring Data then used marks.id. The shop prefers the
other name, because it reads the same in Java, in the database and in every tool. Spring Data's reference does not mention the rule for embedded
documents. Only the runs show it.
A test checks the stored shape against a real MongoDB:
@Test
void storesPlainTypesAndNamesTheLineIdLineId() {
Order order = newOrder();
order.recordPayment(payment(NOW));
orders.save(order);
Document stored = mongo.findById(order.id().value(), Document.class, "orders");
assertThat(stored.getList("lines", Document.class).getFirst()).containsKey("lineId").doesNotContainKey("_id");
assertThat(stored.get("lastPayment", Document.class).get("amount")).isInstanceOf(Decimal128.class);
assertThat(stored.getString("status")).isEqualTo("PAID");
}stored.getList("lines", Document.class).getFirst()The stored lines are a list of embedded documents.
getFirst()returns the first element of a list. It is new in Java 21..containsKey("lineId").doesNotContainKey("_id");The line stores
lineId, and no_id..isInstanceOf(Decimal128.class);The amount is a Decimal128, not a string and not a
double.
Optimistic locking with @Versiontwist#
Two warehouse workers open the same order. The first picks a line and saves. The second cancels the order a moment later, with the copy that they loaded before the pick. Without a check, the second save would write the old line status back, and the pick would be lost. This is called a lost update.
The fix is called optimistic locking. Each document carries a version number. A save succeeds only if the stored version is still the one that was loaded. Otherwise someone else saved in between, and the save fails. In .NET with the MongoDB driver, you write this check yourself. In Spring Data, one annotation does it:
long expected = order.Version;
order.Version = expected + 1;
ReplaceOneResult result = await orders.ReplaceOneAsync(
o => o.Id == order.Id && o.Version == expected, order);
if (result.MatchedCount == 0)
{
throw new InvalidOperationException("Someone else changed the order.");
}public abstract class BaseDocument {
@Id
private String id;
@Version
private Long version;o => o.Id == order.Id && o.Version == expected, order);The C# filter matches the document only when its version is still the expected one. A run for this book showed what happens with a stale copy. Nothing threw, and the result said
matched=0 modified=0. So the C# code must checkMatchedCountitself.@IdThis
@Idis fromorg.springframework.data.annotation. It maps the field to_id.@VersionThis annotation is from the same package. Spring Data adds the version to the filter of every update, and raises it by one. When nothing matches, Spring Data throws
OptimisticLockingFailureException.private Long version;The type is the wrapper
Long, not the primitivelong. Anullversion means "never saved". The table below shows why that matters.
The closest match in .NET is the MongoDB EF Core Provider. It supports
[ConcurrencyCheck] and row versioning with [Timestamp]. On a
conflict, the SaveChanges method of EF Core throws
DbUpdateConcurrencyException. The plain driver has no
version support in its documentation, as of October 2026.
A run for this book measured what Spring Data MongoDB 5.1.2 does with a version field:
| Case | What Spring Data sent | The result |
|---|---|---|
| a Long version that is null | an insert | stored with version 0, even when the ID was already set |
| a primitive long version | an insert | stored with version 1, because 0 counts as never saved |
| a version that is set | a full replacement, filtered on _id and the old version | the version went up by one, and there was no $set |
| a stale version | the same replacement, which matched nothing | OptimisticLockingFailureException, and the stored document was unchanged |
| a mutable class | save | the same object came back, with the new version set on it |
| a record, or a class with final fields and no setters | save | a new object came back, and the object passed in kept its old version |
| a failed save of a mutable object | save | the object's version was raised before the update was sent |
The exception in the run said Cannot save entity m-1 with version 2 to collection
v_mutable; Has it been modified meanwhile. The message names version 2, although the
filter asked for version 1. Spring Data had already raised the version on the object.
org.springframework.dao.OptimisticLockingFailureException: Cannot save entity m-1 with version 2 to collection v_mutable; Has it been modified meanwhile#The last row of the table is the trap of the light style. The note service saves a mutable
Note. After a failed save, the note already holds the newer version. A second save
of the same object would pass the check and overwrite the other change. For that trap and its
test, see the section Versions after a save.
The full style is safe from this trap. The mapper builds a new OrderDocument
for every save. A failed save raises the version of that document, and the document is
thrown away. The Order keeps its old version, because the field is
final. A retry with the same order sends the old version again, and fails again. It
never overwrites.
The order that was passed to savetrap#
A use case saves an order, and then changes it again. The repository's
save returns a new Order. A C# developer is used to an entity that the
save updates in place.
Order original = newOrder();
Order saved = orders.save(original);
saved.pickLine(saved.lines().getFirst().id(), CLOCK);
Order savedAgain = orders.save(saved);Show the answer
DuplicateKeyException.The order passed to save keeps the version it had. original
was never saved before, so its version is null. The version field is
final, so the save cannot change it. The base class copies null into
the document. Spring Data sees no version and sends an insert. The order's
_id exists already, so the insert fails with error 11000.
Continue with the order that save returns, as saved does here.
With an ID from the database, the second insert would succeed silently, as a second
document.
The test is OrderPersistenceAdapterIT.saveReturnsTheStoredCopyWithTheNewVersion.
It also checks that saved has version 0, and savedAgain version 1.
The failure depends on the version of the stale order. An order that was never saved has the
version null, so its second save is an insert, and it fails with a duplicate key.
An order that was loaded has a version, so its second save is an update with the old version,
and it fails with OptimisticLockingFailureException. Either way, nothing is
overwritten. The service's use cases continue with the copy that save returns. For
that rule in the use cases, see the section Continue with the saved
copy.
org.springframework.dao.DuplicateKeyException: ... E11000 duplicate key error collection: research.v_mutable index: _id_ dup key: { _id: "m-1" }#save returned. Check that the mapper or the base
class copies the version into the document.A conflict inside a transactiontrap#
Most use cases of the order service run in a transaction. Inside a transaction, MongoDB usually finds the conflict before Spring Data checks the version. A test shows the second shape of the same conflict:
/**
* Inside transactions, the same conflict shows up earlier and differently: MongoDB aborts the
* second writer with a write conflict, which Spring translates to
* {@code DataIntegrityViolationException}. The platform answers both with 409.
*/
@Test
void aConcurrentUpdateInsideTransactionsIsAWriteConflict() throws Exception {
Order saved = orders.save(newOrder());
TransactionTemplate transactions = new TransactionTemplate(transactionManager);
CountDownLatch firstHasWritten = new CountDownLatch(1);
CountDownLatch secondHasTried = new CountDownLatch(1);
CompletableFuture<Void> first = CompletableFuture.runAsync(() -> transactions.executeWithoutResult(status -> {
Order copy = orders.get(saved.id());
copy.cancel(CLOCK);
orders.save(copy);
firstHasWritten.countDown();
await(secondHasTried); // keep the transaction open while the second one writes
}));
firstHasWritten.await(10, TimeUnit.SECONDS);
Throwable secondError = null;
try {
transactions.executeWithoutResult(status -> {
Order copy = orders.get(saved.id());
copy.pickLine(copy.lines().getFirst().id(), CLOCK);
orders.save(copy);
});
} catch (RuntimeException e) {
secondError = e;
} finally {
secondHasTried.countDown();
}
first.get(10, TimeUnit.SECONDS);
assertThat(secondError).isNotNull();
assertThat(WriteConflicts.isTranslatedWriteConflict(secondError)).as(String.valueOf(secondError)).isTrue();
assertThat(orders.get(saved.id()).status()).isEqualTo(OrderStatus.CANCELLED);
}TransactionTemplate transactions = new TransactionTemplate(transactionManager);A
TransactionTemplateruns a block of code in a transaction. For the class, see the chapter Transactions in depth.CountDownLatch firstHasWritten = new CountDownLatch(1);A
CountDownLatchlets one thread wait until another thread callscountDown(). The two latches force the order of events. The first transaction writes, and stays open while the second transaction tries.CompletableFuture<Void> first = CompletableFuture.runAsync(() -> transactions.executeWithoutResult(status -> {The first transaction runs on another thread. It cancels the order and saves it, but does not commit yet.
copy.pickLine(copy.lines().getFirst().id(), CLOCK);The second transaction loaded the same version. It picks a line and saves.
assertThat(WriteConflicts.isTranslatedWriteConflict(secondError)).as(String.valueOf(secondError)).isTrue();The second save failed with a write conflict, not with
OptimisticLockingFailureException. The cancelled order stayed.
The research for this book ran the same race. MongoDB aborted the second writer with error
112, WriteConflict, labelled TransientTransactionError. Spring
translated it to DataIntegrityViolationException. The second writer failed in this
way also when the first transaction had already committed, but only after the second one
started. So catch (OptimisticLockingFailureException e) around a transactional use
case misses these conflicts. For every case that the research measured, see the section
Write conflicts, and what Spring retries.
The platform answers both shapes with the same 409 response:
@ExceptionHandler(OptimisticLockingFailureException.class)
ResponseEntity<ProblemDetail> staleVersion(OptimisticLockingFailureException ex) {
return conflict();
}
@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<ProblemDetail> dataIntegrity(DataIntegrityViolationException ex) {
if (WriteConflicts.isWriteConflict(ex)) {
return conflict();
}
log.error("Unexpected data error", ex);
return ResponseEntity.internalServerError().body(ProblemDetails.problem(HttpStatus.INTERNAL_SERVER_ERROR,
"Internal error", "Something went wrong. Quote the traceId if you contact support."));
}@ExceptionHandler(OptimisticLockingFailureException.class)A stale version outside a transaction becomes 409 with the code
CONCURRENT_UPDATE.if (WriteConflicts.isWriteConflict(ex)) {DataIntegrityViolationExceptionalso covers real data errors. So the handler checks the cause for error 112 or the label. Only a write conflict becomes 409. Every other data error is a 500.
The agent sees the text "Someone else changed this at the same time. Reload it and try again." The handler is in the platform's mongo package, next to the technology that throws the exceptions. The web code of the platform knows nothing about MongoDB. For whether the service should retry a write conflict itself, see the section Write conflicts, and what Spring retries.
Queries from method namestwist#
Each feature declares its queries as methods of a Spring Data repository. Spring Data writes the implementation when the application starts. It reads the query from the name of the method:
IFindFluent<Note, Note> page = notes.Find(n => n.OrderId == orderId);
long open = await notes.CountDocumentsAsync(n =>
n.OrderId == orderId && n.ActionRequired && n.ActionResolvedAt == null);public interface NoteRepository extends MongoRepository<Note, String> {
Page<Note> findByOrderId(String orderId, Pageable pageable);
/** Counts the notes on an order that still need action. Spring Data reads the rule from the name. */
long countByOrderIdAndActionRequiredTrueAndActionResolvedAtIsNull(String orderId);
}public interface NoteRepository extends MongoRepository<Note, String> {MongoRepositorybrings the usual methods, such assaveandfindById.Noteis the stored type, andStringis the type of its ID.Page<Note> findByOrderId(String orderId, Pageable pageable);findBystarts a query, andOrderIdnames the fieldorderId. ThePageableadds the page and the sort. See the section Paging.long countByOrderIdAndActionRequiredTrueAndActionResolvedAtIsNull(String orderId);countByreturns a number instead of documents. The name has three conditions, joined byAnd.OrderIdtakes the parameter.ActionRequiredTrueasks foractionRequiredto be true.ActionResolvedAtIsNullasks for no resolution time.
In C#, the query is a lambda, and the driver turns it into a filter. In Java, the query is the method name. A Java lambda cannot be read as data, so it cannot describe a query.
The other two features declare one query each. The order's query takes the status as a string, because the document stores a string:
interface OrderMongoRepository extends MongoRepository<OrderDocument, String> {
Page<OrderDocument> findByStatus(String status, Pageable pageable);
}The adapter calls findByStatus with status.name(), and the domain
never sees the string.
The reference feature names its sort in the method name:
@Cacheable("reference-data")
List<ReferenceItemDocument> findByTypeOrderByCodeAsc(String type);OrderByCodeAsc adds a sort on code. In a run for this book,
findByStatusOrderByPlacedAtDesc sent the filter {"status": "NEW"}
with the sort {"placedAt": -1}. @Cacheable keeps the result in a
cache. For the cache, see the chapter Caching and scheduling.
Each query of the service has an index, created by a migration. A query without an index reads the whole collection. For the indexes, see the chapter Migrations, indexes and seed data. For more about derived queries, see the section Repositories and derived queries.
Pagingtwist#
An agent lists the orders that are paid, twenty at a time. The domain asks for a page with
its own type, PageQuery, which knows nothing about Spring. The base class converts
it into Spring Data's types, and the result back:
protected static Pageable pageable(PageQuery query, Sort sort) {
return PageRequest.of(query.page(), query.size(), sort);
}
protected PageResult<A> toPageResult(Page<D> page) {
return new PageResult<>(page.map(mapper::toDomain).getContent(),
page.getNumber(), page.getSize(), page.getTotalElements());
}return PageRequest.of(query.page(), query.size(), sort);PageRequestimplementsPageable. It holds the page number, counted from 0, the page size, and the sort.page.map(mapper::toDomain).getContent(),Page.mapconverts each document into an aggregate, and keeps the paging numbers.getContent()returns the items of this page as a list.page.getNumber(), page.getSize(), page.getTotalElements());These are the page number, the page size, and the number of items on all pages.
PageResultis the platform's own type, so the domain port returns no Spring type.
A run for this book measured a Page query. For page 1 with a size of 2, Spring
Data sent a find with skip 2 and limit 2, and then a
count. The count was an aggregation with $match and $group. On the last
page, which held fewer items than the page size, Spring Data sent no count. It already knew the
total.
So a Page costs two queries on most pages. The order service shows the total in
its responses, so it pays for the count.
The light style does the same conversion in the service, because it has no adapter:
public PageResult<NoteView> list(String orderId, PageQuery page) {
orders.requireExists(orderId);
Page<Note> found = notes.findByOrderId(orderId, PageRequest.of(page.page(), page.size(), NEWEST_FIRST));
return new PageResult<>(found.getContent(), found.getNumber(), found.getSize(), found.getTotalElements())
.map(NoteView::from);
}PageRequest.of builds the Pageable here, and
PageResult.map turns each note into a view.
MongoTemplate for writes that are not aggregatesnew#
Some writes store no aggregate. The inbox checks a key and inserts it. The audit log inserts
a record. The migration runner inserts a lock. A Spring Data repository needs a class for each
of them. MongoTemplate needs none. It works with raw
org.bson.Document values and a collection name:
import static org.springframework.data.mongodb.core.query.Criteria.where;
import static org.springframework.data.mongodb.core.query.Query.query;
// ...
try {
transactions.executeWithoutResult(status -> {
if (mongo.exists(query(where("_id").is(key)), COLLECTION)) {
log.info("Skipping event {}, which was processed before", event.eventId());
return;
}
handler.accept(event);
mongo.insert(new Document("_id", key).append("processedAt", clock.instant()), COLLECTION);
});import static org.springframework.data.mongodb.core.query.Criteria.where;A static import lets the code write
where(...)instead ofCriteria.where(...). C# hasusing staticfor the same purpose.mongo.exists(query(where("_id").is(key)), COLLECTION)where("_id").is(key)builds aCriteria, one condition.query(...)wraps it in aQuery.existsasks whether any document in the collection matches.mongo.insert(new Document("_id", key).append("processedAt", clock.instant()), COLLECTION);new Document(...)builds a raw document.appendadds a field and returns the same document.insertwrites it into the named collection.
The research for this book measured the template's raw methods:
| Call | What happened |
|---|---|
| insert(document, "seed") | the same Document came back, with an ObjectId in _id when it had none |
| insert(document) with no collection name | MappingException, because the template cannot know the collection of a raw Document |
| upsert(query, new Update().set(...).setOnInsert(...), "seed") | one update with upsert true, which inserted the first time and updated after |
| the same upsert with unchanged values | matched 1 and modified 0, which is not an error |
| where("type").is("a").and("type").is("b") | InvalidMongoDbApiUsageException, because one Criteria cannot hold the same field twice |
| updateFirst(query, new Update().set("status", PAID), Order.class) | a $set on one field, while save replaces the whole document |
MongoTemplate has the same purpose as IMongoDatabase and
IMongoCollection<T> in the MongoDB .NET driver. Update describes a
change to some fields, such as set or
inc. A repository's save always replaces the whole document. So use
Update when only one field changes and no aggregate rule is involved. The seed data
of the reference feature is written this way, with upsert. For that code, see the
section Seed data, upserted by a natural key.
Never change an aggregate with Update. An update of one field skips the rules of
the aggregate, and it skips the version check. Change an aggregate only through the root, and
save the whole aggregate.
MongoTemplate also joins the transaction that is running on the thread. So the
inbox key, the audit record and the change are committed together. For transactions, see the
chapter Transactions in depth. For more methods, see the section
MongoTemplate for the rest.
Changing the shape of stored documentsnew#
A new version of the service adds a field to the order, such as a delivery note. Orders that were stored before have no such field. A relational database would need a column added before the release. A document database accepts the new field at once, and the old documents stay as they are. So the code must deal with both shapes.
There are two ways, and the choice depends on the field:
| Way | What to do | Choose it when |
|---|---|---|
| handle the missing field on load | the mapper treats a missing value as "none", or gives it a default | the field is optional in the domain, such as a note to the courier |
| migrate the stored documents | a migration sets the default in every old document | every order must have the value, or a query must find old documents by it |
The mapper already handles one missing value. An order that was never paid has no
lastPayment, so the mapper returns no payment:
return document == null ? null : new Payment(new PaymentReference(document.reference),A new optional field works the same way. The document field is null in old
documents, and the mapper turns null into "none" in the domain. A field with a
primitive type, such as int, cannot be null. Use the wrapper type,
such as Integer, for a field that old documents lack.
A required field needs a migration. The migration runs once, before the new code takes traffic, and writes the default into every document that lacks the field. A migration of this kind should be safe to run twice, so it changes only documents where the field is missing. For migrations, see the chapter Migrations, indexes and seed data.
The note got such a field. Each note now stores createdBy, the agent who wrote
it, for the rule that another agent resolves its action. Notes written before have no author,
and every note must have one. So a migration writes a value into the old notes:
/**
* Gives every stored note an author. Notes written before the field {@code createdBy} existed
* have none, and every note must have one, so they get the value {@code unknown}. Only notes
* without the field change, so the migration is safe to run twice.
*/
@Component
class V003AddNoteAuthors implements MongoMigration {
static final String UNKNOWN_AUTHOR = "unknown";
@Override
public String id() {
return "V003-add-note-authors";
}
@Override
public void migrate(MongoTemplate mongo) {
mongo.updateMulti(query(where("createdBy").exists(false)),
new Update().set("createdBy", UNKNOWN_AUTHOR), "notes");
}
}query(where("createdBy").exists(false))The filter selects only the notes that have no field
createdBy. A second run finds none, so it changes nothing.new Update().set("createdBy", UNKNOWN_AUTHOR)The update sets the one field, and leaves the rest of each note as it is. The value
unknownis no agent's ID, so any agent may resolve an old note.mongo.updateMulti(updateMultichanges every matching document in one command, on the server. The service loads no note.
A test runs it against a real MongoDB, twice:
@Test
void givesEveryOldNoteAnAuthorAndLeavesTheOthersAlone() {
mongo.insert(new Document("_id", "old-note").append("text", "Call back"), "notes");
mongo.insert(new Document("_id", "new-note").append("text", "Refund").append("createdBy", "agent-1"), "notes");
new V003AddNoteAuthors().migrate(mongo);
new V003AddNoteAuthors().migrate(mongo); // a second run finds no note without an author
assertThat(mongo.findById("old-note", Document.class, "notes")).containsEntry("createdBy", "unknown");
assertThat(mongo.findById("new-note", Document.class, "notes")).containsEntry("createdBy", "agent-1");
}The test writes raw documents, as an older version of the service would have stored them. One
note has no author, and one has. After two runs, the old note has unknown, and the
other note keeps its author.
Three changes are harder than adding a field:
- Renaming a field. In the full style, rename the domain field and keep the
document field. In the light style, keep the stored name with
@Field("oldName"). - Renaming an enum constant. Old documents hold the old name, and
valueOffails on it. Migrate the stored values first, or map the old name in the mapper. - Changing a type. For example, an amount stored as a string by Boot 3 becomes a Decimal128 on Boot 4. One collection then holds both forms. Convert the old documents in a migration.
The full style has one more option. The mapper can read two shapes, such as an old field and a new one, while a migration converts the documents. The domain never sees the old shape.
The connection string and the Boot 4 keytrap#
The connection string comes from an environment variable. Each environment sets its own:
mongodb:
# The Spring Boot 4 key. Boot 4 ignores the old spring.data.mongodb.uri without a warning.
# DocumentDB: mongodb://<user>:<password>@<cluster>:27017/orders?tls=true&replicaSet=rs0&readPreference=primary
# The platform forces retryWrites=false, which DocumentDB needs.
uri: ${MONGO_URI}mongodb:This key is under
spring:, so the full name isspring.mongodb.uri. Boot 4 moved every connection key fromspring.data.mongodbtospring.mongodb.uri: ${MONGO_URI}${MONGO_URI}reads the environment variableMONGO_URI. The deployment sets it from the secrets manager, because the string holds a password.
Boot 4 ignores spring.data.mongodb.uri without a warning.
Boot 4 deprecated the old key at the level "error", so it has no effect. In a run for this book,
an application had only the old key, with a working URI. The client went to
localhost:27017, the default, and failed with "Connection refused". Nothing was
logged about the old key. A service that was upgraded from Boot 3 works on a laptop with a local
MongoDB, and fails in every other environment.
A test catches the mistake without Docker. The wiring test starts the whole application, and checks the address that Boot will use:
/** Spring Boot 4 reads spring.mongodb.uri; the old spring.data.mongodb.uri would be ignored silently. */
@Test
void usesTheConfiguredMongoUri() {
ConnectionString used = context.getBean(MongoConnectionDetails.class).getConnectionString();
assertThat(used.getHosts()).containsExactly("localhost:1");
assertThat(used.getDatabase()).isEqualTo("wiring-test");
}MongoConnectionDetails is the bean in which Boot keeps the resolved connection.
The wiring profile sets the URI to port 1 and the database wiring-test. With the
old key, the test would find localhost and the database test.
The comment shows the parameters for Amazon DocumentDB, the database of Amazon Web Services (AWS). Transport Layer Security (TLS) encrypts the connection. A replica set is a primary instance that takes the writes, and replicas that copy them. Each parameter has a reason:
| Parameter | Value | Why |
|---|---|---|
| tls | true | Amazon DocumentDB uses TLS by default, and the platform trusts the AWS certificate bundle |
| replicaSet | rs0 | AWS recommends connecting as a replica set, so the driver follows the cluster's instances |
| readPreference | primary | every read goes to the primary instance, so a read sees the write before it |
| retryWrites | false, forced by the platform | DocumentDB supports retryable writes only from engine 8.0.2, as of October 2026 |
AWS says that reads from the primary are read-after-write consistent, and reads from replicas are eventually consistent. The order service often reads
right after a write. An agent places an order, and the next screen shows it. So the service reads
from the primary. The example connection string of the short chapter on document data uses
secondaryPreferred, which spreads reads over the
replicas. That suits a service with many reads that accepts slightly old data. For that
parameter, see the section Two parameters that change behaviour.
AWS states that replicaSet=rs0 fails through an SSH tunnel. A developer who
connects from a laptop through a tunnel removes it. For a run from an integrated development
environment (IDE) against the local MongoDB, the service's README adds
directConnection=true. The local replica set tells the driver
that its member is mongo:27017, a name that only containers can resolve.
Driver settings that DocumentDB needstwist#
A connection string is easy to copy and easy to get wrong. A service copies a URI without
retryWrites=false. AWS states that most current drivers switch retryable writes on
by default. As of October 2026, every write then fails on an engine before 8.0.2, or on an
elastic cluster. The platform therefore sets the driver
options that DocumentDB needs in code, after the URI is applied:
MongoClientSettings settings = MongoClientSettings.FromConnectionString(connectionString);
settings.RetryWrites = false;
return new MongoClient(settings);public class DocumentDbClientSettingsCustomizer implements MongoClientSettingsBuilderCustomizer {
private final DocumentDbProperties properties;
public DocumentDbClientSettingsCustomizer(DocumentDbProperties properties) {
this.properties = properties;
}
@Override
public void customize(MongoClientSettings.Builder settings) {
settings.retryWrites(false);
if (StringUtils.hasText(properties.getCaBundle())) {
SSLContext sslContext = sslContextTrusting(Path.of(properties.getCaBundle()));
settings.applyToSslSettings(ssl -> ssl.enabled(true).context(sslContext));
}
}public class DocumentDbClientSettingsCustomizer implements MongoClientSettingsBuilderCustomizer {MongoClientSettingsBuilderCustomizeris a Boot interface, in the packageorg.springframework.boot.mongodb.autoconfigure. Boot calls every bean of this type while it builds the client. The platform registers this class as a bean.settings.retryWrites(false);This line switches retryable writes off. A retryable write is a write that the driver sends once more after a network error. A run showed that this line wins over
retryWrites=trueinspring.mongodb.uri.if (StringUtils.hasText(properties.getCaBundle())) {hasTextis true for a string that is notnulland not blank. Locally, no bundle is set, and the connection uses no TLS.settings.applyToSslSettings(ssl -> ssl.enabled(true).context(sslContext));With a bundle, the driver uses TLS, and trusts the certificates in the bundle. See the section TLS with the AWS certificate bundle.
Boot calls the customizers after it has applied the connection string. The run confirmed
it. Without the customizer, an insert carried "txnNumber": 1, the mark of a
retryable write. With the customizer, the insert had none. In C#, you set
RetryWrites on MongoClientSettings yourself, in the code that builds
the client.
Boot's documentation names a limit. If a service defines its own
MongoClientSettings bean, Boot uses it without modification. So the customizer
would change nothing, and a service must never declare that bean.
A unit test checks the setting with no database:
@Test
void turnsRetryableWritesOffWhateverTheConnectionStringSays() {
MongoClientSettings.Builder settings = MongoClientSettings.builder()
.applyConnectionString(new ConnectionString("mongodb://localhost/shop?retryWrites=true"));
new DocumentDbClientSettingsCustomizer(new DocumentDbProperties()).customize(settings);
MongoClientSettings built = settings.build();
assertThat(built.getRetryWrites()).isFalse();
assertThat(built.getSslSettings().isEnabled()).isFalse();
}The test builds the settings from a URI with retryWrites=true, runs the
customizer, and expects getRetryWrites() to be false. With no bundle, TLS stays
off.
The strict rule. Configure each environment for what its database supports. From engine version 8.0.2, Amazon DocumentDB supports retryable writes, as of October 2026.
What we do. The platform switches retryable writes off for every service and every cluster, whatever the connection string says.
Why. On an engine before 8.0.2, and on an elastic cluster, every write fails with retryable writes on. One setting in the platform removes that failure for every service. The writes that matter most run in transactions, and AWS states that writes inside a transaction are not retryable anyway.
When to stop. Stop when every cluster of the company runs engine 8.0.2 or later on instance-based clusters. Then retryable writes protect single writes outside a transaction from a failover, and the line can go.
TLS with the AWS certificate bundletrap#
Amazon DocumentDB uses TLS by default. The driver must trust the certificate authority (CA)
that signed the cluster's certificate. AWS publishes the CA certificates in one file,
global-bundle.pem. The deployment mounts that file, and sets one property:
/**
* The path of the AWS certificate bundle, such as {@code /certs/global-bundle.pem}. The
* deployment mounts the file and sets this property. Leave it empty locally.
*/
private String caBundle;The field caBundle is bound from platform.mongo.documentdb.ca-bundle. The deployment sets
it with the environment variable PLATFORM_MONGO_DOCUMENTDB_CA_BUNDLE.
The Java driver ignores tlsCAFile in the connection string.
AWS's connection strings for other languages contain tlsCAFile. AWS's Java page
shows it in a URI too, as of October 2026. But the MongoDB Java driver's TLS page lists no such
parameter. In a run against a MongoDB that accepts only TLS, a URI with tlsCAFile
failed with "PKIX path building failed". It failed exactly as with no trust at all. So the
trust must come from code or from Boot.
The platform builds the trust from the file:
private static SSLContext sslContextTrusting(Path pemFile) {
try (InputStream pem = Files.newInputStream(pemFile)) {
Collection<? extends Certificate> certificates =
CertificateFactory.getInstance("X.509").generateCertificates(pem);
if (certificates.isEmpty()) {
throw new IllegalStateException("The DocumentDB CA bundle " + pemFile + " holds no certificate");
}
KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null, null);
int index = 0;
for (Certificate certificate : certificates) {
trustStore.setCertificateEntry("documentdb-ca-" + index++, certificate);
}
TrustManagerFactory trustManagers = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
trustManagers.init(trustStore);
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagers.getTrustManagers(), null);
return sslContext;
} catch (IOException e) {
throw new UncheckedIOException("Could not read the DocumentDB CA bundle " + pemFile, e);
} catch (GeneralSecurityException e) {
throw new IllegalStateException("The DocumentDB CA bundle " + pemFile + " is not valid", e);
}
}try (InputStream pem = Files.newInputStream(pemFile)) {This is a try-with-resources statement. It closes the stream at the end, like
usingin C#.Files.newInputStreamopens the file at thePath.CertificateFactory.getInstance("X.509").generateCertificates(pem);CertificateFactoryreads certificates in the X.509 format.generateCertificatesreads every certificate in the file, not only the first. The AWS bundle holds many.KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());A
KeyStoreholds keys and certificates. Here it is a trust store, which holds only the certificates to trust.load(null, null)creates it empty, in memory.trustStore.setCertificateEntry("documentdb-ca-" + index++, certificate);Each certificate needs a name, called an alias. The loop numbers them.
TrustManagerFactory trustManagers = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());A
TrustManagerFactorycreates the objects that decide whether a server's certificate is trusted.init(trustStore)makes them trust exactly the certificates of the store.SSLContext sslContext = SSLContext.getInstance("TLS");An
SSLContextholds the TLS settings of a connection.initgives it the trust managers. The firstnullmeans that the service sends no client certificate.throw new UncheckedIOException("Could not read the DocumentDB CA bundle " + pemFile, e);IOExceptionis a checked exception, andcustomizemay not throw one.UncheckedIOExceptionwraps it as an unchecked exception. The startup fails, with the file name in the message.
These classes are part of the Java Development Kit (JDK), in java.security and
javax.net.ssl. AWS's instructions for C# import the same certificates into the
Windows root store, as global-bundle.p7b. The Java code builds a store of trusted
certificates in memory, and gives it to this one connection.
Two tests check the trust. trustsTheCaBundleWhenOneIsConfigured loads a test
CA and finds TLS on, with a context. failsAtStartupWhenTheCaBundleIsMissing names
a file that does not exist, and expects UncheckedIOException with the file name.
So a wrong mount fails at startup, not at the first query.
There are two other ways, and both work. Both were tested against a MongoDB that accepts only TLS:
| Way | How | What it gets right | Why the platform does not use it |
|---|---|---|---|
| a Boot SSL bundle | spring.ssl.bundle.pem.docdb.truststore.certificate and spring.mongodb.ssl.bundle=docdb | configuration only, and Boot loads every certificate of the PEM file | every service repeats two settings, and the platform cannot test them once |
| a JVM trust store | a JKS file made with keytool, and -Djavax.net.ssl.trustStore | no code, and the way that AWS's Java guide describes | every host needs the file and its password, and the setting lives outside the service |
| the platform's customizer | platform.mongo.documentdb.ca-bundle | one property, and one tested implementation for every service | it is code to maintain, about 30 lines |
The Boot SSL bundle is the better choice for a single service without a platform library. It
needs no code, and Boot supports it. For its settings, see the section
Connecting to Amazon DocumentDB. For the trust store made with
keytool, see the section A trust store made with
keytool.
MongoDB locally, DocumentDB in productiontrap#
Amazon DocumentDB runs only inside AWS. AWS offers no local edition, as of October 2026. So developers and tests use MongoDB, which offers the same API. The local environment pins the image:
# Stands in for DocumentDB. Use the MongoDB version that your DocumentDB cluster is compatible with.
# It runs as a single-node replica set, because transactions need one.
mongo:
image: mongo:8.0.34
command: ["--replSet", "rs0", "--bind_ip_all"]--replSet rs0 starts MongoDB as a replica set named rs0. A driver
sees a DocumentDB cluster under the same name.
DocumentDB emulates the MongoDB API on its own engine. As of October 2026, AWS lists engine versions 3.6, 4.0, 5.0 and 8.0. Engine 3.6 reached its end of life in March 2026. Engine 8.0 also supports applications built with the MongoDB API versions 6.0 and 7.0. Spring Data MongoDB 5.1 states that it is tested against MongoDB 6 to 8. So the shop uses engine 8.0 clusters, and a MongoDB 8.0 image locally.
A test that succeeds on MongoDB can fail on DocumentDB. MongoDB accepts features that DocumentDB lacks or changes, as of October 2026. Some examples matter for this service:
- A transaction on DocumentDB cannot create a collection. A MongoDB 8.0 container allows it.
- DocumentDB guarantees no order of results without a sort. A test against MongoDB cannot prove that every list query names its sort.
- Elastic clusters have no transactions at all.
- Spring's transaction manager opens a causally consistent session, and DocumentDB does not support causal consistency. This was never tested on a cluster.
The first item has a fix in the platform. A migration creates every collection that a transaction writes to, before the first transaction runs. For that migration, see the section The limits of a transaction on Amazon DocumentDB.
The platform's test configuration says the same in its comment:
/**
* Starts MongoDB in Docker for a test, and points Spring Boot at it.
*
* <p>{@link ServiceConnection} sets the connection details from the running container, so the
* test needs no {@code spring.mongodb.uri}. {@code withReplicaSet()} starts MongoDB as a
* single-node replica set. Without it, MongoDB runs stand-alone and every transaction fails, but
* DocumentDB always runs as a replica set.
*
* <p>MongoDB is not DocumentDB. Keep at least one test stage against a real DocumentDB cluster,
* and use the MongoDB version your cluster is compatible with.
*/
@TestConfiguration(proxyBeanMethods = false)
public class MongoTestContainerConfiguration {
public static final String IMAGE = "mongo:8.0.34";
@Bean
@ServiceConnection
MongoDBContainer mongoDbContainer() {
return new MongoDBContainer(IMAGE).withReplicaSet();
}
}@ServiceConnectionBoot reads the address of the running container, and connects the application to it. The test sets no URI.
return new MongoDBContainer(IMAGE).withReplicaSet();The container runs as a single-node replica set, so transactions work. See the section Testing the storage.
So the shop's tests run against MongoDB in Docker, for speed. That covers the slice tests and the journey test, which runs the whole service. The comment asks for one more stage, against a real DocumentDB cluster, before a release. Only that stage catches the differences in the list above. For the full list of differences, see the section What Amazon DocumentDB does not do and the section More differences.
Testing the storagenew#
A mapper can lose a field without a compile error. A version that is not copied breaks optimistic locking without a failing unit test. So every aggregate gets a test against a real MongoDB. The platform provides one annotation for it:
/**
* A persistence slice test against MongoDB in Docker, with the platform's transaction manager
* and DocumentDB settings. Name such tests {@code *IT}, because they need Docker.
*
* <p>Migrations are off, so each test sees only the data it writes. Add the adapter and mapper
* under test with {@code @Import}.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@DataMongoTest(properties = "platform.mongo.migrations.enabled=false")
@ImportAutoConfiguration(PlatformMongoAutoConfiguration.class)
@Import(MongoTestContainerConfiguration.class)
public @interface PlatformDataMongoTest {
}@DataMongoTest(properties = "platform.mongo.migrations.enabled=false")@DataMongoTestis the test slice of Spring Boot for MongoDB. It starts the Spring Data repositories andMongoTemplate, and no web layer. The property switches the migration runner off.@ImportAutoConfiguration(PlatformMongoAutoConfiguration.class)A slice starts only Boot's own MongoDB setup. This line adds the platform's transaction manager and the DocumentDB settings.
@Import(MongoTestContainerConfiguration.class)This line adds the container from the section above.
The test of optimistic locking is the one that every aggregate needs. It loads two copies, changes and saves the first, and expects the second save to fail:
@Test
void rejectsAStaleCopy() {
Order saved = orders.save(newOrder());
Order firstCopy = orders.get(saved.id());
Order secondCopy = orders.get(saved.id());
firstCopy.cancel(CLOCK);
orders.save(firstCopy);
secondCopy.pickLine(secondCopy.lines().getFirst().id(), CLOCK);
assertThatThrownBy(() -> orders.save(secondCopy)).isInstanceOf(OptimisticLockingFailureException.class);
}The test runs without a transaction, so the conflict is
OptimisticLockingFailureException. The test in the section
A conflict inside a transaction covers the other shape. The note
has the same test, NoteRepositoryIT.rejectsAStaleCopyAndStillChangesItsVersion.
The test class imports only what it tests, with
@Import({OrderPersistenceAdapter.class, OrderDocumentMapper.class}). The slice
rolls nothing back after a test. So the tests create new orders with new IDs, and never count
all documents of a collection.
The new MongoDBContainer starts no replica set by default.
Testcontainers 2 has a new class, org.testcontainers.mongodb.MongoDBContainer. It
starts a replica set only after withReplicaSet(). The old class,
org.testcontainers.containers.MongoDBContainer, is deprecated and always starts one.
An upgrade to Boot 4 often changes only the import. Then every transaction in the tests fails.
Boot 4.1 accepts both classes with @ServiceConnection. Its support for the old class
is marked for removal in Boot 4.2.0.
1An expert asksThe order service stores its own IDs as strings. Why does the platform still
set spring.mongodb.representation.uuid=standard?
spring.mongodb.representation.uuid=standard?CodecConfigurationException before it reaches the database. The outbox of
Spring Modulith stores a UUID as the ID of every publication. So without the setting, the outbox
could not store its first event.2A connection string for Amazon DocumentDB contains tlsCAFile=global-bundle.pem.
What does the MongoDB Java driver do with it, and how does the platform trust the AWS
certificates?
tlsCAFile=global-bundle.pem.
What does the MongoDB Java driver do with it, and how does the platform trust the AWS
certificates?tlsCAFile. A run against a MongoDB that accepts only TLS
failed exactly as with no trust at all. The platform reads the bundle file that
platform.mongo.documentdb.ca-bundle names, and builds an SSLContext that
trusts its certificates.3A service on Spring Boot 4 sets only spring.data.mongodb.uri. What happens?
spring.data.mongodb.uri. What happens?localhost:27017. It works on a laptop with a local MongoDB, and fails everywhere
else. The key is spring.mongodb.uri.4Why does an embedded line of the order store its ID as lineId, not as
id?
lineId, not as
id?id as _id, also in an embedded
document. Spring rewrites its own queries, but mongosh, raw driver queries, hand-named indexes
and other services look for lines.id and find nothing. A run counted 0
matches.5TranslateWrite this query as a method of the order's persistence adapter. The domain
passes an OrderStatus and a PageQuery, and gets a
PageResult<Order>, newest first:
List<OrderDocument> items = await orders.Find(o => o.Status == status)
.SortByDescending(o => o.PlacedAt)
.Skip(pageNumber * pageSize)
.Limit(pageSize)
.ToListAsync();
long total = await orders.CountDocumentsAsync(o => o.Status == status);
OrderStatus and a PageQuery, and gets a
PageResult<Order>, newest first:
List<OrderDocument> items = await orders.Find(o => o.Status == status)
.SortByDescending(o => o.PlacedAt)
.Skip(pageNumber * pageSize)
.Limit(pageSize)
.ToListAsync();
long total = await orders.CountDocumentsAsync(o => o.Status == status);@Override
public PageResult<Order> findByStatus(OrderStatus status, PageQuery page) {
return toPageResult(orderDocuments.findByStatus(status.name(), pageable(page, NEWEST_FIRST)));
}orderDocuments.findByStatus(String, Pageable) is a derived query of
OrderMongoRepository. The helper pageable builds a
PageRequest. NEWEST_FIRST sorts on placedAt,
descending. Spring Data sends the
skip, the limit and the count. toPageResult maps each document back to an
Order.
Quick reference#
The storage settings of the order service, with the value the platform or the service sets:
| Setting | Value in the shop | What it does |
|---|---|---|
| spring.mongodb.uri | ${MONGO_URI} | the connection string, and the Boot 4 key |
| spring.mongodb.representation.uuid | standard, from the platform | the binary form of a stored UUID |
| spring.data.mongodb.representation.big-decimal | decimal128, from the platform | stores every BigDecimal as a Decimal128, and Boot's default is unspecified |
| spring.data.mongodb.auto-index-creation | false, from the platform | Spring creates no index at startup, because migrations create them |
| platform.mongo.documentdb.ca-bundle | none locally, a mounted file in AWS | the CA bundle that TLS trusts |
| platform.mongo.migrations.enabled | true, and false in slice and wiring tests | runs the migrations at startup |
The Spring Data annotations that the service uses for storage:
| Annotation | Package | What it does |
|---|---|---|
| @Document("orders") | org.springframework.data.mongodb.core.mapping | names the collection of a class |
| @Id | org.springframework.data.annotation | maps a field to _id |
| @Version | org.springframework.data.annotation | adds the version check to every update |
| @Field(targetType = FieldType.DECIMAL128) | org.springframework.data.mongodb.core.mapping | stores a field as another BSON type, or under another name |
| @Transient | org.springframework.data.annotation | leaves a field out of the stored document |
| @PersistenceCreator | org.springframework.data.annotation | marks the constructor that Spring Data uses to load |
Migrations, indexes and seed data#
A migration is one versioned change to the database, such as new indexes or lookup values. The platform runs each migration once in each environment, at startup, before the service takes traffic. A lock lets only one instance migrate at a time. This chapter covers the runner, the lock, named indexes, unique and time-to-live (TTL) indexes, and seed data that is upserted and never deleted.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | an index created in code with Indexes.CreateOneAsync | spring.data.mongodb.auto-index-creation=false, and indexes in migrations | Spring creates no index at startup, so every index is reviewed code in a migration Why |
| twist | a class that extends Migration, with an Up method | a @Component that implements MongoMigration, with id() and migrate(MongoTemplate) | the platform finds every migration bean and runs the pending ones in ID order Why |
| twist | a timestamp in the name of each EF Core migration | IDs that sort in run order, such as P001 and V001 | the runner sorts the IDs as text, so platform migrations run first and every number is padded Why |
| new | no equivalent | MongoMigrationRunner, a SmartInitializingSingleton | it runs after every bean exists and before the web server starts, and records each migration in schema_migrations Why |
| new | no equivalent | a lock document with a unique _id and an expiry time | only one instance migrates, and a lock left by a crashed instance expires after the lock TTL Why |
| trap | no equivalent | an instance that waits for the lock, with no HTTP port open | the startup probe must allow for the whole lock TTL, or the orchestrator restarts the instance Why |
| new | no equivalent | a runner of about 150 lines, not Mongock or Flamingock | as of October 2026, Mongock has no Boot 4 artifact, and Flamingock lists Spring Data MongoDB only up to 4.x Why |
| twist | Builders<Note>.IndexKeys.Ascending(...).Descending(...) with a Name | new Index().on(field, direction).named(name) | one named index for each query, with a comment that says which query Why |
| twist | CreateIndexOptions { Unique = true } and ExpireAfter | .unique() and .expire(Duration) | DocumentDB supports both, but lists a TTL index only on a single field, as of October 2026 Why |
| trap | a changed EF Core migration that was applied already | an edited migration whose ID was recorded already | the runner skips it, so the change never reaches the environments that ran it Why |
| new | no equivalent | migrations that are safe to run twice, and run without a transaction | a crash between the change and its record runs the migration again Why |
| twist | database.CreateCollectionAsync in the MongoDB .NET driver | V000CreateCollections, a migration that creates the service's collections | DocumentDB cannot create a collection inside a transaction, so each one exists before the first use case Why |
| twist | UpdateOneAsync with UpdateOptions { IsUpsert = true } | SeedFiles.upsert, by type and code | lookup values come from JSON files, are upserted by their natural key, and are never deleted Why |
| new | no equivalent | a unit test with a mocked MongoTemplate, and an integration test against MongoDB | the order of runs, the skipping and the lock are each tested Why |
Why Spring creates no index at startuptwist#
Spring Data can create indexes when the application starts. A field gets
@Indexed, and Spring creates the index on the first start. The platform switches
this off for every service:
# Indexes are created by migrations (MongoMigration), never by Spring at startup.
# Startup index creation is invisible, nobody reviews it, and it can block on a large collection.
spring.data.mongodb.auto-index-creation=falseThe setting spring.data.mongodb.auto-index-creation keeps its old name on Boot 4.
Spring Data's own default is already off, so this line states the choice where a reader sees
it. For the trap of @Indexed without this setting, see the section
Indexes.
Automatic index creation has a careful version, and it has real strengths. The index stands next to the field it serves. A new field with an index needs no second file. On a new, empty collection, the index is created at once. For a prototype or a small internal tool, that is the simpler choice.
The platform still uses migrations, for four reasons:
- A failed build stops the service. In a run for this book, automatic index
creation met a collection that already held two equal values for a unique index. The
application failed to start, with a
DuplicateKeyException. - A build can block the collection. AWS states that the default index build on Amazon DocumentDB is a foreground build, which blocks all other operations on the collection. DocumentDB also allows only one index build on a collection at a time, as of October 2026.
- Nobody sees it. An annotation on a field looks like a small change in a pull request. A migration shows the index, its name and its options as a change of its own.
- It runs on every start. A migration runs once, and the runner records it.
In C#, the MongoDB .NET driver has no index attribute. A run for this book found no
attribute named like an index in the driver's assemblies. So a C# service creates its indexes in
code, with Indexes.CreateOneAsync. The shop does the same in Java, inside a
migration.
One migration: an interface and a beantwist#
A migration is a class that implements one interface of the platform:
/**
* One versioned change to the database, such as creating indexes or loading seed data. It runs
* once in each environment, at startup, before the service takes traffic.
*
* <p>Write each migration as a {@code @Component} in the service's {@code migration} package.
* The rules:
*
* <ul>
* <li>IDs are unique and sort in run order, such as {@code V001-create-indexes}. Platform
* migrations start with {@code P}, so they run first.</li>
* <li>Never change or delete a migration that has run anywhere. Add a new one.</li>
* <li>Make each migration safe to run twice, because a crash part-way runs it again.</li>
* <li>Use no transaction: DocumentDB cannot create a collection inside one.</li>
* </ul>
*/
public interface MongoMigration {
String id();
void migrate(MongoTemplate mongo);
}String id();The ID names the migration forever. The runner sorts by it, and records it when the migration has run.
void migrate(MongoTemplate mongo);The change itself. The runner passes the
MongoTemplate, so a migration can create indexes and write documents of any collection.
Each rule in the comment has its own section below. The service's first migration creates the indexes. EF Core migrations have the same purpose for a relational database:
public partial class CreateIndexes : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "notes_orderId_createdAt",
table: "notes",
columns: ["OrderId", "CreatedAt"]);
}
}@Component
class V001CreateIndexes implements MongoMigration {
@Override
public String id() {
return "V001-create-indexes";
}
@Override
public void migrate(MongoTemplate mongo) {
// GET /orders?status=PAID, newest first
mongo.indexOps("orders").createIndex(new Index()
.on("status", Sort.Direction.ASC).on("placedAt", Sort.Direction.DESC)
.named("orders_status_placedAt"));
// ...
}
}@ComponentThe migration is a bean. Spring finds it by component scanning, and the platform collects every bean of type
MongoMigration. A new migration needs no registration.class V001CreateIndexes implements MongoMigration {The class is package-private. Nothing in the service calls a migration. Only the runner does.
return "V001-create-indexes";The ID starts with
V001, so it sorts beforeV002. The words after it describe the change.
The class name and the ID say the same thing, but only the ID counts. Renaming the class is safe. Changing the ID makes the runner see a new migration, and run it once more.
EF Core generates its migration classes from the model, and the command
dotnet ef database update applies them. The shop writes each migration itself, and
the service applies it at startup. For Flyway, which is the closest match for a relational database in
Java, see the section Migrations.
The service's migrations are in their own package, migration, beside the
features. The package's comment says why:
/**
* The service's database changes: indexes and seed data, as {@code MongoMigration} beans. The
* platform's runner applies each one once, in ID order, before the service takes traffic.
*
* <p>This is not a feature. It changes the storage of every feature, so it sits beside them.
* Never edit a migration that has run anywhere; add the next one.
*/
package com.acme.orderservice.migration;A file named package-info.java holds the comment of a package, here
package com.acme.orderservice.migration. One migration often touches the collections of
several features, so no single feature owns it.
IDs, their order, and the platform's migrations firsttwist#
The platform has migrations of its own. The audit log, the inbox and the outbox need their
collections and indexes in every service. Their IDs start with P:
/**
* The platform's index on the audit log, for finding the history of one resource.
*
* <p>Its ID starts with {@code P}, so it sorts, and runs, before every service migration,
* whose IDs start with {@code V}.
*/
public class AuditLogIndexes implements MongoMigration {
@Override
public String id() {
return "P001-audit-log-indexes";
}
@Override
public void migrate(MongoTemplate mongo) {
mongo.indexOps(MongoAuditLog.COLLECTION).createIndex(new Index()
.on("resourceType", Sort.Direction.ASC)
.on("resourceId", Sort.Direction.ASC)
.on("at", Sort.Direction.ASC)
.named("audit_log_resource"));
}
}public class AuditLogIndexes implements MongoMigration {This class has no
@Component. The platform's auto-configuration declares it as a bean, so a service gets it by adding the library..named("audit_log_resource"));The index serves one question. It finds the history of one resource, in time order.
The runner sorts the migrations by their ID, as text. The letter P sorts before
V, so the platform's migrations run first. So these are the migrations of the
order service, in run order:
| ID | Owner | What it does |
|---|---|---|
| P000-audit-log-collection | the platform's audit log | creates the collection audit_log |
| P000-messaging-collections | the platform's outbox and inbox | creates the collections event_publication and processed_messages |
| P001-audit-log-indexes | the platform's audit log | the index audit_log_resource |
| P002-inbox-indexes | the platform's inbox | the TTL index processed_messages_ttl |
| P003-outbox-indexes | the platform's outbox | the index event_publication_incomplete |
| V000-create-collections | the order service | creates the collections orders, notes and reference_data |
| V001-create-indexes | the order service | one index for each query of the three features |
| V002-seed-reference-data | the order service | the delivery methods and the note categories |
| V003-add-note-authors | the order service | writes an author into the notes that have none |
| V004-replace-open-actions-index | the order service | replaces the index of the shipping rule |
The migrations with the number 000 create collections. On a new database, each one runs before the migrations that create indexes on the same collections. For these migrations, see the section Collections first, outside any transaction.
Text sorts character by character. So V10 sorts before V2. The second
character decides, and the digit 1 comes before the digit 2. The shop pads every number to three
digits, so V010 sorts after V002. Choose the width once, for the life
of the service.
Two migrations with the same ID would make the record ambiguous. The runner refuses them when it is created:
private static void requireUniqueIds(List<MongoMigration> migrations) {
Set<String> seen = new HashSet<>();
for (MongoMigration migration : migrations) {
if (!seen.add(migration.id())) {
throw new IllegalStateException("Two migrations have the ID " + migration.id());
}
}
}seen.add(migration.id()) returns false when the ID is in the set
already. The startup then fails. The test
MongoMigrationRunnerTest.refusesTwoMigrationsWithTheSameId checks the message.
java.lang.IllegalStateException: Two migrations have the ID V001-indexes#MongoMigration return the same ID, often after a copy
and paste.The runner: each migration once, before trafficnew#
A new instance of the service starts. Its code needs an index that does not exist yet, and a lookup value that is not stored yet. If the service took requests now, the first query would read a whole collection, and the first order would fail validation. So the migrations must run before the first request and the first message.
The platform declares the runner as a bean, with every migration bean in ID order:
/** Runs the migrations at startup. Slice and wiring tests turn it off with the property. */
@Bean
@ConditionalOnProperty(name = "platform.mongo.migrations.enabled", havingValue = "true", matchIfMissing = true)
MongoMigrationRunner mongoMigrationRunner(MongoTemplate mongo, ObjectProvider<MongoMigration> migrations, Clock clock,
@Value("${platform.mongo.migrations.lock-ttl:PT15M}") Duration lockTtl) {
return new MongoMigrationRunner(mongo, migrations.orderedStream().toList(), clock, lockTtl);
}@ConditionalOnProperty(name = "platform.mongo.migrations.enabled", havingValue = "true", matchIfMissing = true)The runner exists unless the property is
false.matchIfMissing = truemeans that a service without the property gets the runner.ObjectProvider<MongoMigration> migrationsAn
ObjectProvidergives access to every bean of a type.orderedStream()returns them as a stream. The runner sorts them by ID itself. ForObjectProvider, see the chapter Wiring the service: the container in depth.@Value("${platform.mongo.migrations.lock-ttl:PT15M}") Duration lockTtl)@Valuereads one setting. The text after the colon is the default.PT15Mis the ISO 8601 form of 15 minutes, and Spring converts it into aDuration.
The runner does its work in one method. Spring calls it once every singleton bean of the application exists:
@Override
public void afterSingletonsInstantiated() {
acquireLock();
try {
Set<String> applied = appliedIds();
for (MongoMigration migration : migrations) {
if (!applied.contains(migration.id())) {
run(migration);
}
}
} finally {
releaseLock();
}
}
private void run(MongoMigration migration) {
log.info("Running migration {}", migration.id());
Instant started = clock.instant();
migration.migrate(mongo);
mongo.insert(new Document("_id", migration.id())
.append("appliedAt", clock.instant())
.append("durationMs", Duration.between(started, clock.instant()).toMillis()), HISTORY);
}
private Set<String> appliedIds() {
Set<String> ids = new HashSet<>();
mongo.findAll(Document.class, HISTORY).forEach(document -> ids.add(document.getString("_id")));
return ids;
}public void afterSingletonsInstantiated() {This method comes from the interface
SmartInitializingSingleton. Spring calls it at the end of the phase in which it creates the singleton beans. The source of Spring says that all regular singleton beans exist by then.acquireLock();Only one instance may migrate at a time. See the section The lock between instances.
} finally {The lock is released, also when a migration throws. The other instances then do not wait for the TTL.
Set<String> applied = appliedIds();The runner reads every ID from
schema_migrationsonce. It runs only the migrations whose ID is missing.mongo.insert(new Document("_id", migration.id())After a migration succeeds, the runner records it. The ID is the document's
_id, so it can be recorded only once. The record also holds the time and the duration.
So no HTTP request and no message can arrive while a migration runs. Spring starts the web server and the listener containers of Amazon Simple Queue Service (SQS) only after this method returns. For the order of startup, with a diagram, see the section Work at startup: migrations before traffic.
A failed migration stops the startup. The exception leaves
afterSingletonsInstantiated, and the application context fails. The new
instance never becomes ready, so it takes no traffic. Fix the migration, and deploy again. The test MongoMigrationRunnerTest.releasesTheLockWhenAMigrationFails
checks that the lock is released in that case.
The history collection shows what ran where. A document in schema_migrations
looks like {_id: "V001-create-indexes", appliedAt: ..., durationMs: 41}. The
duration helps to plan the lock TTL.
Tests switch the runner off. @PlatformDataMongoTest sets
platform.mongo.migrations.enabled=false, so each test sees only its own data. The
wiring test does the same in its profile, because it connects to no database:
platform:
# ...
mongo:
migrations:
enabled: falseWith enabled: false, the condition of the bean fails, and no runner exists. The
journey test keeps the runner on. It starts the whole service against MongoDB, so it
proves that the migrations run.
The lock between instancesnew#
The orchestrator starts three instances of a new version at once. Each one runs the runner. Without a lock, all three would create the same indexes and load the same seed data at the same time. Each would try to record the same migration. The runner takes a lock first:
private void acquireLock() {
Instant giveUpAt = clock.instant().plus(lockTtl);
while (true) {
try {
mongo.insert(new Document("_id", LOCK_ID)
.append("owner", owner)
.append("expiresAt", clock.instant().plus(lockTtl)), LOCK);
return;
} catch (DuplicateKeyException lockedByAnotherInstance) {
// A lock left by a crashed instance has expired: remove it, and try again.
mongo.remove(query(where("_id").is(LOCK_ID).and("expiresAt").lt(clock.instant())), LOCK);
if (clock.instant().isAfter(giveUpAt)) {
throw new IllegalStateException("Timed out waiting for the migration lock", lockedByAnotherInstance);
}
log.info("Waiting for another instance to finish the migrations");
sleep(pollInterval);
}
}
}
private void releaseLock() {
mongo.remove(query(where("_id").is(LOCK_ID).and("owner").is(owner)), LOCK);
}mongo.insert(new Document("_id", LOCK_ID)The lock is one document with the fixed
_idlock. Every collection has a unique index on_id. So only one insert can succeed, and the database decides which one..append("owner", owner)owneris a random universally unique identifier (UUID) that each runner creates. It says which instance holds the lock..append("expiresAt", clock.instant().plus(lockTtl)), LOCK);The lock carries the time after which it counts as abandoned.
} catch (DuplicateKeyException lockedByAnotherInstance) {A failed insert means that another instance holds the lock. Spring translates the server's error 11000 into
DuplicateKeyException.mongo.remove(query(where("_id").is(LOCK_ID).and("expiresAt").lt(clock.instant())), LOCK);The runner removes the lock only if it has expired.
ltmeans "less than". So a lock left by a crashed instance goes away, and a live lock stays.sleep(pollInterval);The runner waits two seconds, and tries again.
mongo.remove(query(where("_id").is(LOCK_ID).and("owner").is(owner)), LOCK);An instance releases only its own lock. If its lock had expired and another instance took it, this line removes nothing.
The time to give up is the lock TTL, 15 minutes by default. An instance that waits longer fails its startup. The orchestrator starts it again, and by then the first instance has usually finished.
The runner waits with Thread.sleep, which can be interrupted:
private static void sleep(Duration duration) {
try {
Thread.sleep(duration);
} catch (InterruptedException e) {
// Restore the flag, so the code that interrupted this thread can see it.
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while waiting for the migration lock", e);
}
}} catch (InterruptedException e) {InterruptedExceptionis a checked exception. Another thread asked this thread to stop, for example during a shutdown.Thread.currentThread().interrupt();A method that throws
InterruptedExceptionfirst clears the interrupted status of the thread. This line sets it again, so the code higher up can still see the request to stop. The documentation ofThreadasks for this whenever code throws another exception instead.throw new IllegalStateException("Interrupted while waiting for the migration lock", e);The runner then gives up, with an unchecked exception. The startup fails.
A lock TTL shorter than the slowest migration lets two instances migrate.
The first instance runs a long migration. After the TTL, a second instance finds the lock
expired, removes it and takes the lock. It then runs the same migrations again, while the first
one is still running. The runner's comment says what to do. Set
platform.mongo.migrations.lock-ttl longer than the slowest migration, and split a
long data migration into smaller ones.
The lock needs only the unique _id of a collection. ShedLock, which the outbox
resender uses, also keeps its locks in MongoDB, in the collection scheduler_locks.
For that lock, see the chapter Caching and scheduling.
Waiting at startup, and the probestrap#
An instance that waits for the lock has not started its web server yet. So it answers no HTTP request at all, also no probe. The orchestrator may count every failed probe. With a short startup probe, it restarts the instance while it waits. The new instance waits too, and the deployment never finishes.
The startup probe must allow for the whole lock TTL. A waiting instance has
no HTTP port open for up to platform.mongo.migrations.lock-ttl, 15 minutes by
default. The service's README gives the rule:
**Migrations run before the service takes traffic, and the startup probe allows for the lock.**
The platform's runner runs in `afterSingletonsInstantiated`, before the web server and the SQS
listeners start, so no request or message arrives before the indexes and seed data exist. An
instance that waits for another's migration lock waits up to `platform.mongo.migrations.lock-ttl`
(15 minutes by default) with no HTTP port open. Give the orchestrator's startup probe at least
that long, for example `failureThreshold: 90` with `periodSeconds: 10`, or lower the TTL.The values failureThreshold: 90 and periodSeconds: 10 allow 90
failures, ten seconds apart, which are 15 minutes. Or lower the TTL, if every migration is
quick.
The local Docker Compose file of the service follows the same rule on a small scale:
healthcheck:
# The image has no curl, so bash opens the TCP connection itself. "Ready" means the database is reachable.
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/localhost/8080 && printf 'GET /actuator/health/readiness HTTP/1.0\\r\\n\\r\\n' >&3 && grep -q UP <&3"]
interval: 10s
retries: 12
# Migrations run before the web server starts, and an instance may wait for another's
# migration lock. Allow for that before failed checks count.
start_period: 60sAs the comment says, start_period gives the container 60 seconds before failed
checks count. The waiting time of a local start is short, because one developer starts one
instance.
Two other places could run the migrations. An ApplicationRunner runs after the
web server has started, so the probes are answered, and readiness stays down. A separate job runs
the migrations once, before the rollout. For the comparison of the three places, see the section
Work at startup: migrations before traffic.
The strict rule. A change of the database is a deployment step of its own. It runs once, before the new version starts, and it can be watched and stopped.
What we do. Each instance runs the platform's runner at startup. A lock lets one instance migrate, and the others wait.
Why. The order service's migrations create indexes and upsert a few dozen lookup values. They take seconds. One mechanism for every service needs no extra step in the pipeline, and a new environment migrates itself.
When to stop. Stop when one migration takes longer than a few minutes, such as a change of every stored order. Then run that migration as a separate job, and keep the startup probes short.
Why not Mongock or Flamingock, as of October 2026new#
Writing a migration runner looks like work that a library should do. Two libraries exist for MongoDB in Java. The platform's README gives its decision:
- **Our own migration runner.** Mongock has no Spring Boot 4 artifact. Flamingock says it supports
Boot 4, but lists Spring Data MongoDB only up to 4.x. The runner is about 150 lines; re-evaluate
when a library supports this stack.The bullet names both libraries, Mongock and Flamingock. The
research for this book checked both in October 2026:
| Library | What the sources say, as of October 2026 |
|---|---|
| Mongock | the latest version is 5.5.1, from March 2025, and no artifact for Spring Boot 4 exists |
| Mongock | its README says that it is sunsetting, in maintenance mode, and reaches its end of life at the end of 2026 |
| Flamingock | the successor of Mongock, under the Apache-2.0 licence, with version 1.4.5 from August 2026 |
| Flamingock | its Spring Boot integration says that it is compatible with Spring Boot 2.x to 4.x |
| Flamingock | its target system for Spring Data MongoDB lists Spring Data MongoDB 3.1.x to 4.x |
| the shop's stack | Spring Boot 4.1.2 manages Spring Data MongoDB 5.1.2, which is outside that range |
Flamingock is the careful alternative, and it deserves a fair look. It is maintained, and its licence is open. It keeps an audit of the changes, and it is the successor of Mongock. It may well work with Spring Data MongoDB 5.1.2. But its own page does not say so, and nobody ran it for this book. Its page on moving from Mongock mentions DocumentDB only in a list of systems that Mongock supported.
The shop's runner is small. It runs each migration once, in order, under a lock, and records it. It needs nothing that a library adds, such as rollback steps or several target systems. Re-evaluate when Flamingock lists Spring Data MongoDB 5, and when a test against DocumentDB succeeds. Then the runner can go, and the migrations stay much the same.
An older form of this standard used Mongock. A service that still uses it must move before Mongock's end of life. The research checked the names in Mongock 5.5.1, in its source and its documentation, in October 2026:
| Mongock 5 | What it marks | In the shop's runner |
|---|---|---|
| @ChangeUnit(id, order, author) | a class that is one migration, where author may be left out | a class that implements MongoMigration, with id() |
| @Execution | the method that makes the change | migrate(MongoTemplate mongo) |
| @RollbackExecution | the method that undoes the change | nothing, because a later migration repairs instead |
| @BeforeExecution | an optional method that runs outside the change unit's transaction, for changes that a transaction does not allow | nothing, because no migration runs in a transaction |
| @RollbackBeforeExecution | the undo of @BeforeExecution, required when that method exists | nothing |
| mongock.migration-scan-package | the setting that names the packages with change units | the migration beans, found by component scanning |
All five annotations are in the package io.mongock.api.annotations.
@ChangeUnit has four more attributes, among them runAlways and
transactional. The attribute transactional is true by default. The
documentation says that @BeforeExecution runs outside the transaction. It is meant
for operations that change the structure of the database, which some databases do not allow
inside a transaction, and it names MongoDB. Creating an index or a collection changes the
structure. So, as that documentation advises, a Mongock change unit creates its index in
@BeforeExecution, and changes documents in @Execution.
A named index for each querynew#
Each query of the service reads one collection. Without an index, MongoDB reads every document of the collection to answer it. So the first migration creates one index for each query, and names it:
@Override
public void migrate(MongoTemplate mongo) {
// GET /orders?status=PAID, newest first
mongo.indexOps("orders").createIndex(new Index()
.on("status", Sort.Direction.ASC).on("placedAt", Sort.Direction.DESC)
.named("orders_status_placedAt"));
// GET /orders/{orderId}/notes, newest first
mongo.indexOps("notes").createIndex(new Index()
.on("orderId", Sort.Direction.ASC).on("createdAt", Sort.Direction.DESC)
.named("notes_orderId_createdAt"));
// The shipping rule: count the notes on an order that still need action
mongo.indexOps("notes").createIndex(new Index()
.on("orderId", Sort.Direction.ASC).on("actionRequired", Sort.Direction.ASC)
.on("actionResolvedAt", Sort.Direction.ASC)
.named("notes_orderId_openActions"));
// Lookup by type; the unique index also guarantees that a code appears once in its list
mongo.indexOps("reference_data").createIndex(new Index()
.on("type", Sort.Direction.ASC).on("code", Sort.Direction.ASC)
.unique().named("reference_data_type_code"));
}mongo.indexOps("orders").createIndex(new Index()indexOpsreturns the index operations of one collection.createIndexcreates the index and returns its name. The methodensureIndexdoes the same, but it is deprecated..on("status", Sort.Direction.ASC).on("placedAt", Sort.Direction.DESC)Each
onadds a field to the index, with a direction. An index of several fields is called a compound index. This one holds the status first, and then the time, newest first. That is the filter and the sort of the queryfindByStatus..named("orders_status_placedAt"));The name is the collection and the fields. A later migration can find the index by this name, and drop or change it.
.on("actionResolvedAt", Sort.Direction.ASC)This index serves the count of open actions. It holds the three fields of the condition in
countByOrderIdAndActionRequiredTrueAndActionResolvedAtIsNull..unique().named("reference_data_type_code"));A unique index refuses a second document with the same type and code. See the section Unique and TTL indexes.
Each index has a comment that names its query, an endpoint or a rule. So a reviewer can check two things. Every query has an index, and every index has a query.
A run for this book showed what createIndex sends. An index on a status and a
time, created in the same way, was sent as
{"key": {"status": 1, "createdAt": -1}, "name": "idx_status_createdAt"}. 1 means ascending, and
-1 descending.
In C#, the driver builds the keys with Builders<T>.IndexKeys, and the name
goes into the options:
await notes.Indexes.CreateOneAsync(new CreateIndexModel<Note>(
Builders<Note>.IndexKeys.Ascending(n => n.OrderId).Descending(n => n.CreatedAt),
new CreateIndexOptions { Name = "notes_orderId_createdAt" }));// GET /orders/{orderId}/notes, newest first
mongo.indexOps("notes").createIndex(new Index()
.on("orderId", Sort.Direction.ASC).on("createdAt", Sort.Direction.DESC)
.named("notes_orderId_createdAt"));The C# side names the fields with lambdas, so the compiler checks them. The Java side names them as strings, and the compiler checks nothing. Write the field names exactly as they are stored, and check them in review.
Name every index after its collection and its fields, as the shop does. A short name that
hides a field, such as notes_orderId for the index on orderId and
createdAt, misleads the next reader. As of October 2026, AWS allows index names of 1
to 255 characters on DocumentDB 5.0 and later. On 4.0 and earlier, the collection name and the
index name together may hold only 3 to 63 characters.
Unique and TTL indexes, and what DocumentDB supportstwist#
Two indexes of the service do more than speed up a query. The unique index on
reference_data guarantees that a code appears once in its list. The time-to-live
index of the inbox deletes old documents by itself:
@Override
public String id() {
return "P002-inbox-indexes";
}
@Override
public void migrate(MongoTemplate mongo) {
mongo.indexOps(Inbox.COLLECTION).createIndex(new Index()
.on("processedAt", Sort.Direction.ASC)
.expire(properties.getInbox().getRetention())
.named("processed_messages_ttl"));
}.on("processedAt", Sort.Direction.ASC)A TTL index is on one date field. Here it is the time at which the inbox processed the event.
.expire(properties.getInbox().getRetention())expiresets the time after which the database deletes a document. The settingplatform.messaging.inbox.retentionholds it, 14 days by default. A run showed thatexpirebecameexpireAfterSecondsin the command.
The inbox keeps each key only as long as a repeated message could arrive. Nothing in the service deletes old keys. For the inbox, see the chapter Consuming events: SQS and the inbox.
The runs for this book showed four details of these options:
| Detail | What the run showed |
|---|---|
| expire(Duration.ofMillis(1500)) | expireAfterSeconds 1, because the value is cut to whole seconds without an error |
| .unique() | "unique": true in the command |
| a duplicate on a unique compound index | DuplicateKeyException, E11000, with the name of the index and the values |
| .partial(PartialIndexFilter.of(where("actionRequired").is(true))) | a partialFilterExpression, which indexes only the matching documents |
DocumentDB does not support every kind of index. AWS lists these, as of October 2026:
| Index or property | Engine 5.0 | Engine 8.0 | Note |
|---|---|---|---|
| single field, compound, multikey | yes | yes | every index of the shop is one of these kinds, and some add a property from the rows below |
| unique | yes | yes | a missing field counts as null, so two documents without the field collide |
| TTL | yes | yes | listed for a single field and multikey only, so a compound TTL index is not listed |
| partial | yes | yes | not on 3.6 or 4.0, and only a few operators in the filter |
| sparse | yes | yes | but unique and sparse together on a nested field is not supported |
| hashed, wildcard, 2d | no | no | use a single field or a compound index |
AWS adds two limits for a TTL index. The deletion is best effort, with no guaranteed time. And a TTL index starts to delete only after its build has finished. AWS also limits a collection to 64 indexes, and a compound index to 32 fields.
So never rely on a TTL index for a rule, such as "a reservation is gone after 15 minutes". Check the time in the query or the code too. The inbox can use one, because an old key that stays a little longer does no harm.
In C#, the same options are Unique = true and ExpireAfter in
CreateIndexOptions.
Never edit a migration that rantrap#
The notes of an order must now be listed by category too. A developer opens
V001CreateIndexes, which already creates the notes indexes, and adds one more
index there. It is the obvious place.
Set<String> applied = appliedIds();
for (MongoMigration migration : migrations) {
if (!applied.contains(migration.id())) {
run(migration);
}
}Show the answer
The runner decides by ID, not by content. V001-create-indexes
is in schema_migrations already. So the runner skips it, and no error appears. A
new environment runs the edited V001, and gets the index. So the environments now differ.
Add the index in a new migration, such as V005-index-notes-by-category.
MongoMigrationRunnerTest.skipsTheMigrationsThatHaveRun tests the skipping.
Flyway checks a checksum of every migration that ran, and stops the startup when one changed.
The shop's runner stores no checksum, so an edit goes unnoticed. The rule is therefore in the
comment of MongoMigration and of the migration package. A reviewer must refuse an
edit of a migration that has run anywhere. For Flyway's checksum, see the section
Migrations.
Changing an existing index is harder than adding one. A run for this book tried each
change with createIndex:
| Change | Result |
|---|---|
| the same name, keys and options again | nothing happened, and the name came back, so a migration can run twice |
| the same name, with other keys | DataIntegrityViolationException, error 86, IndexKeySpecsConflict |
| the same keys, with another name | error 85, IndexOptionsConflict, "Index already exists with a different name" |
| the same name and keys, with another TTL | error 85, "An equivalent index already exists with the same name but different options" |
So a changed index needs a new migration that drops the old index by its name, and creates the new one. An edit of the old migration would also fail on every environment that has the index already, with one of the errors above.
Index already exists with a different name#The service has one such change. The shipping rule counts the notes on an order that still need action. V001 gave it an index on three fields of every note. V004 replaces it with a smaller index, which holds only the notes that need action:
/**
* Replaces the index of the shipping rule with a smaller one. V001's index holds every note;
* this one holds only the notes that need action, because the rule counts only those. An index
* cannot be changed in place, so the migration drops the old one by its name and creates the
* new one under a new name.
*
* <p>Dropping an index that does not exist fails, so the old index is dropped only when it is
* there. Creating an index that exists already changes nothing. So the migration is safe to
* run twice. DocumentDB supports partial indexes from version 5.0, on instance-based clusters.
*/
@Component
class V004ReplaceOpenActionsIndex implements MongoMigration {
static final String OLD_INDEX = "notes_orderId_openActions";
static final String NEW_INDEX = "notes_orderId_needingAction";
@Override
public String id() {
return "V004-replace-open-actions-index";
}
@Override
public void migrate(MongoTemplate mongo) {
IndexOperations notes = mongo.indexOps("notes");
boolean oldIndexExists = notes.getIndexInfo().stream().anyMatch(index -> index.getName().equals(OLD_INDEX));
if (oldIndexExists) {
notes.dropIndex(OLD_INDEX);
}
notes.createIndex(new Index()
.on("orderId", Sort.Direction.ASC).on("actionResolvedAt", Sort.Direction.ASC)
.partial(PartialIndexFilter.of(where("actionRequired").is(true)))
.named(NEW_INDEX));
}
}notes.getIndexInfo().stream().anyMatch(index -> index.getName().equals(OLD_INDEX));getIndexInfolists the indexes of the collection. The migration drops the old index only when it is there. A second run finds no old index, so it drops nothing.notes.dropIndex(OLD_INDEX);The old index goes by its name. This is why V001 named every index.
.partial(PartialIndexFilter.of(where("actionRequired").is(true)))A partial index holds only the documents that match its filter. A note without an action is not in this index at all, so the index stays small. A query can use it only when its own filter includes the same condition. The shipping rule's query does, because it asks for notes with
actionRequiredtrue..named(NEW_INDEX));The new index gets a new name. Creating it again with the same name, keys and options changes nothing, as the table above showed.
The test runs V001, then V004 twice, against a real MongoDB:
@Test
void replacesTheIndexOfTheShippingRuleAndCanRunTwice() {
new V001CreateIndexes().migrate(mongo);
new V004ReplaceOpenActionsIndex().migrate(mongo);
new V004ReplaceOpenActionsIndex().migrate(mongo); // the old index is gone, so nothing is dropped
List<IndexInfo> indexes = mongo.indexOps("notes").getIndexInfo();
assertThat(indexes).extracting(IndexInfo::getName)
.contains(V004ReplaceOpenActionsIndex.NEW_INDEX).doesNotContain(V004ReplaceOpenActionsIndex.OLD_INDEX);
assertThat(indexes).filteredOn(index -> index.getName().equals(V004ReplaceOpenActionsIndex.NEW_INDEX))
.singleElement().satisfies(index -> assertThat(index.getPartialFilterExpression()).contains("actionRequired"));
// The query of the shipping rule uses the new index.
Document shippingRuleQuery = new Document("orderId", "order-1").append("actionRequired", true)
.append("actionResolvedAt", null);
Document plan = mongo.getCollection("notes").find(shippingRuleQuery).explain();
assertThat(plan.toJson()).contains(V004ReplaceOpenActionsIndex.NEW_INDEX);
}The test checks three things. The old index is gone, and the new one exists. The new index has
its partial filter. And MongoDB's plan for the shipping rule's query names the new index, so
the query really uses it. explain() returns that plan as a document.
Check the index features of the database you run. Amazon DocumentDB supports partial indexes
on its 5.0 and 8.0 engines, on instance-based clusters, and not on elastic clusters, as of
October 2026. Its supported filter operators include $eq, which
is(true) uses. The test ran on MongoDB 8.0.34, not on DocumentDB.
Seed data follows the same rule. See the section Seed data, upserted by a natural key.
Safe to run twice, and no transactionnew#
The runner runs a migration, and records it afterwards. An instance can crash between the two steps, for example when the orchestrator stops it. The next start finds no record, and runs the same migration again. So every migration must be safe to run twice. This property is called idempotent.
The comment of V001CreateIndexes says so for indexes:
/**
* One named index for each query the service runs. A query without an index reads the whole
* collection. The names say what each index is for, so a later migration can find and change it.
* Creating an index that exists already, with the same keys and options, changes nothing, so the
* migration is safe to run twice.
*/
@Component
class V001CreateIndexes implements MongoMigration {The last sentence of the comment is why V001CreateIndexes can run twice. The two
kinds of migration in the shop are idempotent by nature:
- An index.
createIndexwith the same name, keys and options changes nothing. The run in the section above showed it. - Seed data. An upsert by a natural key updates the existing value, or inserts it once. A second run changes nothing.
A migration that changes stored documents must be written for it. Change only the documents that still need the change, such as those where the new field is missing. Never add or subtract a value relative to the stored one, because a second run would apply it twice.
A transaction would seem to solve the crash. The migration and its record would commit
together. But the platform uses no transaction in migrations. AWS states that a transaction on
Amazon DocumentDB cannot create a collection. MongoDB behaves differently. In a run for this book on MongoDB
8.0.34, the first index and the first insert each created a missing collection, also inside a
transaction. A migration often creates an index or inserts a document. The comment of MongoMigration
gives the reason in one line.
For indexes themselves, AWS states no rule. The research checked the pages on transactions, on indexes and on the functional differences, and AWS's table of supported commands, in October 2026. None says whether DocumentDB builds an index inside a transaction. The only link is an AWS support article, which says that long-running transactions can delay index creation, or make it fail. Mongock's documentation also keeps structure changes on MongoDB out of its transactions. So keep index changes out of transactions. Then the question never arises.
So idempotence is the only protection. It is enough, because an index and an upsert are naturally safe to repeat.
Collections first, outside any transactiontwist#
foreach (string name in new[] { "orders", "notes", "reference_data" })
{
if (!(await database.ListCollectionNames().ToListAsync()).Contains(name))
{
await database.CreateCollectionAsync(name);
}
}/**
* Creates the service's collections before a use case writes to them inside a transaction,
* which Amazon DocumentDB cannot do for a collection that does not exist. On a new database,
* the ID sorts first, so the collections exist before V001 creates their indexes.
*/
@Component
class V000CreateCollections extends CreateCollections {
V000CreateCollections() {
super("V000-create-collections", List.of("orders", "notes", "reference_data"));
}
}class V000CreateCollections extends CreateCollections {CreateCollectionsis a migration class of the platform. It creates each collection of its list that does not exist yet. The service only gives it an ID and the names.super("V000-create-collections", List.of("orders", "notes", "reference_data"));The ID has the number 000, so it sorts before
V001-create-indexes. The three names are the collections of the three features.
A new environment has no collections. On Amazon DocumentDB, the first use case would then fail, because its transaction cannot create the collection that it writes to. MongoDB creates the collection on the fly, so a local test never shows the problem. So the collections are created at startup, in a migration, and no migration runs in a transaction.
CreateCollections asks collectionExists before it creates a collection.
So a second run changes nothing, and the migration is safe to run twice. The platform creates its
own collections the same way, in P000-audit-log-collection and
P000-messaging-collections. A new feature with a new collection adds it in a new
migration, because V000 has run already. For the platform's class, the DocumentDB
limit and the test CreateCollectionsIT, see the section
The limits of a transaction on Amazon DocumentDB.
The C# pane does the same with the MongoDB .NET driver. It lists the names, and creates each
missing collection with CreateCollectionAsync.
Seed data, upserted by a natural keynew#
An agent chooses a delivery method when they place an order. The service checks that the code is an active value of the list. The list must exist in every environment, the same everywhere, and be versioned with the code. So the values are JSON files in the service:
[
{ "code": "STANDARD", "label": "Standard delivery (3 to 5 days)", "active": true },
{ "code": "EXPRESS", "label": "Express delivery (next day)", "active": true },
{ "code": "COLLECT", "label": "Collect from a store", "active": true },
{ "code": "SECOND_CLASS_POST", "label": "Second-class post (no longer offered)", "active": false }
]The code SECOND_CLASS_POST is no longer offered, so it is inactive. It is not
deleted. Orders from last year still store this code, and the service must still show its
label. The other file, note-categories.json, keeps PHONE_CALL in the
same way.
The second migration loads both files:
/**
* Loads the lookup values from {@code resources/seed/}. Each value is upserted by its type and
* code, so the migration is safe to run twice.
*
* <p>To change the values, edit the JSON file and add a new migration that runs the same upserts.
* Never edit this one: environments where it has run would not see the change.
*/
@Component
class V002SeedReferenceData implements MongoMigration {
private static final String COLLECTION = "reference_data";
@Override
public String id() {
return "V002-seed-reference-data";
}
@Override
public void migrate(MongoTemplate mongo) {
SeedFiles.upsert(mongo, COLLECTION, "seed/delivery-methods.json",
Map.of("type", ReferenceTypes.DELIVERY_METHOD), "code");
SeedFiles.upsert(mongo, COLLECTION, "seed/note-categories.json",
Map.of("type", ReferenceTypes.NOTE_CATEGORY), "code");
}
}SeedFiles.upsert(mongo, COLLECTION, "seed/delivery-methods.json",The platform's helper reads the file from the classpath, and upserts each entry into
reference_data.Map.of("type", ReferenceTypes.DELIVERY_METHOD), "code");Every entry of the file gets the field
type, with the valueDELIVERY_METHOD. The type and the code together identify an entry. That pair is the natural key, a key that comes from the business, not from the database.
The helper does the upserts:
await referenceData.UpdateOneAsync(
r => r.Type == type && r.Code == code,
Builders<ReferenceItem>.Update.Set(r => r.Label, label).Set(r => r.Active, active),
new UpdateOptions { IsUpsert = true });public static void upsert(MongoTemplate mongo, String collection, String classpathFile,
Map<String, Object> fixedFields, String keyField) {
for (Document entry : read(classpathFile)) {
Criteria key = Criteria.where(keyField).is(entry.get(keyField));
fixedFields.forEach((field, value) -> key.and(field).is(value));
Update values = new Update();
entry.forEach((field, value) -> {
if (!field.equals(keyField)) {
values.set(field, value);
}
});
mongo.upsert(Query.query(key), values, collection);
}
}
/** Reads a JSON array of objects. BSON parses only objects, so the array is wrapped in one. */
public static List<Document> read(String classpathFile) {
try {
String json = new ClassPathResource(classpathFile).getContentAsString(StandardCharsets.UTF_8);
return Document.parse("{\"entries\": " + json + "}").getList("entries", Document.class);
} catch (IOException e) {
throw new UncheckedIOException("Could not read the seed file " + classpathFile, e);
}
}Criteria key = Criteria.where(keyField).is(entry.get(keyField));The filter starts with the code of the entry.
fixedFields.forEach((field, value) -> key.and(field).is(value));Each fixed field joins the filter. So the filter is "this code in this type". A
Criteriacan hold each field only once. A run showed that a second condition on the same field throwsInvalidMongoDbApiUsageException.values.set(field, value);Every field of the entry except the code becomes a
$set. So the label and the flagactiveare written.mongo.upsert(Query.query(key), values, collection);upsertupdates the matching document, or inserts one when none matches. A run showed one update command withupsert: true. The first call inserted, and the next calls updated.String json = new ClassPathResource(classpathFile).getContentAsString(StandardCharsets.UTF_8);ClassPathResourcefinds a file on the classpath, such asseed/delivery-methods.jsoninside the Java archive (JAR) of the service.getContentAsStringreads it as text in UTF-8.return Document.parse("{\"entries\": " + json + "}").getList("entries", Document.class);Document.parsereads only a JSON object, not an array. So the helper wraps the array in an object with one field, and reads that field as a list.throw new UncheckedIOException("Could not read the seed file " + classpathFile, e);getContentAsStringthrows the checkedIOException. The helper wraps it in the uncheckedUncheckedIOException, somigrateneeds nothrowsclause. The startup fails, with the file in the message.
The C# version is one call per entry, with IsUpsert = true. The Java helper works
for any seed file, because it builds the filter and the update from the JSON.
A run showed that an upsert with unchanged values reports matched=1 modified=0.
That is not an error. It means that the value was already right.
Removing a field from a seed file does not remove it from the database. The helper sets only the fields that are in the file. A field that was in an earlier version of the file stays in the stored document. To remove one, write a migration that unsets it.
The reference feature reads the values, and the order feature checks them:
public void requireActive(String type, String code, String field) {
boolean known = items.findByTypeOrderByCodeAsc(type).stream()
.anyMatch(item -> item.code().equals(code) && item.active());
if (!known) {
throw new InvalidValueException(field, "is not a known value");
}
}requireActive accepts a code only when item.active() is true. So a
new order needs an active value. A stored order with an inactive value is still read and
shown. The journey test
anInactiveDeliveryMethodIsRejectedBeforeTheStockServiceIsCalled places an order with
SECOND_CLASS_POST, and expects a 400 for the field deliveryMethod.
To change a value, follow the comment of V002. Edit the JSON file, and add a new
migration, such as V005-seed-reference-data-again, that runs the same upserts. The
order matters:
- An environment that ran
V002skips it. It runsV005, which reads the edited file. - A new environment runs
V002with the edited file, and thenV005with the same file. The second run changes nothing.
So every environment ends with the same values. Editing only the JSON file would change new environments and leave the old ones as they were.
Never delete a value. Mark it "active": false. A deleted code would leave stored
orders and notes with a code that the service cannot explain, and the label would be lost.
Testing the runner and the migrationsnew#
The runner has a unit test with a mocked MongoTemplate, and an integration test
against MongoDB. The unit test checks the order of runs:
@Test
void runsThePendingMigrationsInIdOrder() {
MongoMigration second = migration("V002-seed");
MongoMigration first = migration("V001-indexes");
MongoMigration platform = migration("P001-audit");
when(mongo.findAll(Document.class, MongoMigrationRunner.HISTORY)).thenReturn(List.of());
runner(List.of(second, first, platform), Duration.ofMinutes(1)).afterSingletonsInstantiated();
InOrder order = inOrder(platform, first, second);
order.verify(platform).migrate(mongo);
order.verify(first).migrate(mongo);
order.verify(second).migrate(mongo);
}when(mongo.findAll(Document.class, MongoMigrationRunner.HISTORY)).thenReturn(List.of());The mocked history is empty, so every migration is pending. For Mockito in this service, see the chapter Testing in depth.
runner(List.of(second, first, platform), Duration.ofMinutes(1)).afterSingletonsInstantiated();The list is in the wrong order on purpose. The test calls the method that Spring would call.
InOrder order = inOrder(platform, first, second);InOrderchecks the order of the calls. The platform's migration comes first, thenV001, thenV002.
The other unit tests cover the rest of the runner without a database:
| Test | What it proves |
|---|---|
| skipsTheMigrationsThatHaveRun | a recorded ID is skipped, and only the pending migration is recorded |
| refusesTwoMigrationsWithTheSameId | the runner fails at creation, with the ID in the message |
| givesUpWhenAnotherInstanceHoldsTheLockTooLong | a lock that never goes away ends in "Timed out waiting for the migration lock" |
| releasesTheLockWhenAMigrationFails | a failing migration still removes the lock |
The integration test runs the lock against a real MongoDB, because the lock depends on the
unique _id:
@Test
void waitsForALiveLockAndThenGivesUp() {
mongo.insert(new Document("_id", "lock").append("owner", "another-instance")
.append("expiresAt", Instant.now().plus(Duration.ofMinutes(10))), MongoMigrationRunner.LOCK);
assertThatThrownBy(() -> runner(List.of(recording("V001-a")), Duration.ofMillis(300)).afterSingletonsInstantiated())
.hasMessage("Timed out waiting for the migration lock");
assertThat(ran).isEmpty();
}
@Test
void takesOverAnExpiredLockLeftByACrashedInstance() {
mongo.insert(new Document("_id", "lock").append("owner", "crashed-instance")
.append("expiresAt", Instant.now().minusSeconds(1)), MongoMigrationRunner.LOCK);
runner(List.of(recording("V001-a")), Duration.ofMinutes(1)).afterSingletonsInstantiated();
assertThat(ran).containsExactly("V001-a");
}.append("expiresAt", Instant.now().plus(Duration.ofMinutes(10))), MongoMigrationRunner.LOCK);The first test inserts a live lock of another instance, which expires in ten minutes.
runner(List.of(recording("V001-a")), Duration.ofMillis(300))The runner gets a TTL of 300 milliseconds, so the test gives up quickly. A test constructor of the runner also takes a short poll interval.
.append("expiresAt", Instant.now().minusSeconds(1)), MongoMigrationRunner.LOCK);The second test inserts a lock that expired a second ago. The runner removes it, takes the lock, and runs the migration.
java.lang.IllegalStateException: Timed out waiting for the migration lock#platform.mongo.migrations.lock-ttl. Its migrations may still be running.SeedFilesTest.readsAJsonArrayOfObjects reads a small test file and checks the
values, with no database. CreateCollectionsIT runs the collection migration twice
against a real MongoDB. NoteMigrationsIT runs V003 and V004
twice each, on raw documents and real indexes. The journey test covers all the service's
migrations at once. OrderJourneyIT.lookupValuesAreSeededAndCached reads the delivery
methods through the API, and finds SECOND_CLASS_POST with active false.
So the collections, the indexes and the seed data exist when the service starts against an empty
MongoDB.
1An expert asksThe migration lock is the insert of a document with the fixed
_id lock. Why not read the collection first, and insert a lock only when
none is there?
_id lock. Why not read the collection first, and insert a lock only when
none is there?_id lets the database decide, because the _id is unique.
Exactly one insert succeeds. The other gets DuplicateKeyException, and waits. The
expiry time lets a crashed instance's lock go away.2An expert asksWhy are lookup values upserted by their type and code, and never deleted?
"active": false. New data cannot use it,
and old data can still show it.3Why does the migration P001-audit-log-indexes run before
V001-create-indexes?
P001-audit-log-indexes run before
V001-create-indexes?P sorts before V, so the
platform's migrations run before every service migration. The numbers are padded to three digits,
because V10 would sort before V2.4Why does the shop use its own migration runner, as of October 2026?
5TranslateWrite this index as a call in a migration of the order service, on the
MongoTemplate named mongo:
await notes.Indexes.CreateOneAsync(new CreateIndexModel<Note>(
Builders<Note>.IndexKeys.Ascending(n => n.OrderId).Descending(n => n.CreatedAt),
new CreateIndexOptions { Name = "notes_orderId_createdAt" }));
MongoTemplate named mongo:
await notes.Indexes.CreateOneAsync(new CreateIndexModel<Note>(
Builders<Note>.IndexKeys.Ascending(n => n.OrderId).Descending(n => n.CreatedAt),
new CreateIndexOptions { Name = "notes_orderId_createdAt" }));mongo.indexOps("notes").createIndex(new Index()
.on("orderId", Sort.Direction.ASC).on("createdAt", Sort.Direction.DESC)
.named("notes_orderId_createdAt"));indexOps("notes") selects the collection. Each on adds a field with
its direction, and named sets the name. The field names are strings, so they must
match the stored names exactly.
Quick reference#
The settings of migrations and indexes:
| Setting | Default | What it does |
|---|---|---|
| platform.mongo.migrations.enabled | true | runs the migration runner at startup, and tests set false |
| platform.mongo.migrations.lock-ttl | PT15M | how long a migration lock lasts, and how long an instance waits for one |
| spring.data.mongodb.auto-index-creation | false, set by the platform | Spring creates no index from @Indexed at startup |
| platform.messaging.inbox.retention | P14D | the TTL of the inbox index processed_messages_ttl |
The indexes of the order service and the platform:
| Index | Collection | Fields | Serves |
|---|---|---|---|
| orders_status_placedAt | orders | status, placedAt descending | GET /orders?status=, newest first |
| notes_orderId_createdAt | notes | orderId, createdAt descending | GET /orders/{orderId}/notes, newest first |
| notes_orderId_needingAction | notes | orderId, actionResolvedAt, partial on actionRequired true | the shipping rule's count of open actions, since V004 replaced notes_orderId_openActions |
| reference_data_type_code | reference_data | type, code, unique | the lookup lists, and one code per list |
| audit_log_resource | audit_log | resourceType, resourceId, at | the history of one resource |
| processed_messages_ttl | processed_messages | processedAt, TTL | deletes inbox keys after the retention |
| event_publication_incomplete | event_publication | completionDate, publicationDate | the outbox queries for unsent rows, oldest first |
The calls of the Index builder in Spring Data MongoDB 5.1:
| Call | What it adds |
|---|---|
| on(field, Sort.Direction.ASC) | a field, ascending, and each further call adds a field to a compound index |
| named(name) | the name of the index |
| unique() | refuses a second document with the same values |
| expire(Duration) | a TTL, cut to whole seconds |
| partial(PartialIndexFilter.of(criteria)) | indexes only the documents that match |
| sparse() | leaves out documents that lack the field |
Transactions in depth#
Each change in the order service runs in one transaction. The transaction stores the
aggregate, its outbox events and its audit record together, or none of them. The platform
registers the transaction manager for MongoDB. This chapter covers @Transactional
and TransactionTemplate, joining, rollback, write conflicts and retries, the limits
of Amazon DocumentDB, and tests without a database.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | session.StartTransaction() around every change | one transaction for each use case that changes data | the change, its outbox row and its audit record are committed together Why |
| trap | no equivalent | a MongoTransactionManager bean from the platform | without it, @Transactional does nothing on MongoDB, and every write commits on its own Why |
| twist | no equivalent | spring.modulith.events.mongodb.transaction-management.enabled=false | Spring Modulith would register a transaction manager too, so the platform keeps one Why |
| twist | session.WithTransactionAsync(...) around the method body | @Transactional on the use case | a proxy starts the transaction, and every repository call joins it without a session parameter Why |
| trap | TransactionScope around calls of the MongoDB driver | @Transactional with a MongoTransactionManager | the plain .NET driver ignores TransactionScope, so a scope that is not completed rolls nothing back Why |
| new | no equivalent | transaction.execute(status -> ...) after the call to the stock service | only the lambda is transactional, so a slow HTTP call never holds a transaction open Why |
| twist | passing the same session to a second method | propagation REQUIRED, the default | the inner method joins the running transaction, so the order and its notes commit together Why |
| trap | catching an exception of an inner call, and committing | catching an exception that an inner @Transactional method threw | the inner method marked the transaction rollback-only, so the commit throws UnexpectedRollbackException Why |
| twist | no equivalent | domain exceptions that extend RuntimeException | every broken rule rolls the transaction back, with no rollbackFor Why |
| new | no equivalent | MongoAuditLog and the outbox, both inside the use case's transaction | an audit record or an event is stored only with the change it describes Why |
| trap | no equivalent | a publisher that throws outside a transaction | an outbox row written outside a transaction is never sent, so the platform refuses to write it Why |
| trap | WithTransactionAsync retries a TransientTransactionError | Spring retries nothing | a write conflict, error 112, reaches the caller as DataIntegrityViolationException Why |
| trap | no equivalent | a transaction on Amazon DocumentDB | as of October 2026, it may run one minute, write less than 32 MB, and create no collection Why |
| twist | no equivalent | MongoDB as a one-node replica set, locally and in tests | MongoDB supports transactions only on a replica set Why |
| new | no equivalent | one aggregate per transaction, except an order with its first notes | a second aggregate in one transaction is a sign to use an event, unless one user action creates both Why |
| twist | a mocked IClientSessionHandle | a real TransactionTemplate with NoOpTransactionManager | a unit test runs the transactional code with no database, and the publisher still sees a transaction Why |
What one transaction holds in the order servicenew#
A warehouse worker ships an order. Three things must be stored. The order gets the status
SHIPPED. The event OrderShipped goes into the outbox, so the shop's
other services hear about it. And the audit log records who shipped the order. If the service
stored only some of the three, the data would contradict itself. An order could be shipped with
no event, or an event could announce a shipment that never happened.
So every use case that changes data runs in one transaction. The transaction commits all three writes, or none of them. The simplest use case shows the shape:
@Transactional
public void pickLine(OrderId orderId, LineId lineId) {
Order order = orders.get(orderId);
order.pickLine(lineId, clock);
saveAndPublish(order, AuditActions.LINE_PICKED);
}saveAndPublish saves the order, publishes its events and records the audit, all
inside the transaction that @Transactional started. The use cases of the order
service use four forms:
| Use case | How it gets its transaction | Why this form |
|---|---|---|
| OrderService.place | TransactionTemplate, after the stock call | only part of the method may be transactional |
| pickLine, ship, cancel, addNote, resolveAction | @Transactional on the method | the whole method is the change |
| OrderService.recordPayment | @Transactional, joining the inbox's transaction | the inbox key and the payment commit together |
| NoteService.addNotes | @Transactional, joining place | the order and its first notes commit together |
| get, list, countNeedingAction | no transaction | a read changes nothing, so it needs no transaction |
For the basics of @Transactional, propagation and the rollback rule, see the
chapter Transactions, propagation and isolation. This
chapter shows how the order service uses them on MongoDB and Amazon DocumentDB.
The transaction manager, and what happens without ittrap#
A transaction manager is the object that begins, commits and rolls back transactions for Spring. Spring Boot creates one for a relational database. For MongoDB, Boot 4.1 creates none. The platform therefore registers one for every service:
/**
* Sets up DocumentDB for every service: transactions, the driver settings DocumentDB needs,
* and the migration runner.
*
* <p>Spring Boot creates no transaction manager for MongoDB. Without one, {@code @Transactional}
* silently does nothing: each write commits on its own. This class registers a
* {@link MongoTransactionManager} before Spring Boot's transaction setup runs, so
* {@code @Transactional} and {@code TransactionTemplate} both use it.
*/
@AutoConfiguration(beforeName = {
"org.springframework.boot.data.mongodb.autoconfigure.DataMongoAutoConfiguration",
"org.springframework.boot.transaction.autoconfigure.TransactionAutoConfiguration"})
@EnableConfigurationProperties(DocumentDbProperties.class)
public class PlatformMongoAutoConfiguration {
@Bean
@ConditionalOnMissingBean(TransactionManager.class)
MongoTransactionManager transactionManager(MongoDatabaseFactory databaseFactory) {
return new MongoTransactionManager(databaseFactory);
}@AutoConfiguration(beforeName = {Spring Boot processes this class before the two Boot classes it names. A run showed that Boot adds its
TransactionTemplatebean only when a transaction manager exists. So the manager must be registered first. For the ordering of auto-configurations, see the chapter Writing the platform library.@ConditionalOnMissingBean(TransactionManager.class)Spring creates this bean only when the service has no transaction manager of its own. A service that needs another manager declares it, and the platform's manager is not created.
MongoTransactionManager transactionManager(MongoDatabaseFactory databaseFactory) {MongoTransactionManageris the transaction manager of Spring Data MongoDB.MongoDatabaseFactorygives it the database. Boot creates the factory from the connection settings.
A run for this book showed both states on Boot 4.1.2:
| Question | With no manager | With a MongoTransactionManager |
|---|---|---|
| TransactionManager beans | none | one, named transactionManager |
| TransactionTemplate beans | none | one, which Boot adds |
| Is the service a proxy? | no | yes |
| isActualTransactionActive() inside @Transactional | false | true |
| What the driver sent | each insert on its own, as a retryable write | startTransaction with the first write, then commit or abort |
| An unchecked exception after an insert | the document stayed | the document was rolled back |
| A bean that needs a PlatformTransactionManager | the startup failed | the startup succeeded |
The worst row is the second to last. Without the manager, @Transactional compiles,
the application starts, and nothing warns you. Each write commits on its own. For the run as a
prediction, see the section Transactions.
No qualifying bean of type 'org.springframework.transaction.PlatformTransactionManager' available#MongoTransactionManager bean, or add the platform library, which
declares one.The wiring test checks the bean without Docker.
ApplicationWiringTest.theContextStartsWithThePlatformsBeans expects the
PlatformTransactionManager to be a MongoTransactionManager.
The condition names TransactionManager, and the inbox asks for a
PlatformTransactionManager. The two types differ. The bytecode of Spring Framework
7.0.10 shows how they relate:
| Type | What it is | Who uses it in the service |
|---|---|---|
| TransactionManager | an interface with no methods, the parent of every kind of transaction manager | the platform's condition, so any kind of manager counts as present |
| PlatformTransactionManager | the interface for blocking code, with getTransaction, commit and rollback | TransactionTemplate and @Transactional on ordinary methods |
| ReactiveTransactionManager | the interface for reactive code | nothing in the order service |
MongoTransactionManager extends AbstractPlatformTransactionManager,
which implements PlatformTransactionManager. So the platform's condition backs off
for any manager, also a reactive one. And the inbox's constructor
can ask for the blocking kind that TransactionTemplate needs.
One transaction manager, also for Spring Modulithtwist#
The outbox of the order service is Spring Modulith's event publication registry. Spring
Modulith's MongoDB module can create its own transaction manager. A property switches that on,
and its default is true. The platform switches it off:
# The outbox: Spring Modulith's event publication registry, collection "event_publication".
# Delete a publication once it has been sent, so the collection stays small.
spring.modulith.events.completion-mode=delete
# The platform's mongo package already provides the transaction manager.
spring.modulith.events.mongodb.transaction-management.enabled=falsespring.modulith.events.mongodb.transaction-management.enabled=falseWith
true, Spring Modulith registers aMongoTransactionManagerand aTransactionTemplate. Both are conditional on a missing bean, as the platform's own manager is. The metadata of the property says that it requires a replica set.
Both registrations back off when a manager exists. So the setting looks unnecessary. It is still useful, because it removes a question of order. Without it, the service would get the manager of whichever auto-configuration ran first. With it, there is exactly one place that creates the manager, the platform's mongo package. A reader who looks for the manager finds one answer. For the outbox itself, see the chapter Publishing events: the outbox.
@Transactional on a use casetwist#
Most use cases are transactional from their first line to their last. They carry
@Transactional:
using IClientSessionHandle session = await client.StartSessionAsync();
await session.WithTransactionAsync(async (s, ct) =>
{
await orders.ReplaceOneAsync(s, o => o.Id == order.Id, order, cancellationToken: ct);
return true;
});/** Ships the order. The rule across the order and its notes is checked by the domain service. */
@Transactional
public void ship(OrderId orderId) {
Order order = orders.get(orderId); // load
long notesNeedingAction = notes.countNeedingAction(orderId.value()); // ask the note feature
shippingPolicy.assertCanShip(order, notesNeedingAction); // rule across aggregates
order.ship(clock); // rule and change
saveAndPublish(order, AuditActions.ORDER_SHIPPED); // save, publish, audit
}await orders.ReplaceOneAsync(s, o => o.Id == order.Id, order, cancellationToken: ct);In C#, every call that belongs to the transaction takes the session
s. A call without it compiles, and runs outside the transaction.@TransactionalA proxy around
OrderServicestarts a transaction before the method runs. It commits when the method returns, and rolls back when an unchecked exception leaves the method.Order order = orders.get(orderId); // loadThe repository takes no session.
MongoTemplatefinds the transaction that is bound to the current thread, and joins it. So the read, the count of notes, the save, the outbox row and the audit record all run in the same transaction.
The C# code needs a session parameter on every call. The Java code needs none, because the transaction is bound to the thread. The cost is the proxy. A call from another method of the same class skips it, and runs with no transaction. For the proxy trap, see the section The proxy trap, again.
The plain MongoDB .NET driver ignores TransactionScope. A C#
developer may expect an ambient transaction, as with SQL Server. A run for this book wrapped an
insert of the MongoDB .NET driver in a TransactionScope, and disposed the scope
without Complete(). The document stayed, and nothing threw. The MongoDB EF Core
Provider goes further, and throws when it meets an ambient transaction. So the .NET match for
@Transactional on MongoDB is a session, not a scope.
The reads of the service carry no @Transactional. Spring Data's reference says
that @Transactional(readOnly = true) still starts a transaction on MongoDB, with the
session on every request. A read of one document needs no transaction, so the service saves that
cost.
On Amazon DocumentDB, AWS states that a transaction's read concern of local, available or
majority is upgraded to snapshot. The service sets no isolation on
@Transactional. For isolation levels in general, see the section Isolation.
TransactionTemplate, when only part of a method is transactionalnew#
Placing an order needs the stock service. It must hold the products before the order exists. That call goes over HTTP, and it can take seconds. If the call ran inside the transaction, the transaction would stay open for those seconds. On Amazon DocumentDB, a transaction may run for one minute at most, as of October 2026. A slow stock service would then abort the order's transaction.
The rule of the service is short. Call other services before the transaction starts. So
place cannot carry @Transactional, which covers the whole method. It
uses a TransactionTemplate for the part that writes:
/**
* Places an order and saves its first notes with it, in one transaction: the order and all
* its notes are stored, or none are.
*
* <p>The stock call happens before the transaction starts. A DocumentDB transaction has time
* and size limits, and a slow HTTP call inside it would hold it open. That is why this method
* uses {@link TransactionTemplate} instead of {@code @Transactional}: only the lambda is
* transactional.
*
* <p>The order ID is chosen first, because it is the idempotency key of the reservation.
*/
// ...
StockAvailability availability = stock.reserve(orderId, items); // outside the transaction
return transaction.execute(status -> {StockAvailability availability = stock.reserve(orderId, items); // outside the transactionThe stock call runs first, with no transaction open. If the stock service is slow or down, no transaction waits for it.
return transaction.execute(status -> {executebegins a transaction, runs the lambda, and commits. It returns the value of the lambda.statusis theTransactionStatus, which the lambda could use to mark a rollback.
For the whole method and the order of its steps, see the section Placing an order: the stock call before the transaction. For the template bean that Boot adds, see the section The TransactionTemplate bean.
A run for this book measured the template that Boot creates:
| Case | What happened |
|---|---|
| the defaults | propagation REQUIRED, and the default isolation and timeout |
| execute with a value | the value came back, in a new transaction that committed |
| executeWithoutResult with status.setRollbackOnly() | the transaction rolled back, and nothing was thrown |
| an unchecked exception in the callback | the transaction rolled back, and the same exception reached the caller |
| a checked exception, thrown in the callback | the transaction rolled back, and the caller got UndeclaredThrowableException |
| an execute inside another execute | the inner call joined the outer transaction |
The fifth row differs from @Transactional. A checked exception rolls the template
back, but by default it commits an annotated method. For the annotation's rule, see the section
The rollback rule.
The stock call outside the transaction leaves one gap. The stock service may hold the
products, and then the transaction fails. The order is not stored, and the hold stays. The
service closes the gap in two ways. The order ID is chosen first, and sent as the idempotency
key of the reservation. And the stock service releases a hold when no
OrderPlacedV1 arrives in time. For that call, see the chapter
Calling other services.
The other way is fair too. Put the transactional part in its own bean,
such as OrderPlacement, with a method marked @Transactional. The use case
calls the stock service, and then that bean. The style stays declarative. But the step list of
the use case moves into two classes, and a reader must open both. The template keeps the whole
use case in one method, with the transaction boundary in plain sight. Choose the second bean
when the transactional part is large, or when several use cases share it.
In C#, the same split is the code before and after StartTransaction on a
session. The Java template is the closest match for the session's
WithTransactionAsync, but it does not retry. See the section
Write conflicts, and what Spring retries.
Joining the caller's transactiontwist#
Placing an order also saves its first notes. The note feature does that, in
NoteService.addNotes, a method with @Transactional. Its propagation is
REQUIRED, the default. A transaction is already running, the one that
place started. So addNotes joins it, and starts none of its own. In a
run for this book, an inner method saw the name of the outer transaction, and both inserts
carried the same transaction number. If a caller had no transaction, addNotes would
start one. For the method and its journey test, see the section
One request that saves the order and its first notes.
The payment works in the same way. The inbox starts a transaction, and the handler calls
OrderService.recordPayment, which joins it. The inbox builds its own template from
the transaction manager:
public Inbox(MongoTemplate mongo, PlatformTransactionManager transactionManager, Clock clock) {
this.mongo = mongo;
this.transactions = new TransactionTemplate(transactionManager);
this.clock = clock;
}new TransactionTemplate(transactionManager) creates a template with the
default settings. The inbox runs its work with executeWithoutResult, the variant
for a lambda that returns nothing. The key of the event and the recorded payment then commit
together. For the inbox, see the chapter Consuming events: SQS and the
inbox.
For the other propagation modes, see the section Propagation.
The order service uses only REQUIRED.
Catching the exception of a joined method does not save the transaction. A
method that joined the transaction throws. The proxy of that method marks the whole transaction
as rollback-only. The caller catches the exception and returns normally. Spring then tries to
commit, finds the mark, and rolls back. In a run for this book, the caller got
UnexpectedRollbackException, and the driver sent abortTransaction. No
document remained. In the order service, never catch a domain exception inside a transaction to
continue. Let it leave the use case.
org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only#Rollback, and the domain's exceptionstwist#
Spring rolls a transaction back for an unchecked exception, and commits for a checked one.
Every exception of the domain is unchecked, because the base class extends
RuntimeException:
public abstract sealed class DomainException extends RuntimeException
permits NotFoundException, InvalidValueException, BusinessRuleViolationException {public abstract sealed class DomainException extends RuntimeExceptionEvery domain exception is a
RuntimeException, so every broken rule rolls the transaction back. No method needsrollbackFor.permits NotFoundException, InvalidValueException, BusinessRuleViolationException {The class is sealed, so only these three kinds may extend it. For the kinds and their statuses, see the chapter The domain model in plain Java.
In C#, every exception is unchecked, so a C# developer has no habit for this rule. The rule
matters only where code throws a checked
exception inside a transaction, such as an IOException. The service has no such
code inside a transaction. The platform's helpers wrap checked exceptions in unchecked ones, for
example UncheckedIOException in the seed loader.
A rule can also fail after a save, inside the same transaction. For that case, see the section One aggregate per transaction, and the order with its notes.
The outbox row and the audit record in the same transactionnew#
A use case saves an order, publishes its events and records the audit. The three writes go to three collections, through three pieces of code. None of them takes a transaction parameter. They all join the transaction of the thread. The audit log says so in its comment:
/**
* Writes audit records to the {@code audit_log} collection.
*
* <p>{@link MongoTemplate} joins the transaction that is running on the thread. A use case
* that records audit inside its transaction therefore stores the record only with its change.
*/
public class MongoAuditLog implements AuditLog {MongoAuditLog inserts a raw document with MongoTemplate. The outbox
works in the same way. Spring Modulith writes one row for each transactional listener of an
event, in the current transaction. The comment of the platform's publisher explains it:
* <p>Spring Modulith writes one row to {@code event_publication} for each transactional listener
* of an event, in the current transaction. The row is therefore stored only if the change is
* stored. An event with no such listener writes no row and reaches no one.So a rollback removes all three writes. A failed shipment leaves no event in
event_publication and no record in audit_log. After the commit, Spring
Modulith sends the events, outside the transaction. For the sending, see the chapter
Publishing events: the outbox.
The other way is to write the audit record in a transaction of its own, with
REQUIRES_NEW. Then a failed attempt leaves a record too. That suits an audit of
attempts, such as failed logins. The order service audits changes, not attempts. A record of a
change that was rolled back would be false, so the record shares the change's transaction.
Refusing to publish outside a transactiontrap#
A developer adds a scheduled job that changes an order, and forgets the transaction. The job publishes an event. Spring Modulith writes the outbox row at once, but it sends events only after a commit, and no commit comes. So the row is stored and never sent. The platform's publisher therefore checks for a transaction before it publishes anything:
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
throw new IllegalStateException(
"Publish domain events inside the use case's transaction, so they are stored with the change");
}TransactionSynchronizationManager holds the transaction state of the current
thread. isActualTransactionActive() is true only when a real transaction runs on
this thread. A run showed it false inside @Transactional with no transaction
manager, and true with one. So the check also catches a service that has no transaction manager.
For the run of an event published with no transaction, and the whole publisher, see the section
Publishing outside a transaction.
java.lang.IllegalStateException: Publish domain events inside the use case's transaction, so they are stored with the change#@Transactional or a
TransactionTemplate.The tests of the publisher run it inside a TransactionTemplate with a transaction
manager that does nothing. See the section A transaction manager that does
nothing, for unit tests.
Write conflicts, and what Spring retriestrap#
Two transactions change the same document at the same moment. MongoDB lets only one of them write. It aborts the other with a write conflict. The research for this book measured the cases:
| Case | What MongoDB 8.0.34 answered | What Spring threw |
|---|---|---|
| two transactions insert the same _id, and the first has not committed | error 112, WriteConflict, at once, with the label TransientTransactionError | DataIntegrityViolationException |
| the second transaction started before the first committed, and wrote after it | error 112, WriteConflict | DataIntegrityViolationException |
| the first committed before the second transaction began | error 11000, a duplicate key, with no label | DuplicateKeyException |
| a write outside any transaction, against an open transaction | the write waited until the other transaction committed, and then failed with 11000 | DuplicateKeyException |
| two transactions update the same versioned document | error 112, before the version check | DataIntegrityViolationException |
A label is a tag on a MongoDB error. TransientTransactionError says that the
whole transaction may succeed if it runs again. The MongoDB manual says that the operations
inside a transaction are not retryable on their own. Only the whole transaction is.
Spring Data translates error 112 to DataIntegrityViolationException, the same
type as many real data errors. A WriteConflict never becomes a transient exception in Spring,
because the translator checks the code first. So the platform checks the cause itself:
public final class WriteConflicts {
/** MongoDB's error code for a write conflict. */
public static final int WRITE_CONFLICT = 112;
private WriteConflicts() {
}
/** Returns whether the exception, or one of its causes, is a MongoDB write conflict. */
public static boolean isWriteConflict(Throwable error) {
for (Throwable cause = error; cause != null; cause = cause.getCause()) {
if (cause instanceof MongoException mongo
&& (mongo.getCode() == WRITE_CONFLICT
|| mongo.hasErrorLabel(MongoException.TRANSIENT_TRANSACTION_ERROR_LABEL))) {
return true;
}
}
return false;
}for (Throwable cause = error; cause != null; cause = cause.getCause()) {The loop walks the chain of causes. Spring's exception wraps the driver's exception.
if (cause instanceof MongoException mongoThis is a type pattern. When the cause is a
MongoException, it is bound to the variablemongo. C# writes the same test ascause is MongoException mongo.|| mongo.hasErrorLabel(MongoException.TRANSIENT_TRANSACTION_ERROR_LABEL))) {The label counts too, so a transient error with another code is also a conflict.
The test WriteConflictsTest builds a MongoCommandException with code
112, and one with code 121. Only the first counts as a conflict. So a broken validation rule in
the database stays a 500, and is not answered as a conflict.
Spring retries nothing. The MongoDB .NET driver's
WithTransactionAsync runs its callback again after a
TransientTransactionError. In a run for this book, it ran the callback three times,
and the third run succeeded. Spring's TransactionTemplate ran its callback exactly
once, and the write conflict reached the caller. MongoTransactionManager uses the
core transaction API of the driver, which has no retry logic. Its source says that it ignores
error labels by default.
Spring Data's reference recommends Spring Retry for errors with these labels. The order service does not retry inside the service. It answers 409, and the caller decides:
| Who caused the conflict | What the service does | What happens next |
|---|---|---|
| an agent, over HTTP | the platform's handler answers 409, with the code CONCURRENT_UPDATE | the agent reloads the order and tries again |
| a message from SQS | the inbox throws ConcurrentDeliveryException, and the transaction rolls back | SQS delivers the message again, and the inbox finds its key or processes it |
A retry inside the service has a careful version. Retry only on the label
TransientTransactionError. Run the whole transaction again, and load the aggregate
again inside it. Retry two or three times at most, with a short pause. Put no call to another
service inside the retried part. This version is safe, and it hides short conflicts from the
user. It is the better choice for a background job that changes hot documents, where nobody can
press "try again".
The order service still answers 409 for two reasons. A conflict means that someone else changed the same order a moment ago. The agent should see the new state before acting again, because a pick or a cancellation may have changed it. And conflicts are rare in the service, because notes have their own documents. A retry would add code for a case that the agent can handle better.
A commit can also fail with an unknown result. The network fails after the commit was sent,
and the driver cannot tell whether it succeeded. The source of MongoTransactionManager
shows how a subclass could retry the commit on the label
constant UNKNOWN_TRANSACTION_COMMIT_RESULT_LABEL. A failed commit raises
TransactionSystemException. The service does not override this, so the caller sees a
500.
com.mongodb.MongoCommandException: Command execution failed on MongoDB server with error 112 (WriteConflict): 'Caused by :: WiredTigerIdIndex::_insert: index: _id_ ... Please retry your operation or multi-document transaction.'#DataIntegrityViolationException.TransientTransactionError.The DocumentDB pages that the research checked document error 251, not error 112. Amazon DocumentDB may answer the same race differently. Nobody ran it on a cluster for this book.
The limits of a transaction on Amazon DocumentDBtrap#
A transaction that works on a local MongoDB can fail on Amazon DocumentDB. AWS documents these limits, as of October 2026:
| Limit | What AWS states | What it means for the order service |
|---|---|---|
| engine version | transactions need engine 4.0 or later | the shop's clusters run engine 8.0 |
| elastic clusters | no ACID transactions at all | the service needs an instance-based cluster |
| duration | a transaction may run for one minute | no HTTP call inside a transaction, as in place |
| size | the transaction log must be less than 32 MB | one order with a few notes is far below it |
| document locks | a write lock on a document times out after one minute, and this cannot be configured | a long transaction can block other writers for that long |
| sessions | a session times out after 30 minutes | MongoTransactionManager opens a session when a transaction begins |
| new collections | a transaction cannot create a collection, or query one that does not exist | every collection must exist before its first transaction |
| cursors | no cursors within a transaction | read single documents or small results inside a transaction |
| causal consistency | not supported, with error 303 | Spring opens a causally consistent session, which was never tested on a cluster |
| isolation | a lower read concern is upgraded to snapshot, and a lower write concern to majority | the service sets neither |
AWS also limits the open transactions of an instance. On engine 8.0, an instance of the class T3.medium allows 50, R5.large allows 200, and R5.2xlarge allows 800. A service whose transactions stay open long uses these up first.
AWS's page contradicts itself on counting. Its table lists countDocuments() as
unsupported in a transaction, and its list of limits recommends it. The shipping rule counts
notes inside the transaction of ship, with a derived count query:
/** For the order feature's shipping rule: a plain number, so no note crosses the boundary. */
public long countNeedingAction(String orderId) {
return notes.countByOrderIdAndActionRequiredTrueAndActionResolvedAtIsNull(orderId);
}The research did not record which command Spring Data sends for
countByOrderIdAndActionRequiredTrueAndActionResolvedAtIsNull. Test the shipping rule
on a real cluster before a release.
A collection that does not exist yet breaks the first transaction. On a new
environment, the first save of a note may happen inside a transaction. On Amazon DocumentDB, that
save cannot create the collection notes. MongoDB 8.0 allows it, so tests do not
show the problem. Create every collection before its first transaction.
An index would seem to create the collection on the way. On MongoDB 8.0.34 it does, in a run for this book. On DocumentDB, no AWS page that the research checked says so, as of October 2026. So the platform does not rely on it. It creates each collection explicitly, outside any transaction, in a migration:
/**
* Creates the collections that a transaction writes to, before the first transaction does.
*
* <p>Amazon DocumentDB cannot create a collection inside a transaction. MongoDB creates a
* missing collection on the first write, even inside a transaction, so a local test never shows
* the problem. On a new DocumentDB cluster, the first use case would fail instead. So every
* collection that a transaction writes to is created here, outside any transaction.
*
* <p>A collection that exists already is left alone, so the migration is safe to run twice.
* Give the ID a {@code 000} number, such as {@code V000-create-collections}, so that on a new
* database it runs before the migrations that create indexes on the same collections.
*/
public class CreateCollections implements MongoMigration {
private final String id;
private final List<String> collections;
public CreateCollections(String id, List<String> collections) {
this.id = id;
this.collections = List.copyOf(collections);
}
@Override
public String id() {
return id;
}
@Override
public void migrate(MongoTemplate mongo) {
for (String collection : collections) {
if (!mongo.collectionExists(collection)) {
mongo.createCollection(collection);
}
}
}
}if (!mongo.collectionExists(collection)) {collectionExistsasks the database for the collection by its name. On MongoDB 8.0.34, a second create of an existing collection with the same options gave no error, in a run for this book. DocumentDB was not checked, so the migration asks first, and stays safe to run twice on both.mongo.createCollection(collection);This creates an empty collection with the default options. The migration runner calls it before the web server and the listeners of Amazon Simple Queue Service (SQS) start, and never inside a transaction.
Every collection that a transaction writes to has an owner, and each owner lists its own. The
order service lists the collections of its three features in the migration
V000-create-collections. For that migration and its place in the run order, see the
section Collections first, outside any transaction.
The platform lists the collections of its own parts. The outbox of Spring Modulith writes to
event_publication in the use case's transaction, and the inbox writes to
processed_messages in the listener's transaction. So the messaging
auto-configuration creates both:
/**
* The outbox and the inbox are written inside the use case's transaction, so both
* collections must exist before it. See {@link CreateCollections}.
*/
@Bean
CreateCollections messagingCollections() {
return new CreateCollections("P000-messaging-collections", List.of(OutboxIndexes.COLLECTION, Inbox.COLLECTION));
}The ID P000-messaging-collections sorts before the platform's index migrations. The
audit log writes to audit_log in the same transactions, so the audit
auto-configuration adds P000-audit-log-collection the same way. The collections
that no transaction writes to need no entry. The migration runner writes
schema_migrations and its lock, and the resender's lock writes
scheduler_locks, all outside transactions.
The test CreateCollectionsIT runs the migration twice against a real MongoDB. It
finds the missing collection created, and the existing one unchanged. No test can show the
DocumentDB failure on MongoDB. Run one use case on a new DocumentDB cluster before the first
release.
For the basic list of these limits, see the section Transactions on Amazon DocumentDB.
A local replica settwist#
MongoDB supports transactions only on a replica set. A stand-alone MongoDB refuses them. Amazon DocumentDB always runs as a replica set. So the local environment starts MongoDB as a replica set with one member, and initialises it in its health check:
mongo:
image: mongo:8.0.34
command: ["--replSet", "rs0", "--bind_ip_all"]
ports: ["27017:27017"]
healthcheck:
# Also initialises the replica set the first time it runs
test: ["CMD", "mongosh", "--quiet", "--eval",
"try { rs.status().ok } catch (e) { rs.initiate({_id: 'rs0', members: [{_id: 0, host: 'mongo:27017'}]}).ok }"]
interval: 5s
retries: 30command: ["--replSet", "rs0", "--bind_ip_all"]MongoDB starts as a member of the replica set
rs0.--bind_ip_alllets other containers connect."try { rs.status().ok } catch (e) { rs.initiate({_id: 'rs0', members: [{_id: 0, host: 'mongo:27017'}]}).ok }"]A replica set must be initiated once. The check asks for the status, and initiates the set when the status fails. So the first check sets it up, and the later checks only report.
host: 'mongo:27017'The member is named by its container name. Other containers reach it by that name. A program on the host machine cannot resolve it, so the README adds
directConnection=truefor a run from an IDE.
The tests start their MongoDB with withReplicaSet(), for the same reason. For
that, and the trap of the new Testcontainers class, see the section
Testing the storage. In a run for this book, a transaction on a
stand-alone MongoDB container failed. For that error, see the entry
UncategorizedMongoDbException.
One aggregate per transaction, and the order with its notesnew#
An aggregate is the boundary of its own rules. So one transaction should change one aggregate. A use case that changes two aggregates in one transaction couples them. Each makes the transaction of the other larger and slower, and each can conflict with writers of the other. The usual answer is an event. The first aggregate commits, and an event changes the second one later, in its own transaction.
The order service keeps this rule in every use case but one. Placing an order stores the new order and its first notes in one transaction. The decision has a callout of its own, "One transaction for an order and its first notes". For the decision, its reasons and the sign to stop, see the section One request that saves the order and its first notes. This section shows what the transaction holds.
The transaction of place holds the order, one document for each note, an outbox
row for each event and an audit record for each change. If a note fails its category check, the
unchecked exception leaves the lambda, and all of these are rolled back. The journey test
OrderJourneyIT.anOrderAndItsNotesAreStoredTogetherOrNotAtAll proves it against a
real MongoDB.
The transaction stays far inside the limits of Amazon DocumentDB. Both aggregates are new, so no other writer can cause a write conflict on them. It holds a few documents, and it calls no other service, because the stock call runs before it. The service's README gives the rule for such a transaction:
**One request saves two aggregates.** Placing an order with its first notes stores both in one
transaction, because the agent sees it as one action. Keep such transactions small, and never
let them span services.The words Keep such transactions small name the limit in transaction terms. A
transaction that grows past a few documents, or that changes an existing aggregate of another
feature, is the sign to use an event instead.
Three other designs avoid the shared transaction. Each one has something it gets right. The service still does not use them. For the reasons, see the section One request that saves the order and its first notes.
For the reasons on the side of the domain, see the section Why order and note are separate aggregates. Between services, the rule is stricter still. No transaction ever spans two services. For that, see the chapter Publishing events: the outbox.
A transaction manager that does nothing, for unit teststwist#
A unit test of OrderService runs with no Spring context and no database. But
place calls a TransactionTemplate, and the outbox publisher checks for
a transaction. A mocked template would not run the lambda at all. So the platform provides a
real transaction manager that does nothing:
/**
* A transaction manager that does nothing, for unit tests.
*
* <p>With it, a real {@code TransactionTemplate} runs its callback, and Spring reports an active
* transaction, but nothing touches a database. A unit test can then check the order of the
* steps inside the transaction without MongoDB.
*/
public class NoOpTransactionManager extends AbstractPlatformTransactionManager {
@Override
protected Object doGetTransaction() {
return new Object();
}
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
// Nothing to begin.
}
@Override
protected void doCommit(DefaultTransactionStatus status) {
// Nothing to commit.
}
@Override
protected void doRollback(DefaultTransactionStatus status) {
// Nothing to roll back.
}
}public class NoOpTransactionManager extends AbstractPlatformTransactionManager {AbstractPlatformTransactionManageris the base class of Spring for transaction managers. It handles propagation, joining and the state of the thread. A subclass only begins, commits and rolls back.protected Object doGetTransaction() {The base class asks for an object that stands for the transaction. Any object will do here.
// Nothing to commit.The real work of the other methods is empty. The base class still marks the thread as being in a transaction, so
isActualTransactionActive()is true inside the callback.
The test builds the service with new, and passes a real template:
private final OrderService service = new OrderService(orders, notes, referenceData, stock, new ShippingPolicy(),
events, audit, new TransactionTemplate(new NoOpTransactionManager()), CLOCK);new TransactionTemplate(new NoOpTransactionManager()) runs the lambda of
place, and returns its value. The test
reservesStockThenSavesTheOrderAndItsNotesAndPublishesOrderPlaced then checks the
order of the steps with InOrder. The stock call comes first, then the save, the
event, the audit and the notes. The test cannot prove a rollback, because nothing is stored. The
journey test proves that against MongoDB.
The publisher's own test uses the same manager.
OutboxDomainEventPublisherTest.publishesEachDomainEventAndItsIntegrationEvent
publishes inside transaction.executeWithoutResult, and the publisher sees an active
transaction. A C# test of code with sessions would need a mock of
IClientSessionHandle. The Java test needs no mock, because the template itself is
real. NoOpTransactionManager extends the same base class as
MongoTransactionManager.
1An expert asksA method that joined the use case's transaction throws a domain exception. The
use case catches it, and returns normally. What happens, and why?
UnexpectedRollbackException, and nothing is stored. The
proxy of the inner method marked the whole transaction as rollback-only when the exception left
it. The outer code cannot undo that mark. So never catch a domain exception inside a transaction
to continue. Let it leave the use case.2An expert asksA write conflict aborts a transaction. Why does Spring not retry it, as
WithTransactionAsync does in .NET, and what does the order service do instead?
WithTransactionAsync does in .NET, and what does the order service do instead?MongoTransactionManager uses the driver's core transaction API, which has no
retry, and it ignores error labels. The conflict reaches the caller as
DataIntegrityViolationException. The service answers an agent with 409, so the agent
reloads the order. A message is redelivered by SQS.3What does spring.modulith.events.mongodb.transaction-management.enabled=false do
in the platform?
spring.modulith.events.mongodb.transaction-management.enabled=false do
in the platform?MongoTransactionManager and
TransactionTemplate. The default is true. The platform's mongo package
already registers the manager, so exactly one place creates it.4On Amazon DocumentDB, why must a collection exist before the first transaction that writes to
it?
5TranslateWrite the transactional part of the platform's inbox in Java. The template
for transactions is transactions, and the MongoTemplate is
mongo. The collection name is COLLECTION:
using IClientSessionHandle session = await client.StartSessionAsync();
await session.WithTransactionAsync(async (s, ct) =>
{
if (await processed.Find(s, new BsonDocument("_id", key)).AnyAsync(ct))
{
return false;
}
await handler();
await processed.InsertOneAsync(s,
new BsonDocument { { "_id", key }, { "processedAt", clock.GetUtcNow().UtcDateTime } },
cancellationToken: ct);
return true;
});
transactions, and the MongoTemplate is
mongo. The collection name is COLLECTION:
using IClientSessionHandle session = await client.StartSessionAsync();
await session.WithTransactionAsync(async (s, ct) =>
{
if (await processed.Find(s, new BsonDocument("_id", key)).AnyAsync(ct))
{
return false;
}
await handler();
await processed.InsertOneAsync(s,
new BsonDocument { { "_id", key }, { "processedAt", clock.GetUtcNow().UtcDateTime } },
cancellationToken: ct);
return true;
});transactions.executeWithoutResult(status -> {
if (mongo.exists(query(where("_id").is(key)), COLLECTION)) {
log.info("Skipping event {}, which was processed before", event.eventId());
return;
}
handler.accept(event);
mongo.insert(new Document("_id", key).append("processedAt", clock.instant()), COLLECTION);
});executeWithoutResult runs a lambda that returns nothing, in one transaction. The
handler joins the same transaction, so the key and the handler's changes commit together. No call
takes a session. One difference stays. WithTransactionAsync retries a transient
error, and the template does not.
Quick reference#
The transaction settings that the order service relies on:
| Setting or attribute | Value in the shop | What it does |
|---|---|---|
| the MongoTransactionManager bean | from PlatformMongoAutoConfiguration | makes @Transactional and TransactionTemplate start MongoDB transactions |
| spring.modulith.events.mongodb.transaction-management.enabled | false, from the platform | Spring Modulith registers no second transaction manager |
| @Transactional propagation | REQUIRED, the default | joins a running transaction, or starts one |
| @Transactional rollbackFor | not set | an unchecked exception rolls back, and every domain exception is unchecked |
| @Transactional isolation | not set | DocumentDB upgrades the read concern of a transaction to snapshot |
| TransactionTemplate | the bean that Boot adds | REQUIRED, and the default isolation and timeout |
The exceptions of a failed transaction, and where the service handles each:
| Exception | Cause | Handled by |
|---|---|---|
| OptimisticLockingFailureException | a stale @Version outside a transaction | the platform's handler, as 409 |
| DataIntegrityViolationException with error 112 | a write conflict inside a transaction | the platform's handler as 409, or the inbox as a redelivery |
| DuplicateKeyException | an insert of an existing _id or unique key | the inbox as a redelivery, and otherwise a 500 |
| UnexpectedRollbackException | a caught exception of a joined method | nothing, because the service never catches one inside a transaction |
| TransactionSystemException | a commit that failed | nothing, so the caller gets a 500 |
Calling other services#
The order service asks the stock service to hold the products of each new order. The use case sees only a small interface in its own words. Behind it, Spring generates the HTTP client, and an adapter translates the answer. The platform gives every call timeouts, tracing, a token, a circuit breaker and safe retries. A call that still fails becomes a 503.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | no equivalent | the stock call before the transaction | the call runs before the transaction, so a slow stock service never holds a database transaction open Why |
| twist | an IStockChecker interface in the application layer | StockChecker in application/port | the use case asks in its own types, so a unit test replaces the stock service with a mock Why |
| twist | a Refit interface | an @HttpExchange interface | Spring builds a proxy that turns each method call into an HTTP request Why |
| twist | records for the other service's JSON | ReservationRequest and ReservationResponse in client/dto | their names, such as sku, stop at the adapter and never reach the use case Why |
| twist | [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Skip)] | @JsonIgnoreProperties(ignoreUnknown = true) | Jackson 3 already ignores unknown fields, and the annotation states it where a reader looks Why |
| new | no equivalent | StockAdapter and its static translate method | the adapter is the anti-corruption layer, and an answer it does not know is a bug that it reports Why |
| twist | IHttpClientFactory and a named client | HttpClientFactory.create(type, properties) | the platform builds every client the same way, with timeouts, tracing and a token Why |
| trap | no equivalent | RestClient.create() or the static RestClient.builder() | a client that is not built from Boot's injected builder sends no traceparent header, so the trace breaks Why |
| twist | HttpClient.Timeout | connect-timeout and read-timeout in clients.stock.* | the JDK client limits the connection, and the request factory limits the wait for the reply Why |
| twist | no equivalent | spring.http.clients.read-timeout | the Boot 4 keys have a plural clients, and the singular keys are deprecated since 4.0.0 Why |
| twist | a configuration class per typed client | StockClientConfig with two @Bean methods | each client binds its own settings prefix and creates its bean in one small class Why |
| twist | a DelegatingHandler that adds a token | token-registration-id in the client's settings | the platform adds the service's own client-credentials token to every call Why |
| new | Polly's retry and circuit breaker strategies | ResilientCalls, with one registry for each | a retry repeats a short failure, and a breaker stops calls to a service that keeps failing Why |
| trap | MaxRetryAttempts = 2 in Polly | maxAttempts = 3 in Resilience4j | Resilience4j counts the first call, so 3 attempts are only 2 retries Why |
| twist | MinimumThroughput = 10 in Polly | minimumNumberOfCalls = 10 | the breaker computes no failure rate before 10 calls, whatever the size of its window Why |
| trap | no equivalent | recordException(...) alone | an exception that the predicate does not record counts as a success, so ignoreExceptions keeps 4xx out of the rate Why |
| new | no equivalent | isTransient: ResourceAccessException or HttpServerErrorException | only timeouts, failed connections and 5xx answers are retried, and a 4xx reaches the adapter unchanged Why |
| twist | AddRetry, then AddCircuitBreaker | Retry.decorateSupplier(retry, CircuitBreaker.decorateSupplier(...)) | the retry is outside the breaker, so the breaker counts every attempt Why |
| new | no equivalent | DownstreamServiceException, answered with 503 | a call that fails after its retries, or that the breaker blocks, means "dependency unavailable" Why |
| new | no equivalent | callWithRetry for idempotent calls, and call for the others | a POST that timed out may have worked, so only a call with an idempotency key is retried Why |
| new | no equivalent | the order ID as the Idempotency-Key header | the ID is chosen before the call, so a repeated reservation holds the stock once Why |
| twist | a pipeline for each named client | one circuit breaker for each dependency name | a failing stock service never blocks the calls to another service Why |
| twist | AddStandardResilienceHandler() | ResilientCalls, written once in the platform with Resilience4j core | plain Java, so it works the same on every Boot version and needs no starter Why |
| twist | Polly's ResiliencePipelineBuilder | the core decorators of Resilience4j | both wrap one call in a retry and a breaker, and both put the retry outside Why |
| twist | Polly's AddRetry | @Retryable from Spring Framework 7 | it retries, but it has no circuit breaker, and maxRetries counts only the retries Why |
| twist | WireMock mappings, which work for any language | the stock stubs in local-environment | a stub with a lower priority number wins, so special products override the default answer Why |
| twist | a test with a fake HttpMessageHandler | StockAdapterTest against the JDK's HttpServer | the test runs real HTTP, JSON, the token and the retries, without Docker Why |
Why the order service calls the stock servicenew#
An agent places an order for two mugs. The shop must not sell mugs that it does not have. The stock service knows how many mugs are in the warehouse. So the order service asks it to hold the mugs for the new order, before it saves the order.
The service's README states the rule. An order is placed only if the stock service confirms
and holds every product. The use case place makes the call first, and opens its
transaction afterwards:
public PlacedOrder place(CustomerContact customer, DeliveryMethod deliveryMethod, List<OrderItem> items,
List<NewNote> firstNotes) {
referenceData.requireActive(ReferenceTypes.DELIVERY_METHOD, deliveryMethod.code(), "deliveryMethod");
OrderId orderId = OrderId.newId();
StockAvailability availability = stock.reserve(orderId, items); // outside the transaction
return transaction.execute(status -> {
Order order = Order.place(orderId, customer, deliveryMethod, items, availability, clock);
Order saved = saveAndPublish(order, AuditActions.ORDER_PLACED); // aggregate 1
List<String> noteIds = notes.addNotes(saved.id().value(), firstNotes); // aggregate 2, same transaction
return new PlacedOrder(saved.id().value(), noteIds);
});
}OrderId orderId = OrderId.newId();The use case chooses the order's ID before anything else. The ID travels to the stock service as the idempotency key. The section Retry only what is safe to repeat explains why.
StockAvailability availability = stock.reserve(orderId, items); // outside the transactionstockis the port to the stock service. The call returns a value object of the order service, not the stock service's JSON. It runs before the transaction, so a slow stock service never holds a database transaction open.return transaction.execute(status -> {Only the code inside this lambda is transactional.
Order.placethen decides what the answer means. For theTransactionTemplate, see the chapter Transactions in depth.
One call to the stock service passes through five pieces. Each piece has one job:
The sections below follow the call from the use case to the network, and then back through every way it can fail.
The port: the question in the service's own wordstwist#
public interface IStockChecker
{
Task<StockAvailability> ReserveAsync(OrderId order, IReadOnlyList<OrderItem> items);
}public interface StockChecker {
/**
* Asks the stock service to confirm that the items are available, and to hold them for the
* order. The stock service keeps the hold until it sees {@code OrderPlacedV1}, or releases it
* after a timeout if the order is never placed.
*
* <p>Calling it twice for the same order holds nothing twice, because the order ID is the
* idempotency key.
*/
StockAvailability reserve(OrderId order, List<OrderItem> items);
}StockAvailability reserve(OrderId order, List<OrderItem> items);The method takes and returns types of the order service.
OrderId,OrderItemandStockAvailabilitybelong to the domain model. Nothing here says HTTP, JSON orsku.The stock service keeps the hold until it sees {@code OrderPlacedV1}, or releases itThe comment states the contract with the stock service. A hold for an order that is never saved does not last for ever.
The Java method returns the answer directly, and the C# method returns a
Task. The Java call blocks its thread. On a
virtual thread that is the normal way. For the
reason, see the chapter HTTP clients and
resilience.
A port is an interface that the application layer owns, and an adapter elsewhere implements
it. This port is in the package order.application.port, not in the domain. Calling
another service is a step of the use case, and the domain only judges the answer. A unit test of
OrderService replaces the port with a Mockito
mock, and needs no server. The journey test does the same with @MockitoBean StockChecker
stock. For ports in general, see the chapter Use cases: the application
service.
The HTTP interface: Spring writes the clienttwist#
public interface IStockHttpClient
{
[Post("/stock/reservations")]
Task<ReservationResponse> ReserveAsync([Header("Idempotency-Key")] string idempotencyKey,
[Body] ReservationRequest request);
}@HttpExchange("/stock")
interface StockHttpClient {
/**
* Holds stock for an order.
*
* <p>A POST that creates something is not safe to repeat: if the response is lost, a retry
* would hold the stock twice. The {@code Idempotency-Key} header makes it safe. The stock
* service remembers the key, and answers a repeated request with the first answer.
*/
@PostExchange("/reservations")
ReservationResponse reserve(@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody ReservationRequest request);
}@HttpExchange("/stock")This annotation on the interface gives the start of every path. It is in the package
org.springframework.web.service.annotation.@PostExchange("/reservations")This method sends a POST to
/stock/reservations, like[Post]in Refit.@RequestHeader("Idempotency-Key") String idempotencyKey,The argument becomes the header
Idempotency-Key. The annotation is the same one that a controller uses to read a header.@RequestBody ReservationRequest request);The argument is written as the JSON body. The answer is read into a
ReservationResponse.
The interface has no access modifier, so it is package-private. Only the
client package can use it. Spring creates the implementation at runtime. The platform asks
HttpServiceProxyFactory for it, and the factory returns a
proxy. A run for this book looked at such a client, created by Boot. It was a proxy of the Java Development Kit (JDK), an object that implements the interface and turns each method call into an HTTP request.
Boot 4 can also create the bean itself, with @ImportHttpServices and the settings
spring.http.serviceclient.<group>.*. For that form, see the section
Declarative clients. The order service builds its clients through
the platform instead, so every client gets the same timeouts, tracing and token. See the section
The platform's HttpClientFactory.
Their DTOs stay in the client packagetwist#
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Skip)]
public record ReservationResponse(string Result, List<string> ShortSkus);@JsonIgnoreProperties(ignoreUnknown = true)
public record ReservationResponse(String result, List<String> shortSkus) {
}The record ReservationResponse is the stock service's answer. A data transfer
object (DTO) is a class that only carries data over the wire. The stock
service has its own words. It calls a product code a sku, and its answer is
RESERVED or INSUFFICIENT_STOCK. The order service copies those words
into two records in the package
order.client.dto. The request is the other one:
/** The stock service's request body, in its own words: it calls a product code a {@code sku}. */
public record ReservationRequest(String orderId, List<Line> lines) {
public record Line(String sku, int quantity) {
}
}Line is a record inside a record, so its full name is
ReservationRequest.Line. Only the adapter uses these records. The use case and the
domain never see them. So a renamed field on the stock service's side changes one package of
the order service.
The answer reads only the fields that the order service uses. The stub of the stock service
also sends reservationId, and the record has no such field. A reader that takes
what it needs and ignores the rest is called a tolerant reader. The stock service can then add
fields without breaking the order service.
Jackson 3 already ignores unknown fields. A run for this book confirmed that
FAIL_ON_UNKNOWN_PROPERTIES is off, in Jackson 3 and in the mapper that Boot builds.
In Jackson 2 it was on by default. The annotation @JsonIgnoreProperties(ignoreUnknown =
true) states the rule on the class itself. It keeps the record safe if a later setting
turns the check on. Its package is still com.fasterxml.jackson.annotation, because
Jackson 3 kept the annotations of the 2.x line. For Jackson
3 in full, see the chapter The web layer in depth.
The adapter: the anti-corruption layernew#
The stock service may one day answer PARTIALLY_RESERVED. If the order service passed the stock service's words through, that new word would reach the domain. A rule would then treat it as a success or a failure by accident. Something must stand between the two
models.
That something is called an anti-corruption layer. It translates the other service's model into this service's model, in one place. In the order service, it is the adapter that implements the port:
@Component
class StockAdapter implements StockChecker {
static final String DEPENDENCY = "stock";
private final StockHttpClient http;
private final ResilientCalls resilientCalls;
StockAdapter(StockHttpClient http, ResilientCalls resilientCalls) {
this.http = http;
this.resilientCalls = resilientCalls;
}
@Override
public StockAvailability reserve(OrderId order, List<OrderItem> items) {
ReservationRequest request = new ReservationRequest(order.value(), items.stream()
.map(item -> new ReservationRequest.Line(item.product().value(), item.quantity()))
.toList());
ReservationResponse response = resilientCalls.callWithRetry(DEPENDENCY,
() -> http.reserve(order.value(), request));
return translate(response);
}
/** Translates their answer into ours. An answer this service does not know is a bug to report. */
static StockAvailability translate(ReservationResponse response) {
return switch (response.result()) {
case "RESERVED" -> StockAvailability.allAvailable();
case "INSUFFICIENT_STOCK" -> StockAvailability.unavailable(
response.shortSkus().stream().map(ProductCode::new).toList());
default -> throw new IllegalStateException("Unknown stock reservation result: " + response.result());
};
}
}class StockAdapter implements StockChecker {The class is package-private, and
@Componentmakes it a bean. Spring injects it wherever aStockCheckeris needed. Nothing outside the client package can name the class.static final String DEPENDENCY = "stock";The name of the dependency. It chooses the circuit breaker and the retry settings in
ResilientCalls, and it appears in the log when the call fails..map(item -> new ReservationRequest.Line(item.product().value(), item.quantity()))The adapter builds their request from our items. A
ProductCodebecomes the text that the stock service calls asku.() -> http.reserve(order.value(), request));The call is passed as a lambda, and
ResilientCallsdecides when to run it. Its type isSupplier<ReservationResponse>. See the section The platform's HttpClientFactory.return switch (response.result()) {This is a switch expression on a string. Each
casewith->gives the value of the whole switch, as an arm of aswitchexpression does in C#.default -> throw new IllegalStateException("Unknown stock reservation result: " + response.result());A
defaultarm may throw instead of giving a value. An answer that the order service does not know is a bug to report, not a guess to make..map(ProductCode::new)ProductCode::newis a method reference to the constructor. It turns eachskuinto aProductCodeof the order service.
The method translate is static and has no dependencies. So a test
can call it with a ReservationResponse and check the result, with no server. The
test StockAdapterTest.refusesAnAnswerItDoesNotKnow sends MAYBE, and
expects the IllegalStateException with that message.
An unknown answer is not a 503. The stock service answered, and the order service did not understand it. So the exception reaches the platform's handler for any other exception, and the caller gets 500. The log then shows a bug, which a person must fix.
The stock service never sees the domain classes, and the domain never sees the DTOs. Keep
the translation in the adapter, in both directions. A use case that reads
ReservationResponse would bring the stock service's model into the order
service's model.
The platform's HttpClientFactorynew#
Every service of the shop calls other services. Each client needs the same things. It needs a base URL, two timeouts, the trace ID on each request, and often a token. If each team wrote this code, one client would sooner or later have no timeout. So the platform builds every client in one method:
public <T> T create(Class<T> clientType, HttpClientProperties properties) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(properties.getConnectTimeout())
.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(properties.getReadTimeout());
RestClient.Builder builder = restClientBuilders.get()
.baseUrl(properties.getBaseUrl().toString())
.requestFactory(requestFactory);
if (StringUtils.hasText(properties.getTokenRegistrationId())) {
builder.requestInterceptor(tokenInterceptor(properties.getTokenRegistrationId()));
}
return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(builder.build()))
.build()
.createClient(clientType);
}public <T> T create(Class<T> clientType, HttpClientProperties properties) {A generic method.
Class<T>is the interface to implement, such asStockHttpClient.class, and the method returns aT.HttpClient httpClient = HttpClient.newBuilder()This is the HTTP client of the JDK, in the package
java.net.http. It opens the connections..connectTimeout(properties.getConnectTimeout())The connect timeout belongs to the JDK client.
requestFactory.setReadTimeout(properties.getReadTimeout());JdkClientHttpRequestFactorylets Spring'sRestClientsend its requests through the JDK client. The read timeout belongs to this factory.RestClient.Builder builder = restClientBuilders.get()A new builder from Spring Boot. The next section shows why it must come from Boot.
builder.requestInterceptor(tokenInterceptor(properties.getTokenRegistrationId()));When the settings name a token registration, every request gets the service's own token. See the section The service's own token on every call.
HttpServiceProxyFactory.builderFor(RestClientAdapter.create(builder.build()))RestClientAdapterlets the proxy factory send requests through the finishedRestClient..createClient(clientType);The factory reads the annotations of the interface and returns the proxy.
So three Spring types stand between the interface and the network:
| Type | What it does | Where its settings come from |
|---|---|---|
| HttpServiceProxyFactory | turns the interface into a proxy, one request for each method call | the annotations of the interface |
| RestClient | sends the request, writes and reads JSON, runs the interceptors | Boot's builder, plus the base URL and the token interceptor |
| JdkClientHttpRequestFactory | hands each request to the JDK's HttpClient, with a read timeout | the client's settings |
The factory receives its builders as a Supplier<RestClient.Builder>. A
Supplier<T> is a functional
interface with one method, get(), and no parameters. It does the same as
Func<T> in C#. The platform uses three of these interfaces:
| Java | C# | Where the platform uses it |
|---|---|---|
| Supplier<T> | Func<T> | the builders here, and the call that ResilientCalls runs |
| Consumer<T> | Action<T> | the handler that the inbox runs for a message |
| Function<T, R> | Func<T, TResult> | the type of a lambda such as item -> new ReservationRequest.Line(...) |
For lambdas and these types in full, see the section Lambdas. The platform's auto-configuration creates the factory:
@Bean
@ConditionalOnMissingBean
HttpClientFactory httpClientFactory(ObjectProvider<RestClient.Builder> restClientBuilders,
ObjectProvider<ClientRegistrationRepository> registrations,
ObjectProvider<OAuth2AuthorizedClientService> authorizedClients) {
Supplier<OAuth2AuthorizedClientManager> tokenManager = () -> {
ClientRegistrationRepository repository = registrations.getIfAvailable();
if (repository == null) {
throw new IllegalStateException("A client asks for a token, but no "
+ "spring.security.oauth2.client.registration is configured");
}
return new AuthorizedClientServiceOAuth2AuthorizedClientManager(repository, authorizedClients.getObject());
};
return new HttpClientFactory(restClientBuilders::getObject, tokenManager);
}ObjectProvider<RestClient.Builder> restClientBuilders,An
ObjectProviderasks the container for a bean each time it is called, not once. ForObjectProvider, see the chapter Wiring the service: the container in depth.return new HttpClientFactory(restClientBuilders::getObject, tokenManager);restClientBuilders::getObjectis a method reference, used as theSupplier. Each call toget()asks the container for a builder.ClientRegistrationRepository repository = registrations.getIfAvailable();The token manager is built only when a client asks for a token. A service without client registrations still starts.
The Supplier is also the seam for tests. StockAdapterTest passes the
method reference RestClient::builder, so it needs no Spring container. See the
section Testing the client against a real HTTP server.
The platform's factory has the same purpose as IHttpClientFactory in ASP.NET
Core. It creates a typed client from an interface and one settings object, and every client gets
the same setup.
Why the factory uses Boot's injected buildertrap#
The platform's comment is short. Never use RestClient.create(). The reason is
the trace ID. The trace ID travels to the next service in a header named traceparent. A run for this book sent requests from three clients to a JDK
HttpServer that recorded the headers. The application had
spring-boot-starter-opentelemetry on its classpath. This is the run's own
code:
var b1 = ctx.getBean(RestClient.Builder.class);
// ...
b1.baseUrl(base).build().get().uri("/stock/a").retrieve().toBodilessEntity();
// ...
RestClient.create(base).get().uri("/stock/a").retrieve().toBodilessEntity();
// ...
RestClient.builder().baseUrl(base).build().get().uri("/stock/a").retrieve().toBodilessEntity();Show the answer
b1, Boot's builder, carries traceparent. The other two requests carry none.Only Boot's builder carries the tracing setup. Spring Boot creates and
configures a RestClient.Builder bean, and the run shows that its setup includes
tracing. The static methods RestClient.create and RestClient.builder
make a bare client that knows nothing of Boot.
A second run showed the effect. A request that called itself through the static builder started a new trace. Through Boot's builder, the inner request kept the trace of the outer one. So always take the builder from the container.
The builder bean is named restClientBuilder, and its scope is
prototype. A prototype bean is created new for each request to the container. Two
calls to getBean returned two instances in a run. This matters, because a builder
is a mutable object. The factory sets a base URL and an interceptor on it. With one shared
builder, the second client would get the first client's settings too. So the factory asks for a
new builder for each client, through ObjectProvider::getObject.
Boot's builder brings more than tracing. The platform's comment also names metrics, and the reference of Boot says that it creates and pre-configures the builder. For traces and metrics, see the chapter Logging, tracing and health.
A test that builds the client with RestClient::builder has no
tracing. That is right for StockAdapterTest, which tests the call, the JSON
and the retries. A test that must check the trace ID needs the builder from a Spring context.
Timeouts, and the Boot 4 key namestwist#
builder.Services.AddHttpClient("stock", c =>
{
c.BaseAddress = new Uri(stockUrl);
c.Timeout = TimeSpan.FromSeconds(3);
});clients:
stock:
base-url: ${STOCK_SERVICE_URL}
read-timeout: 3s
# Each call carries a token from this client registration
token-registration-id: stock-servicebase-url: ${STOCK_SERVICE_URL}The URL comes from an environment variable, so each environment names its own stock service.
read-timeout: 3sThe order service waits at most 3 seconds for the stock service's answer. The connect timeout keeps the platform's default of 2 seconds.
The keys clients.stock.* are the platform's own. Each client binds them into a
HttpClientProperties object:
@Validated
public class HttpClientProperties {
/** The root URL of the other service, such as {@code https://stock.internal}. */
@NotNull
private URI baseUrl;
/** How long to wait for a connection. */
@NotNull
private Duration connectTimeout = Duration.ofSeconds(2);
/** How long to wait for the response once connected. */
@NotNull
private Duration readTimeout = Duration.ofSeconds(5);@ValidatedSpring validates the object when it binds the settings. A missing
base-urlstops the service at startup, not at the first call.private URI baseUrl;java.net.URIholds a parsed address. A value that is not a valid URI fails at binding.private Duration connectTimeout = Duration.ofSeconds(2);The default applies when the setting is missing. A text such as
3sbinds to aDuration.
The two timeouts guard against two different problems. Runs for this book measured both, with a connect timeout of 500 ms and a read timeout of 1 second:
| Situation | Timeout that fires | What the caller got |
|---|---|---|
| the address cannot be reached, here 10.255.255.1 | connect timeout, after 507 ms | ResourceAccessException, caused by HttpConnectTimeoutException |
| the server is reached, and answers after 3 seconds | read timeout, after 1071 ms | ResourceAccessException, caused by HttpTimeoutException with the message Request cancelled |
Both failures arrive as ResourceAccessException, Spring's exception for an
input or output error. So the platform can treat them as one kind of failure. See the section
Which failures are worth a retry.
The C# pane sets one Timeout on the HttpClient. The platform sets
two limits, because a slow connection and a slow answer are different problems. Without a limit, a
stock service that never answers would hold the order service's request for as long as the
socket stays open. See the section Timeouts.
Spring Boot 4 also has its own keys, for the clients that Boot builds. They apply to Boot's
builder and to an @ImportHttpServices client. Their names changed in Boot 4:
| Boot 4 key | Deprecated key, since 4.0.0 | What it does |
|---|---|---|
| spring.http.clients.connect-timeout | spring.http.client.connect-timeout | the time to wait for a connection, for every client Boot builds |
| spring.http.clients.read-timeout | spring.http.client.read-timeout | the time to wait for an answer, for every client Boot builds |
| spring.http.clients.imperative.factory | spring.http.client.factory | which HTTP library the blocking clients use |
| spring.http.serviceclient.<group>.read-timeout | none | the same, for one @ImportHttpServices group |
The plural clients is easy to miss. A run with
spring.http.clients.read-timeout=1s failed a 3-second reply after 1070 ms. With no
other HTTP client library on the classpath, Boot chose the
JdkClientHttpRequestFactory, the same one that the platform uses.
The platform's factory passes its own request factory to the builder, with the client's
timeouts. So the settings clients.stock.* decide the stock call's timeouts. Use
Boot's keys for a client that Boot builds, and the platform's keys for a client that the factory
builds.
Wiring one clienttwist#
@Configuration(proxyBeanMethods = false)
class StockClientConfig {
/** Binds the platform's reusable properties class to this client's prefix. */
@Bean
@ConfigurationProperties("clients.stock")
HttpClientProperties stockClientProperties() {
return new HttpClientProperties();
}
@Bean
StockHttpClient stockHttpClient(HttpClientFactory clients, HttpClientProperties stockClientProperties) {
return clients.create(StockHttpClient.class, stockClientProperties);
}
}@ConfigurationProperties("clients.stock")On a
@Beanmethod, this annotation binds the returned object to the prefix. So the platform's one class serves every client, each with its own prefix. For binding, see the chapter Configuration in depth.StockHttpClient stockHttpClient(HttpClientFactory clients, HttpClientProperties stockClientProperties) {The parameter name
stockClientPropertiesmatches the bean name. With a second client, two beans of typeHttpClientPropertiesexist, and the name chooses one.@Configuration(proxyBeanMethods = false)No method of this class calls another
@Beanmethod, so Spring needs no Code Generation Library (CGLIB) subclass of it.
This class completes the client. A new client for another service needs five files in the
feature's client package and in its port package:
| File | Example | What it holds |
|---|---|---|
| the port | application/port/StockChecker | the question, in this service's types |
| the HTTP interface | client/StockHttpClient | the other service's endpoints, as annotated methods |
| the DTOs | client/dto/ReservationRequest, ReservationResponse | their JSON, in their words |
| the adapter | client/StockAdapter | the call through ResilientCalls, and the translation |
| the configuration | client/StockClientConfig | the settings prefix and the client bean |
Add the settings under clients.<name>, a stub in the local environment, and
the adapter's test. Only the adapter and the configuration are beans. The port is the only type
that the rest of the service sees.
The service's own token on every calltwist#
builder.Services.AddHttpClient("stock")
.AddHttpMessageHandler<ClientCredentialsTokenHandler>();/**
* The Spring Security client registration that issues this client's tokens, such as
* {@code stock-service}. When it is set, every call carries a client-credentials token.
* Leave it empty only for a service that needs no token.
*/
private String tokenRegistrationId;The stock service needs a token, and its stub answers 401 without one. The field
tokenRegistrationId is bound from the setting token-registration-id,
which the stock client sets to stock-service. The value names a client registration of
Spring Security. When the field is set, the factory adds an interceptor that puts a client-credentials token on every request. In
the C# pane, ClientCredentialsTokenHandler stands for a
DelegatingHandler that you write yourself.
The token belongs to the order service, not to the agent. Spring Security fetches it at the
first call, and keeps it until 60 seconds before it expires. The platform fixes the principal under
which the token is stored, so all agents share one token. The test
StockAdapterTest.twoDifferentUsersShareOneServiceToken proves it with two agents
and one token request.
Security has its home in another chapter. For the registration and the interceptor, see the section The service's own token for the stock service. That section also explains the trap of one token for each user.
Retry and circuit breaker: what each one is fornew#
The stock service restarts during a deployment, and one request gets no answer. A second try a moment later would work. Later that day, the stock service is down for ten minutes. Every request waits 3 seconds for nothing, and agents see slow failures.
The two cases need two tools. A retry repeats a call that failed for a short time. A circuit breaker stops calling a service that keeps failing, and fails at once instead. After a wait, it lets a few test calls through. If they work, it closes again.
Think of the circuit breaker as a fuse box in a house. When a device keeps failing, the fuse trips, and the device gets no power until someone tests it. The comparison stops at the reset. A circuit breaker tests the line by itself after its wait, and no person is needed.
The platform puts both tools in one class, ResilientCalls. It uses the core
modules of the library Resilience4j, version 2.4.0. Its constructor builds the two
registries:
public ResilientCalls(ResilienceProperties properties) {
ResilienceProperties.CircuitBreaker breaker = properties.getCircuitBreaker();
this.circuitBreakers = CircuitBreakerRegistry.of(CircuitBreakerConfig.custom()
.failureRateThreshold(breaker.getFailureRateThreshold())
.slidingWindowSize(breaker.getSlidingWindowSize())
.minimumNumberOfCalls(breaker.getMinimumNumberOfCalls())
.waitDurationInOpenState(breaker.getWaitDurationInOpenState())
.recordException(ResilientCalls::isTransient)
.ignoreExceptions(HttpClientErrorException.class)
.build());
ResilienceProperties.Retry retry = properties.getRetry();
this.retries = RetryRegistry.of(RetryConfig.custom()
.maxAttempts(retry.getMaxAttempts()) // calls in total, not retries
.intervalFunction(IntervalFunction.ofExponentialRandomBackoff(
retry.getInitialInterval().toMillis(), retry.getMultiplier()))
.retryOnException(ResilientCalls::isTransient)
.build());
}this.circuitBreakers = CircuitBreakerRegistry.of(CircuitBreakerConfig.custom()A registry holds one circuit breaker for each name, all with this configuration. The builder
CircuitBreakerConfig.custom()starts from the library's defaults..failureRateThreshold(breaker.getFailureRateThreshold())The breaker opens when this percentage of the recent calls failed. The platform's value is 50.
.slidingWindowSize(breaker.getSlidingWindowSize())The rate is computed over the last 20 calls.
.minimumNumberOfCalls(breaker.getMinimumNumberOfCalls())No rate is computed before 10 calls. See the section When the breaker opens.
.waitDurationInOpenState(breaker.getWaitDurationInOpenState())An open breaker refuses calls for 30 seconds, then lets test calls through.
.recordException(ResilientCalls::isTransient)Only transient failures count as failures. See the section What the breaker counts.
.maxAttempts(retry.getMaxAttempts()) // calls in total, not retriesThree attempts in total, including the first call.
.intervalFunction(IntervalFunction.ofExponentialRandomBackoff(The waits between attempts grow, and each wait has a random part.
.retryOnException(ResilientCalls::isTransient)The retry uses the same test as the breaker. A 4xx answer is never retried.
The values come from the platform's settings, under platform.resilience.*. The
quick reference at the end of the chapter lists them with the library's defaults. The services
rarely change them.
A circuit breaker has three states. These are the states of
CircuitBreaker.State that the service meets:
| State | What happens to a call | When the state changes |
|---|---|---|
| CLOSED | the call runs, and its result is counted | the failure rate reaches 50% of the last 20 calls, after at least 10 calls |
| OPEN | the call does not run, and CallNotPermittedException is thrown at once | the wait of 30 seconds ends |
| HALF_OPEN | a few test calls run, 10 by default | the test calls decide whether it closes or opens again |
How many times a retry callstrap#
The setting is called maxAttempts, and its value is 3. A test sends a reservation
to a stub that always answers 503:
@Test
void retriesServerErrorsThenReportsTheServiceAsUnavailable() {
status = 503;
body = "{}";
assertThatThrownBy(() -> adapter.reserve(OrderId.newId(), TWO_MUGS)).isInstanceOf(DownstreamServiceException.class);Show the answer
assertThat(reservationCalls).hasValue(3).Resilience4j counts the first call as an attempt. Its documentation defines
maxAttempts as the maximum number of attempts, including the initial call. A run with
an always-failing call ran it 3 times.
Polly counts differently. MaxRetryAttempts = 3 makes 4 calls, and so does
@Retryable(maxRetries = 3) in Spring Framework 7. When you copy a value between
libraries, check what it counts.
The platform's settings say it in a comment, because the name misleads:
/** The number of attempts in total, including the first one. 3 means up to 2 retries. */
private int maxAttempts = 3;
/** The wait before the first retry. Later waits grow by the multiplier, with random spread. */
private Duration initialInterval = Duration.ofMillis(200);
/** How much each wait grows before the random spread. 2.0 doubles it. */
private double multiplier = 2.0;The default of Resilience4j is also 3 attempts, with a fixed wait of 500 ms. The platform
changes the wait. Its initialInterval is 200 ms, and its multiplier is
2.0. IntervalFunction.ofExponentialRandomBackoff starts at 200 ms and doubles each
time, with a random spread around each value. A run with 200 ms, 2.0 and a
randomization factor of 0.5 gave four waits of 115, 447, 988 and 1410 ms. Without the random
part, ofExponentialBackoff waited exactly 200, 400, 800 and 1600 ms.
The random part matters when many instances fail at the same moment. With fixed waits, they all retry at the same moment too, and the stock service gets a wave of calls just as it starts again. Random waits spread the wave.
Count the total time as well. Three attempts with a read timeout of 3 seconds can keep the agent waiting for about 9 seconds, plus the waits. Keep the read timeout short when the call is retried.
When the breaker openstwist#
A breaker over the last 20 calls seems to wait for 20 calls before it decides. Or two failures
out of the first two calls seem to be a rate of 100%, enough to open it. Both guesses are
wrong. The setting minimumNumberOfCalls decides.
A run for this book configured a breaker with a threshold of 50%, a window of 20 and a minimum of 10 calls. Every call failed:
| Failed calls so far | State | Failure rate that the breaker reported |
|---|---|---|
| 8 | CLOSED | -1.0, which means not computed yet |
| 9 | CLOSED | -1.0 |
| 10 | OPEN | 100.0 |
| the next call | OPEN | the call did not run, and CallNotPermittedException was thrown |
The breaker cannot open before minimumNumberOfCalls calls, whatever the
size of its window. Before that, it reports no failure rate at all.
The library's default minimum is 100, which is high for a service with modest traffic. The platform sets 10. Its comment gives the reason. Two early failures never open the breaker.
/** The failure rate, in percent, that opens the breaker. */
private float failureRateThreshold = 50;
/** The number of recent calls the failure rate is computed over. */
private int slidingWindowSize = 20;
/** The breaker computes no rate before this many calls, so two early failures never open it. */
private int minimumNumberOfCalls = 10;
/** How long the breaker stays open before it lets a few test calls through. */
private Duration waitDurationInOpenState = Duration.ofSeconds(30);The platform's test ResilientCallsTest.opensTheBreakerAfterManyFailuresAndThenStopsCalling
makes 10 failing calls through call, which does not retry. It then expects the state
OPEN, and checks that the next call never ran.
Polly's breaker has the same idea under another name. A run with Polly 8.8.0 set
FailureRatio = 0.5 and MinimumThroughput = 10. Calls 9 and 10 went
through, and call 11 threw BrokenCircuitException. That is the same point as
Resilience4j. The window differs. Polly measures over a time, by default 30 seconds. The platform
measures over a count of calls.
What the breaker countstrap#
Some answers of a service are not failures of that service. A 404 for an unknown product means that the caller asked for something that does not exist. A breaker that counts 404s would open when users mistype, and block good requests too. So the platform tells the breaker which errors to record.
CircuitBreaker cb2 = CircuitBreaker.of("stock2", CircuitBreakerConfig.custom()
.slidingWindowSize(20).minimumNumberOfCalls(10).recordException(ResilienceTest::transientFailure).build());
for (int i = 0; i < 15; i++) {
try { cb2.executeSupplier(() -> { throw HttpClientErrorException.create(HttpStatus.NOT_FOUND, "nf", null, null, null); }); } catch (Exception ignored) {}
}Show the answer
CLOSED.An exception that the predicate does not record counts as a success. It is
not left out of the count. The documentation says that all other exceptions count as a success,
unless ignoreExceptions names them.
So 404s fill the window with successes. Ten 503s among many 404s then stay under 50%, and the
breaker never opens. Add ignoreExceptions(HttpClientErrorException.class). An ignored
exception is not counted at all.
The platform uses both settings, and its comment explains the pair:
* <p>The breaker uses two settings for this on purpose. {@code recordException} decides which
* errors count as failures, but it counts every other error as a success. {@code ignoreExceptions}
* keeps 4xx answers out of the failure rate entirely, so many 404s neither open the breaker nor
* hide the 5xx answers that should.So ignoreExceptions is not a detail. The test
ResilientCallsTest.clientErrorsNeitherOpenTheBreakerNorCountAsSuccesses sends 20
404s, and expects the breaker to stay CLOSED. The ignored exception still
reaches the caller unchanged. The adapter may then turn a 404 into an answer of its own.
Which failures are worth a retrynew#
A retry helps only when the next attempt may work. A timeout may work next time. A 400 Bad Request will fail the same way every time, because the request itself is wrong. So the platform sorts the failures into two kinds, with one method:
static boolean isTransient(Throwable error) {
return error instanceof ResourceAccessException || error instanceof HttpServerErrorException;
}A failure that may pass by itself is called transient. The method isTransient
names two exception types of Spring:
| Failure | Exception from RestClient | Transient | Retried, and counted by the breaker |
|---|---|---|---|
| connect timeout, refused connection, read timeout | ResourceAccessException | yes | yes |
| 503, 502, 500 and every other 5xx | HttpServerErrorException | yes | yes |
| 400, 401, 404 and every other 4xx | HttpClientErrorException | no | no, and the breaker ignores it |
| an answer that the adapter does not know | IllegalStateException, from translate | no | no, because it happens after the call |
A 5xx answer may be a bug in the other service, not a short outage. The platform still treats it as transient. Three attempts cost little, and the breaker stops the calls if the bug stays.
A 401 is not transient either. A missing or expired token is fixed by a new token, not by the same request again. The token manager fetches a new token 60 seconds before the old one expires, by default.
The test ResilientCallsTest.doesNotRetryClientErrors throws a 400, and expects
exactly 1 attempt. The test retriesTransientFailuresThenSucceeds fails twice with a
502, and succeeds on attempt 3.
Retry outside, circuit breaker insidetwist#
var pipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions { ShouldHandle = transient, MaxRetryAttempts = 2 })
.AddCircuitBreaker(new CircuitBreakerStrategyOptions { ShouldHandle = transient })
.Build();public <T> T callWithRetry(String dependency, Supplier<T> call) {
CircuitBreaker circuitBreaker = circuitBreakers.circuitBreaker(dependency);
Retry retry = retries.retry(dependency);
Supplier<T> protectedCall = Retry.decorateSupplier(retry, CircuitBreaker.decorateSupplier(circuitBreaker, call));
return translateFailures(dependency, protectedCall);
}CircuitBreaker circuitBreaker = circuitBreakers.circuitBreaker(dependency);The registry returns the breaker of this name, and creates it on first use.
Supplier<T> protectedCall = Retry.decorateSupplier(retry, CircuitBreaker.decorateSupplier(circuitBreaker, call));Each
decorateSupplierwraps aSupplierin anotherSupplier. The breaker wraps the call, and the retry wraps the breaker. Nothing runs yet.return translateFailures(dependency, protectedCall);Only here is the call made, through both wrappers.
The order changes what the breaker counts. A run for this book compared the two orders, with 3 attempts:
| Order | What the breaker counted for one call that always failed |
|---|---|
| Retry(CircuitBreaker(call)), as in the platform | 3 calls, one for each attempt |
| CircuitBreaker(Retry(call)) | 1 call for the same 3 attempts |
With the retry outside, the breaker sees every attempt, so it opens sooner when a service is
really down. In the run, the second failing call opened the breaker after its first attempt, and
ended with CallNotPermittedException. The documentation of Resilience4j gives the
same order for its Spring Boot aspects. Retry is the outermost, and CircuitBreaker comes
next.
The retry tests only isTransient. CallNotPermittedException is not
transient, so the retry does not repeat a call that an open breaker refused. In a run, a retry
predicate that excluded that exception stopped retrying into an open breaker.
Polly behaves in the same way when the retry is added first. A run for this book with Polly
8.8.0 added a retry with MaxRetryAttempts = 2, then a breaker that opens after 3
failures. The first call made 3 attempts and opened the breaker. The second call threw
BrokenCircuitException at once, without a retry. With the breaker added first, the
second call made 3 more attempts.
A failed call becomes a 503new#
The stock service is down, and an agent places an order. The agent should learn that a service that the shop depends on is unavailable, and that a later try may work. A 500 would say that the order service has a bug. So the platform translates the failures of a call:
private static <T> T translateFailures(String dependency, Supplier<T> call) {
try {
return call.get();
} catch (CallNotPermittedException | ResourceAccessException | HttpServerErrorException e) {
throw new DownstreamServiceException(dependency, e);
}
}return call.get();This runs the decorated call, with its retries.
} catch (CallNotPermittedException | ResourceAccessException | HttpServerErrorException e) {A multi-catch block catches any of the three types. It is like a
catchwithwhen (e is A or B)in C#. The three types are an open breaker, and the two transient failures after the last retry.throw new DownstreamServiceException(dependency, e);The platform's exception keeps the name of the dependency, and the original exception as its cause.
A 4xx answer is not in the list. It reaches the adapter as
HttpClientErrorException, and the adapter decides what it means. The platform's
controller advice answers the platform's
exception:
@ExceptionHandler(DownstreamServiceException.class)
ResponseEntity<ProblemDetail> downstream(DownstreamServiceException ex) {
log.warn("Dependency {} is unavailable", ex.dependency(), ex);
return respond(ProblemDetails.problem(HttpStatus.SERVICE_UNAVAILABLE, "Dependency unavailable",
"A service this request depends on is unavailable. Try again later."));
}The line log.warn names the dependency, and the response does not. A caller learns that a
dependency failed, but not which one or why. The response is a
ProblemDetail with a traceId, like every
other error. The test OrderControllerTest.anUnavailableDependencyIsA503 lets the use
case throw DownstreamServiceException, and expects 503. For the problem format, see
the chapter The web layer in depth.
Only the requests that call the stock service fail. The service's README states what each dependency affects:
| Dependency | Used for | When it is down |
|---|---|---|
| stock service | placing an order | only POST /orders answers 503, and everything else works |
| token issuer | checking tokens, and the stock call's own token | requests get 401, and placing an order gets 503 |
| SNS | publishing events | events wait in the outbox and are resent |
| SQS | payments | payments wait in the queue |
An agent can still read, pick and ship orders while the stock service is down. A service that keeps working in part when a dependency fails is said to degrade gracefully. Write such a table for every service, and test the row of each HTTP dependency.
ResilientCalls also reports the state of a breaker, for tests and health
checks:
/** Returns the breaker's state, for tests and health checks. */
public CircuitBreaker.State state(String dependency) {
return circuitBreakers.circuitBreaker(dependency).getState();
}The method state returns one of the three states of the breaker. Do not put an open breaker into the readiness probe. The orchestrator would then stop all
traffic to the order service, although only one endpoint needs the stock service. For probes,
see the chapter Logging, tracing and health.
Retry only what is safe to repeatnew#
The order service sends a reservation, and the read timeout fires after 3 seconds. Did the stock service hold the mugs? It may have held them and then been slow to answer. A retry of that POST would then hold the mugs twice, and the shop would refuse the next customer.
The rule is to retry only an idempotent call. A call is idempotent when doing it twice has the same effect as doing it once. A read is idempotent. A POST that creates something is not, unless the other service can recognise the repeat. So the platform offers two methods:
/**
* Calls once, through the dependency's circuit breaker. Use it for a call that must not run
* twice, such as a POST that creates something without an idempotency key: if a timed-out
* POST was retried, the other service could create the thing twice.
*/
public <T> T call(String dependency, Supplier<T> call) {
CircuitBreaker circuitBreaker = circuitBreakers.circuitBreaker(dependency);
return translateFailures(dependency, CircuitBreaker.decorateSupplier(circuitBreaker, call));
}call runs once, through the breaker. callWithRetry also retries. The
test ResilientCallsTest.callsANonIdempotentOperationOnlyOnce expects exactly one
attempt from call, even for a 503.
The reservation is a POST, and the adapter still uses callWithRetry. An
idempotency key makes the POST safe. The key is a value that the caller chooses once and sends
with every attempt. The other service remembers each key with its first answer. A repeated
request with the same key gets the first answer, and changes nothing.
The order ID is the key. The use case chooses the ID before the call, so every attempt carries
the same value. The adapter passes it as the first argument of http.reserve, which
the interface sends as the header Idempotency-Key. The test
StockAdapterTest.sendsTheServicesTokenAndTheOrderIdAsTheIdempotencyKey checks that
the stock service receives the order ID in that header.
An ID created on each attempt would defeat the key. So would an ID created by the stock service. The key must exist before the first attempt, and the caller must keep it.
| The call | Idempotent | Use |
|---|---|---|
| GET of a product, or of a price | yes | callWithRetry |
| POST with an idempotency key that the other service honours | yes | callWithRetry |
| PUT that replaces a whole resource | usually yes | callWithRetry, after checking the other service's contract |
| POST without a key, such as sending an email | no | call |
The key works only if the other service honours it. Ask the other team. Their documentation or their contract must say it. A key that the other service ignores makes the retry unsafe again, and nothing warns you.
The strict rule. A use case's changes and its effects commit together, or not at all.
What we do. The use case reserves the stock before its transaction. If the
transaction then fails, the hold stays in the stock service. The stock service releases it after a
timeout, because no OrderPlacedV1 arrives.
Why. A DocumentDB transaction has time and size limits. A slow HTTP call inside it would hold it open, and a retry would hold it open longer. A hold that expires by itself is cheap, and a lost sale is not.
When to stop. Stop when the stock service cannot release a hold by itself, or when a stale hold costs real money. Then the order service must cancel the hold when its transaction fails. If several services must agree, a saga or a process manager is the next step.
One circuit breaker for each dependencytwist#
ReservationResponse response = resilientCalls.callWithRetry(DEPENDENCY,
() -> http.reserve(order.value(), request));The first argument, DEPENDENCY, is the name of the dependency, here
stock. The registry keeps
one breaker for each name. If the stock service fails and its breaker opens, a call with another
name, such as payment, still has a closed breaker. The test
opensTheBreakerAfterManyFailuresAndThenStopsCalling opens the breaker named
failing. It then expects the breaker named another to stay
CLOSED.
Use one name for each service you call, not one for each endpoint. A service that is down is down for all its endpoints, and one breaker learns that faster. Use one name for each endpoint only when the endpoints fail on their own, for example when they run on different servers.
Compared with Pollytwist#
A .NET developer writes the same policy as a Polly pipeline. This pipeline matches the platform's settings, and it compiles with Polly.Core 8.8.0:
var transient = new PredicateBuilder()
.Handle<HttpRequestException>()
.Handle<TaskCanceledException>();
return new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
ShouldHandle = transient,
MaxRetryAttempts = 2,
Delay = TimeSpan.FromMilliseconds(200),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true
})
.AddCircuitBreaker(new CircuitBreakerStrategyOptions
{
ShouldHandle = transient,
FailureRatio = 0.5,
MinimumThroughput = 10,
BreakDuration = TimeSpan.FromSeconds(30)
})
.Build();MaxRetryAttempts = 2,Polly counts only the retries. So 2 here gives the same 3 calls as
maxAttempts(3)in Resilience4j.ShouldHandle = transient,The predicate has the same purpose as
isTransient. It names the failures that the strategy handles.
The Java side is the constructor of ResilientCalls. See the section
Retry and circuit breaker: what each one is for. The two
libraries differ in several defaults and names:
| Point | Polly 8.8.0 | Resilience4j 2.4.0, as the platform uses it |
|---|---|---|
| retry count | MaxRetryAttempts = 2 makes 3 calls | maxAttempts = 3 makes 3 calls |
| retry defaults | a constant delay of 2 seconds, without jitter | 3 attempts, with a fixed wait of 500 ms |
| breaker window | a time, SamplingDuration, 30 seconds by default | a count of calls, here the last 20 |
| breaker minimum | MinimumThroughput, 100 by default | minimumNumberOfCalls, 100 by default and 10 in the platform |
| breaker defaults | FailureRatio 0.1, BreakDuration 5 seconds | a threshold of 50%, and 60 seconds open |
| order | the first strategy added is the outermost | the outer decorator is the outermost |
AddStandardResilienceHandler in .NET adds a whole pipeline to an
HttpClient in one line. It chains five strategies. The outermost is a rate limiter. Then come a total timeout of 30 seconds, a retry and a circuit breaker. The innermost is an attempt timeout of 10 seconds. Its retry makes 3 retries with exponential waits and jitter. Its breaker uses a failure
ratio of 10% and a minimum of 100 calls. The retry is outside the breaker there too.
The closest match in the platform is ResilientCalls plus the two timeouts of the
factory. The platform has no rate limiter and no total timeout. A total timeout would be the
next thing to add, if agents wait too long during an outage.
Resilience4j core, its starter, or Spring's @Retryabletwist#
/**
* Wraps calls to other services in a circuit breaker, and retries the calls that are safe to
* repeat. It uses the plain-Java core modules of Resilience4j, so it works the same with any
* Spring Boot version.
// ...
public class ResilientCalls {The class ResilientCalls depends on two plain-Java modules, resilience4j-circuitbreaker and
resilience4j-retry, at 2.4.0. It calls their decorators in its own code. A Spring
service has two other choices, and both have real strengths:
| Choice | How a call is protected | What it does well |
|---|---|---|
| Resilience4j core, as in the platform | a method call, such as callWithRetry(...) | one policy in one class, with no tie to the Boot version |
| the Resilience4j starter, resilience4j-spring-boot4 2.4.0 | @Retry(name = "stock") and @CircuitBreaker(name = "stock") on the method | the policy is visible on the method, and each name has its own values in YAML |
| @Retryable from Spring Framework 7 | @Retryable on the method, with @EnableResilientMethods | no extra library at all, for a service that needs only a retry |
The starter's settings for each name are under
resilience4j.retry.instances.<name> and
resilience4j.circuitbreaker.instances.<name>. An operator can change a value
without a build. The library documents the order of its aspects, and it is the platform's order,
with the retry outside. For both annotation styles in code, see the section
Resilience: the Polly answer.
@Retryable counts only the retries, as Polly does. Its defaults are 3 retries, 1
second apart, on any exception, so the default makes 4 calls. Spring Framework 7 has no circuit
breaker, no bulkhead and no time limiter. So the order service would still need Resilience4j for
its breaker.
The book still recommends the core modules in a platform library, for four reasons:
- No tie to the Boot version. The core is plain Java. A new Boot version needs no new starter, so the platform can move first.
- One policy, written once. The rule "retry only transient failures, and ignore 4xx in the breaker" is in one method. With annotations, each method or each YAML instance must repeat it.
- No proxy. The annotations of the starter and
@Retryablework through a proxy. So a call from inside the same class is not protected. For that trap, see the section Self-invocation: a call from inside the class. A method call onResilientCallsalways works. - The choice is in the code.
callWithRetryandcallare two names. A reviewer sees at once which calls retry.
Choose the starter for a single service without a platform library, or when operators must
tune each instance without a release. Choose @Retryable for a service that needs a
retry and no breaker. Do not mix the styles in one service. Two places that decide the retry are
harder to check than one.
The stock stubs in the local environmenttwist#
{
"name": "stock-service: the product OUT-OF-STOCK is never available (for testing the 422 path)",
"priority": 1,
"request": {
"method": "POST",
"urlPath": "/stock/reservations",
"headers": { "Authorization": { "matches": "Bearer .+" } },
"bodyPatterns": [ { "matchesJsonPath": "$.lines[?(@.sku == 'OUT-OF-STOCK')]" } ]
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"jsonBody": { "result": "INSUFFICIENT_STOCK", "shortSkus": ["OUT-OF-STOCK"] }
}
}"priority": 1,When several stubs match a request, the lowest number wins. This stub beats the default answer, which has priority 10.
"headers": { "Authorization": { "matches": "Bearer .+" } },The stub matches only a request with a bearer token, as the real stock service would.
"bodyPatterns": [ { "matchesJsonPath": "$.lines[?(@.sku == 'OUT-OF-STOCK')]" } ]A JSON path selects the lines whose
skuisOUT-OF-STOCK. The stub matches when the path finds one.
The local environment runs WireMock in Docker, with four stubs for the stock service:
| Stub | Priority | Answer |
|---|---|---|
| a line with the product OUT-OF-STOCK | 1 | 200 INSUFFICIENT_STOCK, so the agent gets 422 |
| a line with the product SERVICE-DOWN | 1 | 503, so the retries run and the agent gets 503 |
| any reservation with a token and an Idempotency-Key | 10 | 200 RESERVED |
| any reservation without a token | 20 | 401 |
A run with WireMock 3.13.2 confirmed the priorities. A stub with priority 1 and a
matchesJsonPath beat a catch-all with priority 10. A stub with no priority beat
priority 10, because the default priority is 5. So give every stub a priority, or a stub without
one may win.
A tester can try each path of the client with a product code, without changing any code. QA's Playwright tests use the same stubs, so the stubs must stay realistic. For the local environment, see the chapter Running locally and shipping.
Testing the client against a real HTTP servertwist#
@BeforeEach
void start() throws IOException {
server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.createContext("/token", exchange -> {
tokenCalls.incrementAndGet();
respond(exchange, 200, """
{"access_token":"token-1","token_type":"Bearer","expires_in":3600}""");
});
server.createContext("/stock/reservations", exchange -> {
reservationCalls.incrementAndGet();
authorization = exchange.getRequestHeaders().getFirst("Authorization");
idempotencyKey = exchange.getRequestHeaders().getFirst("Idempotency-Key");
respond(exchange, status, body);
});
server.start();
String baseUrl = "http://localhost:" + server.getAddress().getPort();
HttpClientProperties properties = new HttpClientProperties();
properties.setBaseUrl(URI.create(baseUrl));
properties.setTokenRegistrationId("stock-service");
ResilienceProperties resilience = new ResilienceProperties();
resilience.getRetry().setInitialInterval(Duration.ofMillis(1));
HttpClientFactory factory = new HttpClientFactory(RestClient::builder, () -> tokenManager(baseUrl + "/token"));
adapter = new StockAdapter(factory.create(StockHttpClient.class, properties), new ResilientCalls(resilience));
}server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);com.sun.net.httpserver.HttpServeris a small HTTP server in the JDK. Port 0 asks the system for a free port, so tests never clash.server.createContext("/token", exchange -> {Each context handles one path. This one plays the token issuer, and counts its calls.
idempotencyKey = exchange.getRequestHeaders().getFirst("Idempotency-Key");The stock path records the headers, so a test can check what the client sent.
resilience.getRetry().setInitialInterval(Duration.ofMillis(1));The retries wait 1 ms, so the test of three attempts stays fast.
HttpClientFactory factory = new HttpClientFactory(RestClient::builder, () -> tokenManager(baseUrl + "/token"));The test builds the real factory with plain builders and a real token manager. Only the servers are local.
The test exercises the real code from the adapter to the socket. The JSON is real, the headers are real, the token request is real, and the retries are real. It needs no Docker, no WireMock and no Spring context, so it runs in the normal build. The tests of the class are these:
| Test | What it proves |
|---|---|
| translatesAReservationIntoOurModel | RESERVED becomes allAvailable(), and the unknown field reservationId is ignored |
| translatesMissingStockWithTheProducts | INSUFFICIENT_STOCK becomes unavailable(...) with the product codes |
| sendsTheServicesTokenAndTheOrderIdAsTheIdempotencyKey | the request carries Bearer token-1 and the order ID as the key |
| twoDifferentUsersShareOneServiceToken | two agents cause one token request |
| retriesServerErrorsThenReportsTheServiceAsUnavailable | a 503 is tried 3 times, then becomes DownstreamServiceException |
| refusesAnAnswerItDoesNotKnow | the answer MAYBE throws IllegalStateException |
The rules of ResilientCalls have their own tests in the platform, with plain
lambdas and no HTTP. So the adapter's test checks the wiring, and the platform's test checks the
policy.
In ASP.NET Core, a test of a typed client often uses an HttpMessageHandler that
returns fixed answers. That test never opens a socket. The JDK server costs a few lines more, and
also tests the timeouts, the request factory and the real JSON writer. For test kinds and
WireMock in tests, see the chapter Testing in depth.
1An expert asksThe stock reservation is a POST, which creates a hold. Why may the order service
still retry it, and what would make the retry unsafe?
Idempotency-Key header. The stock service answers a repeated
key with its first answer. The retry becomes unsafe if each attempt makes a new key, or if the
stock service ignores the key.2An expert asksWhy does the platform build each client from Spring Boot's injected
RestClient.Builder, and ask for a new one for each client?
RestClient.Builder, and ask for a new one for each client?traceparent header. A client from RestClient.create() sends none, and
the trace breaks. The builder bean is a prototype and is mutable. A shared builder would pass one
client's base URL and interceptor to the next client.3TranslateWrite this C# translation of the stock service's answer in Java, as a static
method of the adapter:
public static StockAvailability Translate(ReservationResponse response) => response.Result switch
{
"RESERVED" => StockAvailability.AllAvailable(),
"INSUFFICIENT_STOCK" => StockAvailability.Unavailable(
response.ShortSkus.Select(sku => new ProductCode(sku)).ToList()),
_ => throw new InvalidOperationException($"Unknown stock reservation result: {response.Result}")
};
public static StockAvailability Translate(ReservationResponse response) => response.Result switch
{
"RESERVED" => StockAvailability.AllAvailable(),
"INSUFFICIENT_STOCK" => StockAvailability.Unavailable(
response.ShortSkus.Select(sku => new ProductCode(sku)).ToList()),
_ => throw new InvalidOperationException($"Unknown stock reservation result: {response.Result}")
};static StockAvailability translate(ReservationResponse response) {
return switch (response.result()) {
case "RESERVED" -> StockAvailability.allAvailable();
case "INSUFFICIENT_STOCK" -> StockAvailability.unavailable(
response.shortSkus().stream().map(ProductCode::new).toList());
default -> throw new IllegalStateException("Unknown stock reservation result: " + response.result());
};
}The Java switch writes case and ->. It writes
default in place of _. ProductCode::new is a constructor reference, and
toList() ends the stream.
Quick reference#
The platform's settings for retries and circuit breakers, under
platform.resilience, next to the library's defaults:
| Setting | Platform default | Resilience4j default | What it does |
|---|---|---|---|
| retry.max-attempts | 3 | 3 | the attempts in total, including the first call |
| retry.initial-interval | 200ms | 500 ms, fixed | the wait before the first retry |
| retry.multiplier | 2.0 | none, because the default wait is fixed | how much each later wait grows, with a random spread |
| circuit-breaker.failure-rate-threshold | 50 | 50 | the failure rate in percent that opens the breaker |
| circuit-breaker.sliding-window-size | 20 | 100 | how many recent calls the rate is computed over |
| circuit-breaker.minimum-number-of-calls | 10 | 100 | no rate is computed before this many calls |
| circuit-breaker.wait-duration-in-open-state | 30s | 60 s | how long the breaker refuses calls before test calls |
The settings of one client, under clients.<name>:
| Setting | Default | What it does |
|---|---|---|
| base-url | none, and required | the root URL of the other service, checked at startup |
| connect-timeout | 2s | the time to wait for a connection |
| read-timeout | 5s | the time to wait for the answer once connected, 3s for the stock service |
| token-registration-id | none | the Spring Security client registration whose token every call carries |
The exceptions of a call, and where each one ends:
| Exception | Thrown by | The caller gets |
|---|---|---|
| ResourceAccessException | RestClient, for a timeout or a failed connection | 503, after the retries |
| HttpServerErrorException | RestClient, for a 5xx answer | 503, after the retries |
| CallNotPermittedException | the open circuit breaker | 503, at once |
| HttpClientErrorException | RestClient, for a 4xx answer | whatever the adapter decides |
| IllegalStateException | the adapter, for an unknown answer | 500 |
Publishing events: the outbox#
When an order ships, other services must hear about it, and only if the change was saved. The order service writes each outgoing event to an outbox collection, in the same transaction as the change. Spring Modulith sends it to Amazon Simple Notification Service (SNS) after the commit. A scheduled job sends again whatever failed. This chapter covers what leaves, what is guaranteed, and what can go wrong.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | no equivalent | the event stored in the change's transaction, then sent | a direct send to SNS can lose an event or announce a change that rolled back Why |
| twist | a record that implements IDomainEvent | a record that implements DomainEvent | a domain event is internal and may change with the model, so it never leaves the service as it is Why |
| twist | a versioned message class, such as OrderShippedV1 | a record that implements IntegrationEvent | an integration event is a contract with other teams, so it changes carefully Why |
| twist | a switch expression with type patterns | switch (event) { case OrderShipped shipped -> ... } | one mapper per feature decides which domain events leave, and in which shape Why |
| new | no equivalent | OutboxDomainEventPublisher | the platform publishes each domain event and its integration events through Spring's event bus Why |
| trap | no equivalent | publishEvent outside a transaction | the outbox row is stored but never sent, so the platform's publisher refuses with IllegalStateException Why |
| new | the outbox table of MassTransit or NServiceBus | Spring Modulith's event publication registry | one document for each event and transactional listener, written in the publisher's transaction Why |
| twist | no equivalent | spring.modulith.events.completion-mode=delete | a sent row is deleted, so the collection holds only what is still to be sent Why |
| twist | Publish(message) through the bus outbox | @Externalized(OrderTopics.ORDER_EVENTS) on the record | Spring Modulith sends the event to the SNS topic after the commit, on another thread Why |
| trap | no equivalent | the topic name order-events | Spring Cloud AWS finds the ARN by calling CreateTopic, so the role needs sns:CreateTopic and a typo creates a topic Why |
| twist | MessageId set by the bus | eventId, created once in the mapper | a resend keeps the ID, so a consumer's inbox can detect the duplicate Why |
| new | no equivalent | the event ID and the order ID in every log line | the SNS message carries no trace ID by default, so flows are followed by their IDs Why |
| new | no equivalent | OutboxResender and IncompleteEventPublications | a scheduled job sends again the rows that are older than five minutes and still not sent Why |
| trap | no equivalent | a row left RESUBMITTED by an instance that died | the resender skips it for ever, unless the staleness monitor is turned on, as the platform does Why |
| twist | no equivalent | ShedLock's lock in MongoDB | every instance schedules the resender, but only one runs it at a time Why |
| new | no equivalent | @ApplicationModuleListener | it runs after the commit in its own transaction, and its outbox row brings a retry after a failure Why |
| twist | a MediatR INotificationHandler | @EventListener | it runs at once on the publisher's thread, inside its transaction, with no outbox row Why |
| new | no equivalent | at least once, never for a rolled-back change | an event can be sent twice, so every consumer must be idempotent Why |
| twist | UsingAmazonSqs against a local emulator | the journey test with Floci, and the outbox collection empty at the end | turning externalisation off in a test also stops the outbox rows Why |
| new | no equivalent | the externaliser of Spring Cloud AWS 4.1 | it is built on a class that Spring Modulith 2.1 deprecated for removal, so drain the outbox before that upgrade Why |
| twist | AddMongoDbOutbox(o => o.UseBusOutbox()) | the platform's outbox beans, from one dependency | MassTransit v9 needs a licence as of 2026, and v8 stays Apache 2.0 Why |
The dual-write problemnew#
An agent ships an order. The order service saves the order, then sends
OrderShippedV1 to a topic of Amazon SNS. Other
services subscribe to that topic. The service crashes between the two steps. The order is shipped in the
database, and no other service ever hears of it.
Swap the two steps, and the opposite happens. The service sends the event, then the save fails, and the transaction rolls back. Every subscriber now believes in a shipment that never happened.
Writing to two systems that share no transaction is called the dual-write problem. The database and SNS cannot commit together. No order of the two calls is safe. A careful version sends after the commit, from a transaction callback. It is right in the normal case, and it still loses the event when the process dies after the commit and before the send.
The fix is called the transactional outbox. The service writes the event into its own database, in the same transaction as the change. A separate step reads the stored events and sends them. The change and the event are then stored together, or not at all.
In the order service, every use case ends with the same private method. It saves, publishes and audits in the use case's transaction:
private Order saveAndPublish(Order order, String auditAction) {
List<DomainEvent> raised = order.pullEvents();
Order saved = orders.save(order);
events.publish(raised);
audit.record(auditAction, AuditActions.ORDER, saved.id().value());
return saved;
}List<DomainEvent> raised = order.pullEvents();The aggregate collected its events while its methods ran, such as
OrderShippedfromship.pullEventsreturns them and empties the list. For how the aggregate collects events, see the chapter The domain model in plain Java.events.publish(raised);eventsis the portDomainEventPublisher. Its implementation writes the events to the outbox. It sends nothing yet.
The use case never calls SNS. The rest of this chapter follows the events from this line to the topic.
Domain events and integration eventstwist#
public record OrderShipped(OrderId OrderId, DateTimeOffset OccurredAt) : IDomainEvent;/** An order was shipped. It leaves the service as {@code OrderShippedV1}. */
public record OrderShipped(OrderId orderId, Instant occurredAt) implements DomainEvent {
}A domain event tells that something happened inside the service, and its name is in the past tense. In the order service, each one is a record. OrderShipped holds an OrderId, a value object of the domain.
It may change whenever the model changes, because only the order service reads it.
The event that leaves the service is a different record:
/** Published when an order ships. The schema is in {@code contracts/events/OrderShippedV1.json}. */
@Externalized(OrderTopics.ORDER_EVENTS)
public record OrderShippedV1(String eventId, Instant occurredAt, String orderId) implements IntegrationEvent {
}OrderShippedV1 is an integration event. It is a versioned message for other
services, and so a contract with other teams. It holds plain types, such as a
String for the order ID, and an eventId that the domain event does not
need. The two kinds differ in every way that matters for change:
| Question | Domain event | Integration event |
|---|---|---|
| Who reads it | code in this service | other services, owned by other teams |
| Its types | value objects of the domain, such as OrderId | plain types, such as String and Instant |
| Its name | the business fact, OrderShipped | the fact and a version, OrderShippedV1 |
| When it may change | whenever the model changes | only by rules that keep consumers working |
| Where it is declared | order/domain/event | order/messaging/event |
The separation lets the domain evolve without breaking consumers. If one record served both purposes, a renamed field of the domain would break the payment service. Some teams publish the domain events directly, to save a mapper and a class per event. That works while one team owns both sides. It stops working when the first consumer belongs to another team, because every refactoring of the model then needs their agreement.
The mapper decides what leavestwist#
public IIntegrationEvent? ToIntegrationEvent(IDomainEvent domainEvent) => domainEvent switch
{
OrderShipped shipped => new OrderShippedV1(NewEventId(), shipped.OccurredAt, shipped.OrderId.Value),
OrderCancelled cancelled => new OrderCancelledV1(NewEventId(), cancelled.OccurredAt, cancelled.OrderId.Value),
_ => null
};@Component
class OrderIntegrationEventMapper implements IntegrationEventMapper {
@Override
public Optional<IntegrationEvent> toIntegrationEvent(DomainEvent event) {
return switch (event) {
case OrderPlaced placed -> Optional.of(new OrderPlacedV1(newEventId(), placed.occurredAt(),
placed.orderId().value(), placed.deliveryMethod().code(),
placed.items().stream()
.map(item -> new OrderPlacedV1.Line(item.product().value(), item.quantity()))
.toList()));
case OrderShipped shipped -> Optional.of(new OrderShippedV1(newEventId(), shipped.occurredAt(),
shipped.orderId().value()));
case OrderCancelled cancelled -> Optional.of(new OrderCancelledV1(newEventId(), cancelled.occurredAt(),
cancelled.orderId().value()));
default -> Optional.empty();
};
}
}return switch (event) {A switch on an object, with a type pattern in each arm. It is like a
switchexpression with type patterns in C#. Java needs Java 21 or later for it.case OrderShipped shipped -> Optional.of(new OrderShippedV1(newEventId(), shipped.occurredAt(),When the event is an
OrderShipped, the arm binds it toshipped, and builds the integration event from it.newEventId()is a static method ofIntegrationEvent, imported with a static import.default -> Optional.empty();Every other event stays inside the service. Java needs the
defaulthere, becauseDomainEventis an open interface, and the compiler cannot list its types.public Optional<IntegrationEvent> toIntegrationEvent(DomainEvent event) {The platform's interface returns an Optional, where the C# version returns null. An empty
Optionalmeans "this event does not leave the service".
The order feature raises five domain events. The mapper publishes three of them:
| Domain event | Leaves the service as | Why |
|---|---|---|
| OrderPlaced | OrderPlacedV1 | the stock service keeps its hold on the products when it sees it |
| OrderShipped | OrderShippedV1 | other services act on a shipment |
| OrderCancelled | OrderCancelledV1 | other services must undo what the order started |
| LinePicked | nothing | no other service needs to know which line was picked |
| PaymentRecorded | nothing | the payment service sent the payment, so it knows already |
The note feature's events, NoteAdded and NoteActionResolved, have no
mapper at all. They stay inside the service too.
Each feature that publishes events has one mapper bean. The platform's interface says why in its comment:
/**
* Decides which domain events leave the service, and in which shape. Each feature that
* publishes events has one mapper bean.
*
* <p>Return an empty {@link Optional} for an internal event. Every event you publish is a
* contract you must maintain, so publish as few as other services need.
*/
public interface IntegrationEventMapper {
Optional<IntegrationEvent> toIntegrationEvent(DomainEvent event);
}The method toIntegrationEvent is the whole interface. Every published event is a contract to maintain. A consumer may start to depend on it the day
it appears, and the order service can no longer remove it. So the default answer for a new domain
event is Optional.empty(). Publish it when another service asks for it.
The mapper is also the one place to read the answer to "what does the order feature tell the
world?". That question gets an answer in one file per feature, which keeps event flows easy to
follow. The test OrderIntegrationEventMapperTest.keepsInternalEventsInternal passes
a LinePicked, and expects an empty result.
The platform's publishernew#
The use case calls the port DomainEventPublisher with a list of domain events.
Something must turn them into outbox rows. The platform's implementation hands every event to
Spring's event bus, and Spring Modulith writes the rows:
@Override
public void publish(List<DomainEvent> events) {
if (events.isEmpty()) {
return;
}
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
throw new IllegalStateException(
"Publish domain events inside the use case's transaction, so they are stored with the change");
}
for (DomainEvent event : events) {
publisher.publishEvent(event);
mappers.stream()
.map(mapper -> mapper.toIntegrationEvent(event))
.flatMap(Optional::stream)
.forEach(publisher::publishEvent);
}
}if (!TransactionSynchronizationManager.isActualTransactionActive()) {This static method tells whether the current thread runs inside a real transaction. The next section explains why the publisher refuses to work without one.
publisher.publishEvent(event);The field
publisherholds Spring'sApplicationEventPublisher. The domain event itself is published first, so a listener in this service can react to it. See the section Reacting inside the service..map(mapper -> mapper.toIntegrationEvent(event))Every mapper bean of the service is asked. Most answer with an empty
Optional..flatMap(Optional::stream)Optional::streamturns an emptyOptionalinto an empty stream, and a full one into a stream of one element. So only the real integration events go on..forEach(publisher::publishEvent);Each integration event is published too. It is
@Externalized, so Spring Modulith sends it to SNS after the commit.
The publisher knows no feature and no topic. It receives the list of every
IntegrationEventMapper bean from the auto-configuration:
@Bean
@ConditionalOnMissingBean(DomainEventPublisher.class)
OutboxDomainEventPublisher domainEventPublisher(ApplicationEventPublisher publisher,
ObjectProvider<IntegrationEventMapper> mappers) {
return new OutboxDomainEventPublisher(publisher, mappers.orderedStream().toList());
}mappers.orderedStream() returns every mapper bean of the service. A new
feature adds a mapper, and the publisher uses it with no change. A service may still replace the
whole publisher, because of @ConditionalOnMissingBean.
The test OutboxDomainEventPublisherTest.publishesEachDomainEventAndItsIntegrationEvent
publishes two domain events. It expects each domain event, followed by its integration event, if
it has one.
An older design used a port for each feature, such as an OrderEvents interface,
with an adapter that called ApplicationEventPublisher for each event type. That
design is explicit. A reader of the adapter sees every event that the feature sends. But each
feature repeated the same adapter, and nothing stopped a publish outside a transaction. The
platform's publisher keeps the explicit part in the mapper, and writes the adapter once.
Publishing outside a transactiontrap#
A developer adds a job that cancels old orders. The job has no
@Transactional, and it publishes the events of each cancelled order. A run for this
book did the same with an @Externalized event and no transaction at all:
events.publishEvent(new OrderPlacedV1("o-8", false));
Thread.sleep(500);
System.out.println(" SNS calls so far=" + ctx.getBean(FakeSns.class).calls.get());
rows(jdbc);Show the answer
PUBLISHED. The event never reaches SNS.The row is written, but the listener that sends it waits for a commit that never
comes. Spring Modulith stores the row when the event is published. The sending
listener is a transactional listener. Without a transaction, it does not run, because its
fallbackExecution is false by default.
At shutdown, the registry logged "Shutting down with the following publications left unfinished". So the platform's publisher refuses to publish outside a transaction.
The refusal turns a silent loss into a loud error. The test
OutboxDomainEventPublisherTest.refusesToPublishOutsideATransaction expects the
IllegalStateException, and checks that nothing reached Spring's publisher.
Do not rely on the resender for such a row. Fix the caller. Put the job's work into a use case with @Transactional, or run it in a
TransactionTemplate. For both, see the chapter Transactions in
depth.
An empty list needs no transaction. The publisher returns before the check, so a use case
that raised no events never fails. anEmptyListNeedsNoTransaction tests that.
Spring Modulith's event publication registrynew#
The outbox itself comes from a library. Spring Modulith is a Spring project for structuring an application into modules. One of its parts is the event publication registry. It records each event that a transactional listener must receive, and it marks the record complete when the listener has run. That registry is the order service's outbox.
The platform library adds these modules:
<!--
The outbox (Spring Modulith's event publication registry in MongoDB, sent to SNS) and SQS.
The platform needs no spring-modulith-events-jackson. The MongoDB store keeps each event
as an embedded document, and never uses a serializer. SNS writes JSON with Boot's JsonMapper.
-->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-events-api</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-events-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-events-mongodb</artifactId>
</dependency>
<dependency>
<groupId>io.awspring.cloud</groupId>
<artifactId>spring-cloud-aws-modulith-events-sns</artifactId>
</dependency><artifactId>spring-modulith-events-api</artifactId>The annotations and interfaces that code uses, such as
@Externalized,@ApplicationModuleListenerandIncompleteEventPublications.<artifactId>spring-modulith-events-core</artifactId>The registry itself, and the multicaster that writes the rows.
<artifactId>spring-modulith-events-mongodb</artifactId>The store in MongoDB. It was not run against DocumentDB for this book, so test the outbox on a real cluster early.
<artifactId>spring-cloud-aws-modulith-events-sns</artifactId>The part that sends
@Externalizedevents to SNS. It comes from Spring Cloud AWS, not from Spring Modulith.
The comment names a module that the platform leaves out on purpose.
spring-modulith-events-jackson holds the serializer that writes events as JSON. The
store for Java Database Connectivity (JDBC) uses it. The
MongoDB store keeps the event as an embedded
document instead, and its source never calls the serializer. The SNS sender writes JSON with
the JsonMapper bean of Boot, as the source of Spring Cloud AWS shows. Nothing else in
the core of Spring Modulith uses the serializer. So the platform removed the module, and the
tests, the journey test with SNS among them, still pass.
The versions are 2.1.2 for Spring Modulith and 4.1.0 for Spring Cloud AWS, as of October 2026. The parent Project Object Model (POM) sets them. For the parent POM, see the chapter The build: a parent POM and a platform library.
The registry works through Spring's event bus. A call of publishEvent reaches
Spring Modulith's multicaster. For each matching transactional listener, the multicaster writes
one row. The sender to SNS counts as such a listener. Facts from the source and from runs:
- Only transactional listeners create a row.
@ApplicationModuleListenerand the SNS sender are transactional listeners. A plain@EventListeneris not. - The row is written in the publisher's transaction. Before the commit, the row was already
there with status
PUBLISHED. After a rollback, there was no row and no SNS call. - An event that no transactional listener receives creates no row, and nobody hears it.
- A row belongs to one event and one listener.
OrderPlacedhas a listener that counts orders, so it gets a row for that listener.OrderPlacedV1gets a row for the SNS sender.
In MongoDB, the rows are documents in the collection event_publication. The
source of the MongoDB store gives their fields:
| Field | What it holds |
|---|---|
| _id | a UUID for the publication |
| publicationDate | when the event was published, inside the transaction |
| listenerId | the listener that must receive the event, as a method signature |
| event | the event itself, as an embedded document, not as a JSON string |
| status | PUBLISHED, PROCESSING, COMPLETED, FAILED or RESUBMITTED |
| completionDate | when the listener finished, empty until then |
| completionAttempts | how many times a listener tried, 1 at first |
| lastResubmissionDate | when the resender last took the row |
A failed listener marks its row FAILED. In a run, the row went
FAILED, then COMPLETED with completionAttempts 2 after a
resend.
A run of the local environment, on 25 October 2026, recorded one row in MongoDB 8.0.34. The
order service ran in Docker, and the SNS emulator was stopped, so the send failed and the row
stayed. mongosh printed it as follows. Only the array lines is joined
onto one line here:
{
_id: UUID('0680c15d-37e7-478a-86c7-695ebc121f43'),
publicationDate: ISODate('2026-10-25T22:30:54.689Z'),
listenerId: 'org.springframework.modulith.events.support.DelegatingEventExternalizer.externalize(java.lang.Object)',
event: {
eventId: '14970a89-d74a-4ec3-b3bc-2d2bbf3621c8',
occurredAt: ISODate('2026-10-25T22:30:54.677Z'),
orderId: '9ade9c6c-c572-4a45-80ab-7ac67746370d',
deliveryMethod: 'STANDARD',
lines: [ { productCode: 'MUG-001', quantity: 1 } ],
_class: 'com.acme.orderservice.order.messaging.event.OrderPlacedV1'
},
lastResubmissionDate: ISODate('2026-10-25T22:30:54.689Z'),
completionAttempts: 1,
status: 'FAILED',
_class: 'org.springframework.modulith.events.mongodb.MongoDbEventPublication'
}The output is not an excerpt of the companion. It is what the database held. Four details matter:
_idis a universally unique identifier (UUID) in the standard binary form, whichmongoshprints asUUID(...). That form comes from the platform's settingspring.mongodb.representation.uuid=standard.- The event is an embedded document with its own
_class, the full name of the record. ItsInstantbecame a BSON date. So the class name is part of the stored row. Spring Data reads it when it loads the row for a resend. This book did not run a rename, so send every row of an event before its record is renamed or moved. - The listener is the SNS sender, named by its method signature. That is the only row, because
OrderPlacedV1has no other transactional listener. - The row has no
completionDatefield at all, because the send never completed. Withcompletion-mode=delete, a successful send removes the row.
The platform uses this registry rather than an outbox of its own, because the registry already does the three hard parts. It stores the row in the publisher's transaction. It sends after the commit, on another thread. And it keeps the rows in MongoDB, with a resend API. An outbox written in-house would need all three, plus their tests. The cost is a dependency on the registry's design, which the section What the sender depends on describes.
Spring Modulith 2.1 added a second mode, spring.modulith.events.externalization.mode=outbox.
It sends events through the Namastack Outbox library. The reference of Spring Modulith says that
this mode is available only for relational databases. The README of Namastack Outbox 1.11.0 lists
MongoDB among its stores, as of October 2026. The two sources disagree, and nobody ran the mode on
MongoDB for this book. So the order service on DocumentDB uses the default mode, the registry
itself.
The registry's settings, and whytwist#
# The outbox: Spring Modulith's event publication registry, collection "event_publication".
# Delete a publication once it has been sent, so the collection stays small.
spring.modulith.events.completion-mode=delete
# The platform's mongo package already provides the transaction manager.
spring.modulith.events.mongodb.transaction-management.enabled=false
# OutboxResender sends unsent events on a schedule, so a restart need not resend them as well.
spring.modulith.events.republish-outstanding-events-on-restart=false
# ...
spring.modulith.events.externalization.enabled=truespring.modulith.events.completion-mode=deleteThe default is
update, which keeps each completed row with its completion date.deleteremoves it.archivemoves it toevent_publication_archive. In a run withdelete, completed rows vanished, and failed or unsent rows stayed.spring.modulith.events.mongodb.transaction-management.enabled=falseThe default is
true. Spring Modulith then registers its ownMongoTransactionManager. The platform's mongo package registers one already, so this line keeps one manager.spring.modulith.events.republish-outstanding-events-on-restart=falseThis is the default anyway. When true, each instance resends every unfinished row at startup. The reference warns against that with several instances, because other instances may still be sending those rows.
spring.modulith.events.externalization.enabled=trueThe default too. Externalisation is the step that sends
@Externalizedevents to SNS. The line states the platform's intent where a reader looks.
The file is in the platform's jar, under META-INF/platform-defaults/. It gives every
service these values as defaults, and a service's own settings override them. For how a library
adds defaults, see the chapter Writing the platform library.
The choice of delete keeps the collection small. Only rows still to be sent
remain, so an operator who counts the documents counts the backlog. The cost is the history.
Choose archive if support must answer "was this event ever sent?" from the
database. Then give the archive collection a time-to-live (TTL) index, so it cleans itself.
@Externalized: from the outbox to SNStwist#
/**
* Published when an order is placed. The stock service uses it to keep its hold on the products.
*
* <p>{@link Externalized} tells Spring Modulith to send the event to the SNS topic after the
* transaction commits. The schema is in {@code contracts/events/OrderPlacedV1.json}.
*/
@Externalized(OrderTopics.ORDER_EVENTS)
public record OrderPlacedV1(String eventId, Instant occurredAt, String orderId, String deliveryMethod,
List<Line> lines) implements IntegrationEvent {
public record Line(String productCode, int quantity) {
}
}@Externalized(OrderTopics.ORDER_EVENTS)The value is the target, here the topic name
order-events. A target may also carry a routing key, written"target::key". The key may be an expression of the Spring Expression Language (SpEL), such as#{orderId()}, evaluated on the event.public record Line(String productCode, int quantity) {The lines use the shop's own words,
productCode, because this is the order service's contract. The stock service's wordskustays in the client package.
The sending itself happens later, and elsewhere. A run for this book recorded each send to a fake SNS:
- Before the commit, the count of SNS calls was 0.
- Every send ran on a thread named
task-N, and outside any transaction. - The sender is a method with
@ApplicationModuleListener(propagation = SUPPORTS). So it runs after the commit, on Spring's task executor.
Two consequences follow. The request's thread does not wait for SNS, so a slow SNS never slows
the agent's request. And an error from SNS never reaches the use case. It appears in the log,
on a task- thread, and the row stays for the resender.
An @Externalized event declared outside the application's package is not
sent. By default, Spring Modulith considers only events in the auto-configuration
packages, the package of @SpringBootApplication and below. This is read from the
source and was not run. Declare integration events in the service, as the order service does in
order/messaging/event, never in a shared library.
A FIFO topic needs a routing key, because SNS requires a message group ID there. Spring
Cloud AWS uses the routing key as that group ID. In a run, the target
"order-events.fifo::#{orderId()}" sent with the group ID of the order. The order
service uses a standard topic, so it has no key.
From a topic name to an ARNtrap#
The order feature names its topic once, in a constant:
/**
* Spring Cloud AWS turns this name into the topic's ARN by calling {@code CreateTopic}, which
* returns the ARN of an existing topic. So the service's IAM role needs {@code sns:CreateTopic}
* as well as {@code sns:Publish}, and a typo here silently creates a new, empty topic.
*/
public static final String ORDER_EVENTS = "order-events";SNS needs the full Amazon Resource Name (ARN) of a topic, such as
arn:aws:sns:eu-west-2:000000000000:order-events. The constant
ORDER_EVENTS holds only the name. Spring Cloud AWS resolves the name, and the source
shows how:
SnsTemplateuses aCachingTopicArnResolveraround aDefaultTopicArnResolver. A service may replace them with a resolver bean of its own.DefaultTopicArnResolverpasses a value that starts witharn:through. For a name, it callsCreateTopic. That call returns the ARN of a topic that exists already.- The result is cached for each name.
A typo in the topic name creates a new, empty topic. CreateTopic
with order-evnets succeeds, and the events go to a topic that nobody reads. And
the service's Identity and Access Management (IAM) role needs sns:CreateTopic as
well as sns:Publish. Without it, the resolution fails, although publishing is
allowed.
There are two ways to avoid the call. Put the full ARN in the target, which every
environment must then set. Or declare a TopicsListingTopicArnResolver bean, which
pages through ListTopics and never creates a topic. The order service keeps the
name, because one constant serves every environment. Its README lists both permissions for the
role. For the role itself, see the section AWS credentials: a role, never a
key.
The event ID and the versiontwist#
/**
* A versioned message that crosses the service boundary, such as {@code OrderShippedV1}. It is
* a contract with other teams, so it changes carefully:
*
* <ul>
* <li>The version is in the name. Within a version, add fields freely, but never remove or
* rename one. For a breaking change, publish {@code V2} next to {@code V1} until every
* consumer has moved.</li>
* <li>It holds IDs and facts in plain types, and no more personal data than a consumer needs.</li>
* <li>The event ID is created once, with the event. A resend keeps it, so a consumer's inbox
* can detect the duplicate.</li>
* </ul>
*/
public interface IntegrationEvent {
String eventId();
Instant occurredAt();
/** Returns a new, random event ID. Call it once, when the event is created. */
static String newEventId() {
return UUID.randomUUID().toString();
}
}String eventId();Every integration event has an ID. A record implements this method with its component of the same name,
eventId.static String newEventId() {An interface may have a static method in Java. The mapper calls it once for each event. A
UUIDas text is unique without a database.
The mapper creates the ID, inside the use case's transaction. The integration event, with its ID, is then stored in the outbox row. A resend reads the same row, so it sends the same ID. A consumer that has seen the ID knows that the message is a duplicate. For that consumer, see the chapter Consuming events: SQS and the inbox.
An ID made at send time would break this. Each resend would then carry a new ID, and every duplicate would look new.
The other rules in the comment keep consumers working:
- The version is part of the name, as in
OrderShippedV1. A new optional field is safe, because consumers ignore fields they do not know. - A removed or renamed field breaks a consumer. Publish
OrderShippedV2next to V1, until every consumer has moved. - Plain types only, such as
StringandInstant. A consumer in another language can read them. - IDs and facts, and no personal data beyond what a consumer needs.
OrderPlacedV1carries no customer name or email address.
Each published event has a JSON schema in contracts/events/. For schemas, versions
and how to check them, see the chapter API and event contracts.
Keeping event flows traceablenew#
An agent asks why the stock service still holds the mugs of a shipped order. Support must follow one order from the HTTP request, through the outbox and SNS, to the stock service's log. Events make that harder than a direct call, because the send happens later, on another thread.
Plan for it. The order service uses four practices:
- Business IDs in every event. Each integration event carries the
orderId. A search for the order ID finds the request, the audit record, the event and the consumer's log lines. - The event ID in every consumer's log. The inbox puts the
eventIdinto the logging context. See the section Log lines that name the message. - One mapper per feature. The question "which events does the order feature send?" has one answer, in one file.
- A schema for every event. The files in
contracts/events/list what each event contains.
The trace ID of the request does not travel with the event. In a run with no header extractor configured, the SNS notification had no headers at all. So a consumer starts a new trace, and the order ID connects the two traces. A service that needs one trace across the event must add the trace context as a message header itself. How Spring Cloud AWS maps such headers to SNS message attributes was not checked.
Here is the schema of one event, with the fields that a support engineer searches for:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "order-service/OrderShippedV1",
"title": "OrderShippedV1",
"description": "An order was shipped. Published to the order-events SNS topic.",
"type": "object",
"required": ["eventId", "occurredAt", "orderId"],
"properties": {
"eventId": { "type": "string", "format": "uuid" },
"occurredAt": { "type": "string", "format": "date-time" },
"orderId": { "type": "string", "format": "uuid" }
},
"additionalProperties": true
}"additionalProperties": true tells consumers that new fields may appear. The
three required fields are the ones every flow is traced by.
A flow that spans many services and days needs more than IDs. Then use a workflow engine or a process manager, which keeps the state of the whole flow in one place. For that choice, see the section What keeps a DDD service readable.
Resending what was not sentnew#
SNS is unavailable for a few minutes. Every send in that time fails, and its row stays in the
outbox as FAILED. Nothing sends it again by itself. So the platform runs a job that
resends old rows:
@Scheduled(initialDelayString = "${platform.messaging.outbox.resend-interval:PT1M}",
fixedDelayString = "${platform.messaging.outbox.resend-interval:PT1M}")
void resend() {
LockConfiguration lock = new LockConfiguration(clock.instant(), LOCK_NAME,
properties.getLockAtMostFor(), properties.getResendInterval().dividedBy(2));
locks.executeWithLock((Runnable) this::resendNow, lock);
}
private void resendNow() {
Duration olderThan = properties.getResendOlderThan();
log.debug("Resending outbox events older than {}", olderThan);
incompletePublications.resubmitIncompletePublicationsOlderThan(olderThan);
}fixedDelayString = "${platform.messaging.outbox.resend-interval:PT1M}")The job runs once a minute by default, and each run starts after the previous run ended. For @Scheduled with durations from settings, see the section @Scheduled with durations from settings.
locks.executeWithLock((Runnable) this::resendNow, lock);The run happens only if this instance gets the lock, which ShedLock keeps. See the section One instance at a time: ShedLock.
incompletePublications.resubmitIncompletePublicationsOlderThan(olderThan);The Spring Modulith interface
IncompleteEventPublicationssends each unfinished row again, if it is older than five minutes.
The five minutes protect the rows that are still being sent. A row written a second ago is
unfinished too, because its send is running on a task thread. Resending it would send the event
twice for no reason. The age counts from the first publication date, not from the last resend.
In a run, resubmitIncompletePublicationsOlderThan(PT1H) did nothing for a fresh
failure, and PT0S resent it at once.
The settings are in MessagingProperties.Outbox, bound from
platform.messaging.outbox.*:
/** Turns the scheduled resender on. Tests turn it off, so no background job runs. */
private boolean resendEnabled = true;
/** How often the resender runs. */
private Duration resendInterval = Duration.ofMinutes(1);
/** Only events older than this are resent, so events still being sent are left alone. */
private Duration resendOlderThan = Duration.ofMinutes(5);
/** The longest one instance may hold the resender lock, in case it dies while holding it. */
private Duration lockAtMostFor = Duration.ofMinutes(5);The field resendEnabled switches the whole job. The platform's messaging
auto-configuration declares the resender in a nested configuration, together with
@EnableScheduling. So scheduling is on only when the resender is on. A service that
has jobs of its own declares @EnableScheduling itself. For that trap and the
platform's callout on it, see the section Scheduling, switched on by
the platform.
The setting republish-outstanding-events-on-restart stays false, because this job
already resends. Two mechanisms would only send more duplicates.
A row that an instance took just before it died stays RESUBMITTED, and
the resender skips it for ever. The resend claims each row in one atomic update. The
update sets the status RESUBMITTED, and only for a row that is not
RESUBMITTED already. So a row is never claimed twice. But if the process dies after
the claim, no later run takes the row. Only Spring Modulith's staleness monitor moves it back to
FAILED. The monitor is off by default. It registers only when one of the settings
spring.modulith.events.staleness.published, .processing or
.resubmitted is set.
The claim comes from the source of the MongoDB store. A run with two instances on MongoDB is pending. The platform therefore switches the monitor on, in its defaults:
# Spring Modulith's staleness monitor. A row that has stayed RESUBMITTED for this long goes back
# to FAILED, so the resender takes it again. A row stays RESUBMITTED for ever when an instance
# dies during a resend. Keep the value longer than the longest resend run (lock-at-most-for,
# 5 minutes), or a resend that is still running would send its events a second time.
spring.modulith.events.staleness.resubmitted=PT15Mspring.modulith.events.staleness.resubmitted=PT15MA row that has been
RESUBMITTEDfor 15 minutes goes back toFAILED. The next run of the resender takes it again. The monitor measures from the row's last resubmission date, or from its publication date when it has none.Keep the value longer than the longest resend run (lock-at-most-for,A resend that is still running has rows in
RESUBMITTEDtoo. If the monitor marked themFAILED, the next run would send them a second time. The resender's lock lasts at most five minutes, so no run takes longer than that. Fifteen minutes leaves a margin.
The monitor checks once a minute by default, with
spring.modulith.events.staleness.check-interval. It registers through
SchedulingConfigurer, so it runs only when scheduling is on, and so only together with
the resender. For how it is scheduled, see the section Scheduling,
switched on by the platform. Each instance runs the monitor, with no lock. Marking a row FAILED a
second time should change nothing, but this book did not run two monitors at once.
Watch the backlog as well. With completion-mode=delete, the number of documents in
event_publication is the number of events not yet sent. A number that only grows
means that SNS or a listener keeps failing. For metrics and alerts, see the chapter
Logging, tracing and health.
One instance at a time: ShedLocktwist#
/** ShedLock's lock in MongoDB, in the collection {@code scheduler_locks}. */
@Bean
@ConditionalOnMissingBean
LockingTaskExecutor lockingTaskExecutor(MongoTemplate mongo) {
return new DefaultLockingTaskExecutor(new MongoLockProvider(mongo.getCollection("scheduler_locks")));
}When the order service runs on three instances, each one schedules the resender. Without a
lock, all three would read the same rows every minute, and send them again. ShedLock is a small
library that lets only one instance run a scheduled job at a time. This bean keeps its lock as a
document in the collection scheduler_locks. An instance that finds the lock taken
skips its run, and does not wait. For the lock, its two durations, its tests and a prediction,
see the section Several instances, and the lock.
The lock reduces duplicates, and it does not remove them. An instance can stop after a send and before it marks the row complete. The next run then sends that event again. So every consumer must be idempotent. ShedLock's README does not mention DocumentDB, and the provider was not run against a DocumentDB cluster.
Reacting inside the servicenew#
A developer wants to count shipped orders, as a business metric. The obvious tool is a
listener for the domain event OrderShipped. Spring has two kinds of listener for
this, and they behave very differently. A run for this book published one event to each, inside
a transaction:
@Component
public class Listeners {
@EventListener
void on(Events.NoteAdded e) {
System.out.println(" @EventListener NoteAdded on thread " + Thread.currentThread().getName()
+ ", inTx=" + TransactionSynchronizationManager.isActualTransactionActive());
}
@ApplicationModuleListener
void on(Events.PaymentRecorded e) {
System.out.println(" @ApplicationModuleListener PaymentRecorded on thread " + Thread.currentThread().getName()
+ ", inTx=" + TransactionSynchronizationManager.isActualTransactionActive());
}
}Show the answer
@EventListener creates no row, and runs at once on the publisher's thread,
inside its transaction. The @ApplicationModuleListener creates one row, and runs
after the commit on the thread task-1, in a new transaction.Only a transactional listener gets an outbox row.
@ApplicationModuleListener combines three annotations. They are
@Async, @Transactional(propagation = REQUIRES_NEW) and
@TransactionalEventListener. The default phase of the last one is after the
commit.
A plain @EventListener runs synchronously, during publishEvent.
Spring Modulith records nothing for it. If it fails, the use case fails with it. If the use case
later rolls back, the listener has already acted.
The order service counts its orders with the second kind:
@ApplicationModuleListener
void on(OrderPlaced event) {
placed.increment();
}
@ApplicationModuleListener
void on(OrderShipped event) {
shipped.increment();
}Each method on receives a domain event, and increments a counter of
Micrometer. A rolled-back order is never counted,
because the listener runs only after a commit. A failed listener is resent, because it has a row.
The journey test reads the counter shop.orders with the tag
shipped, and expects at least 1.
The two kinds of listener compare like this:
| Question | @EventListener | @ApplicationModuleListener |
|---|---|---|
| When it runs | at once, inside publishEvent | after the commit |
| Which thread | the publisher's thread | a thread of Spring's task executor, such as task-1 |
| Which transaction | the publisher's transaction, if any | a new one of its own |
| An outbox row | no | yes, one for each event |
| After a failure | the use case fails too | the row stays, and the resender tries again |
Choose @ApplicationModuleListener for a side effect that may happen a moment
later, and must not slow or break the use case. Examples are metrics, a cache refresh, or a step
of another feature. Keep a rule that must hold at once in the use case itself, in its
transaction. A plain @EventListener is right only for work that must share the
publisher's transaction, and that is rare.
Spring Modulith turns on @EnableAsync by itself for these listeners, unless the
application has its own async configuration. In ASP.NET Core, a plain
listener behaves as a MediatR notification handler does. A run showed that
Publish in MediatR runs its handlers on the caller's thread, and a failing handler
fails the caller.
A domain event that no mapper publishes and no listener receives is dropped. It creates no row,
and nobody hears it. That is correct for LinePicked today. To react to such an event
later, add an @ApplicationModuleListener. The publisher already publishes every
domain event, so no other change is needed.
What the outbox guaranteesnew#
A consumer team asks what they can rely on. The answer comes from the steps of the outbox, and from what can fail between them:
| What fails, and when | What the consumers see |
|---|---|
| the use case throws, and the transaction rolls back | no event, because the row rolled back with the change |
| the process dies before the commit | no event, for the same reason |
| SNS is down after the commit | the event, later, when the resender sends it |
| the process dies after the send, before the row is marked complete | the event twice |
| two instances resend in one moment | the event once in that round, because the claim is atomic, as the source shows |
The resender's own comment names the case of the duplicate:
* <p>Consumers must still be idempotent: an event can be sent twice, for example when an
* instance stops after sending and before marking the row complete.The comment's first words, Consumers must still be idempotent, state the price of
the outbox. So the outbox gives three guarantees:
- A saved change always produces its event. The row commits with the change, and stays until it is sent.
- A rolled-back change never produces an event. The row was never committed.
- An event may arrive more than once. This is called at-least-once delivery. Amazon Simple Queue Service (SQS) adds its own duplicates on top.
Two things are not guaranteed. The order of events is not guaranteed, because each send runs on a task thread, and a resent event arrives late. And the delay is not guaranteed. A failed event waits at least five minutes.
Consumers handle both. A consumer detects duplicates by the eventId, and its
domain ignores a late message. The order service does the same as a consumer of payments. For
that side, see the chapter Consuming events: SQS and the inbox.
The outbox needs a database transaction, with every write of the use case inside it. For transactions on DocumentDB and their limits, see the chapter Transactions in depth.
Testing the outboxtwist#
// The outbox sent the published events to SNS, and SNS delivered them to the subscribed queue.
List<String> received = new ArrayList<>();
await().atMost(Duration.ofSeconds(20)).untilAsserted(() -> {
aws.sqs.receiveMessage(request -> request.queueUrl(aws.orderEventsQueueUrl).maxNumberOfMessages(10))
.messages().stream().map(Message::body).forEach(received::add);
assertThat(received).anySatisfy(body -> assertThat(body).contains(orderId, "\"deliveryMethod\":\"STANDARD\""))
.anySatisfy(body -> assertThat(body).contains(orderId).doesNotContain("deliveryMethod"));
});
// Completed publications are deleted, so the outbox empties once every listener has run.
await().atMost(Duration.ofSeconds(20)).untilAsserted(() ->
assertThat(mongo.findAll(Document.class, "event_publication")).isEmpty());await().atMost(Duration.ofSeconds(20)).untilAsserted(() -> {Awaitility repeats the block until it passes, or 20 seconds have gone. The send happens on another thread after the commit, so the test must wait for it.
aws.sqs.receiveMessage(request -> request.queueUrl(aws.orderEventsQueueUrl).maxNumberOfMessages(10))The test subscribed its own queue to the topic
order-events, with raw message delivery. It reads what SNS delivered there..anySatisfy(body -> assertThat(body).contains(orderId).doesNotContain("deliveryMethod"));One message is
OrderPlacedV1, with the delivery method. Another isOrderShippedV1, without it.assertThat(mongo.findAll(Document.class, "event_publication")).isEmpty());Every row was completed and deleted, the counting listener's rows too.
The journey test runs the service against Floci, a free emulator of SNS and SQS, in Docker. It proves the whole path. The path is the row, the commit, the send, SNS and the subscription. The platform's unit tests cover the parts without Docker:
| Test | What it proves |
|---|---|
| OutboxDomainEventPublisherTest | the refusal outside a transaction, and each domain event followed by its integration event |
| OrderIntegrationEventMapperTest | which events leave, their plain types, and that internal events stay inside |
| OutboxResenderTest | the resend with the five-minute age, and the skip when the lock is taken |
| OutboxResenderLockIT | two executors on a real MongoDB, and only one runs |
A second design keeps SNS out of the test. Point the SNS endpoint at an address where nothing
listens, then check that the row stays in event_publication. That test needs no
emulator, and it shows the outbox directly. It has two weak points. The failed send appears later,
on another thread, as an error in the log. And it proves nothing about the topic or the
subscription.
Turning off externalisation in a test also turns off the outbox rows. With
spring.modulith.events.externalization.enabled=false, an
@Externalized event produced no row in a run. The sender is the transactional
listener behind the row. Without the sender, the event has no listener, so no row is written.
Turn off platform.messaging.outbox.resend-enabled instead, as the journey test
does.
For Floci, Testcontainers and the other test kinds, see the chapter Testing in depth.
What the sender depends onnew#
The libraries behind the outbox moved recently. Spring Modulith had its own AWS modules once. It removed them in version 1.4, in favour of the modules of Spring Cloud AWS. Version 1.4.0 came out on 27 May 2025, and 2.0.0 on 21 November 2025. So a guide that names the old modules is older than 1.4.
The current sender comes from spring-cloud-aws-modulith-events-sns 4.1.0. The
platform's parent POM sets the versions:
<!-- Libraries that Spring Boot does not manage -->
<spring-modulith.version>2.1.2</spring-modulith.version>
<spring-cloud-aws.version>4.1.0</spring-cloud-aws.version>The comment says why spring-modulith.version and spring-cloud-aws.version are set at all. Spring Boot does not manage these two libraries. The
sender of Spring Cloud AWS 4.1 is built on DelegatingEventExternalizer. Spring
Modulith 2.1 deprecated that class for removal, with the advice to use
EventExternalizerModuleListener instead.
The deprecation matters for the rows. Each row names its listener by a method signature. In a
run, the sender's rows had the listener ID
...DelegatingEventExternalizer.externalize(java.lang.Object). A later Spring Cloud
AWS may move to the new class. Rows written before that upgrade would then name a listener that
no longer exists. The source shows that the multicaster then logs "Listener ... not found" and
marks the row failed. This is read from the source, not run.
The strict rule. Do not build on a class that is deprecated for removal.
What we do. The platform uses the SNS sender of Spring Cloud AWS 4.1, which
is built on DelegatingEventExternalizer. That class is deprecated for removal in
Spring Modulith 2.1.
Why. It is the SNS sender that Spring Cloud AWS provides, and Spring Modulith removed its own. It works with Spring Modulith 2.1.2 and Boot 4.1.2, as the journey test shows. Writing a sender of our own would mean owning the retry and the completion logic.
When to stop. Stop when a Spring Cloud AWS release moves to
EventExternalizerModuleListener, or when Spring Modulith removes the old class.
Before that upgrade, let the outbox drain to zero documents, stop the services, then
deploy.
Compared with MassTransit and NServiceBustwist#
builder.Services.AddMassTransit(x =>
{
x.AddMongoDbOutbox(o =>
{
o.ClientFactory(provider => provider.GetRequiredService<IMongoClient>());
o.DatabaseFactory(provider => provider.GetRequiredService<IMongoDatabase>());
o.UseBusOutbox();
});
x.UsingAmazonSqs((context, cfg) => cfg.ConfigureEndpoints(context));
});@AutoConfiguration(after = PlatformMongoAutoConfiguration.class)
@ConditionalOnProperty(name = "platform.messaging.enabled", havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(MessagingProperties.class)
public class PlatformMessagingAutoConfiguration {x.AddMongoDbOutbox(o =>MassTransit's outbox in MongoDB, from the package
MassTransit.MongoDb. The C# pane compiles with MassTransit 8.5.12.o.UseBusOutbox();The bus outbox stores the messages that the code publishes, until they are sent.
@AutoConfiguration(after = PlatformMongoAutoConfiguration.class)In Java, a service writes no setup. The platform's auto-configuration runs after the mongo one, which provides the transaction manager.
@ConditionalOnProperty(name = "platform.messaging.enabled", havingValue = "true", matchIfMissing = true)A service that sends and receives no events turns the whole package off with one setting.
MassTransit's documentation describes its bus outbox as adding published and sent messages to the database context. Its consumer outbox combines an inbox and an outbox. The documentation also says that the outbox does not replace idempotent business logic. Spring Modulith's registry has the same purpose as the bus outbox. The order service writes its inbox itself, which the next chapter shows.
The licences differ, and they changed recently. As of October 2026:
| Library | Licence | Note |
|---|---|---|
| MassTransit v9 | commercial, source-available | a paid licence is needed once annual revenue exceeds one million US dollars |
| MassTransit v8 | Apache 2.0 | 8.5.12 came out on 21 October 2026, and an archived notice ends official maintenance after 2026, with no exact date |
| NServiceBus | commercial, free for development | its outbox simulates an atomic transaction over the database and the queue |
MassTransit 9.0.0 came out on 6 January 2026. A team that moves from v8 to v9 must check the
licence first. NServiceBus deduplicates by MessageId, and supports MongoDB among
its stores. Its documentation warns that messages sent through IMessageSession are
not handled by the outbox. That is the same trap as a publish outside a transaction.
1An expert asksWhy can the order service not send OrderShippedV1 to SNS right after
it saves the order, even from an after-commit callback?
OrderShippedV1 to SNS right after
it saves the order, even from an after-commit callback?2An expert asksWhy does the mapper create the eventId, and not the code that sends
the event?
eventId, and not the code that sends
the event?3An instance of the order service dies just after the resender claimed an outbox row. What
happens to the row, and what brings it back?
RESUBMITTED, and later resends skip it, because the claim takes
only rows that are not RESUBMITTED. Only Spring Modulith's staleness monitor moves
it back to FAILED, and the monitor is off until
spring.modulith.events.staleness.resubmitted is set.4TranslateWrite this C# mapper as the Java mapper of the order feature, for the
cancelled order only, so that every other event stays inside:
public IIntegrationEvent? ToIntegrationEvent(IDomainEvent domainEvent) => domainEvent switch
{
OrderCancelled cancelled => new OrderCancelledV1(NewEventId(), cancelled.OccurredAt, cancelled.OrderId.Value),
_ => null
};
public IIntegrationEvent? ToIntegrationEvent(IDomainEvent domainEvent) => domainEvent switch
{
OrderCancelled cancelled => new OrderCancelledV1(NewEventId(), cancelled.OccurredAt, cancelled.OrderId.Value),
_ => null
};public Optional<IntegrationEvent> toIntegrationEvent(DomainEvent event) {
return switch (event) {
// ...
case OrderCancelled cancelled -> Optional.of(new OrderCancelledV1(newEventId(), cancelled.occurredAt(),
cancelled.orderId().value()));
default -> Optional.empty();
};
}The Java switch uses a type pattern in each case. default returns an
empty Optional where the C# version returns null. The // ... stands for
the arms of the other published events.
Quick reference#
The settings that the outbox uses, with the platform's values:
| Setting | Platform value | Library default | What it does |
|---|---|---|---|
| spring.modulith.events.completion-mode | delete | update | what happens to a row once its listener has run |
| spring.modulith.events.mongodb.transaction-management.enabled | false | true | whether Spring Modulith registers its own MongoDB transaction manager |
| spring.modulith.events.republish-outstanding-events-on-restart | false | false | whether each instance resends every unfinished row at startup |
| spring.modulith.events.externalization.enabled | true | true | whether @Externalized events are sent, and get their rows |
| spring.modulith.events.staleness.resubmitted | PT15M | not set, so no monitor | after this time, a RESUBMITTED row moves back to FAILED |
| spring.modulith.events.staleness.check-interval | not set | one minute | how often the staleness monitor checks |
| platform.messaging.outbox.resend-enabled | true | none, a platform setting | whether the resender and its scheduling exist |
| platform.messaging.outbox.resend-interval | PT1M | none, a platform setting | the pause between two resend runs |
| platform.messaging.outbox.resend-older-than | PT5M | none, a platform setting | the age a row must reach before it is resent |
| platform.messaging.outbox.lock-at-most-for | PT5M | none, a platform setting | how long a dead instance can hold the lock |
The statuses of an outbox row in Spring Modulith 2.1:
| Status | Meaning |
|---|---|
| PUBLISHED | written with the change, and not yet handled |
| PROCESSING | a listener is running for the row at this moment |
| COMPLETED | the listener finished, and with completion-mode=delete the row is gone |
| FAILED | the listener threw, so the resender may take the row |
| RESUBMITTED | the resender took the row, and a dead instance can leave it here |
Consuming events: SQS and the inbox#
The order service reads the payment service's events from a queue of Amazon Simple Queue Service (SQS). SQS may deliver the same message twice. So the order service keeps the ID of each event it has handled, in an inbox, in the same transaction as the change. Its domain also ignores a repeated payment. This chapter covers the listener, failures, the dead-letter queue and the inbox.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | no equivalent | the inbox, keyed by consumer and event ID | a standard queue delivers at least once, so each event is handled once by the record of its ID Why |
| twist | IMessageHandler<PaymentReceivedV1> from AWS.Messaging | @SqsListener("${messaging.queues.payment-received}") | Spring Cloud AWS polls the queue, converts the JSON body, and calls the method Why |
| twist | a record for the other service's event | PaymentReceivedV1, this service's own copy | it holds only the fields the service uses, and ignores the others Why |
| trap | no equivalent | a subscription without raw message delivery | the record is built from the SNS envelope with every field null, and the message is deleted Why |
| new | no equivalent | Objects.requireNonNull in the record's compact constructor | a wrong message fails, is retried, and ends in the dead-letter queue, where someone sees it Why |
| twist | MessageProcessStatus.Success() | a listener method that returns normally | the container acknowledges on success, and an acknowledgement deletes the message Why |
| trap | the framework extends the visibility timeout | the visibility timeout of the queue, 30 seconds | the container deletes in batches and extends nothing, so a short timeout delivers a message twice Why |
| twist | MessageProcessStatus.Failed() | an exception from the listener | the message is not deleted, comes back after the timeout, and moves to the dead-letter queue after 5 receives Why |
| trap | no equivalent | the default queue-not-found strategy, CREATE | a missing queue is created with no dead-letter queue, so the platform sets fail Why |
| new | the inbox of MassTransit's consumer outbox | Inbox.process(consumer, event, handler) | in one transaction it skips a known key, or runs the handler and inserts the key Why |
| trap | no equivalent | the consumer name order-service.payment-received | a new name makes every old event look new, so the name never changes Why |
| twist | MongoCommandException with code 112, in the .NET driver too | DataIntegrityViolationException with a write conflict, code 112 | two copies inside two transactions collide before a commit, so the inbox catches both errors Why |
| twist | no equivalent | a TTL index on processedAt, 14 days | MongoDB deletes old keys by itself, and 14 days is the longest that SQS keeps a message Why |
| new | no equivalent | InboxMessageHandler, with a final process and an abstract handle | every listener gets the inbox, the log context and the system actor the same way Why |
| twist | a thin consumer class | handle builds value objects and calls one use case | the listener holds no business rule, and the use case joins the inbox's transaction Why |
| twist | no equivalent | Order.recordPayment ignores a payment that is not later | the domain is idempotent too, a second defence when the inbox key is gone Why |
| twist | ILogger.BeginScope | LogContext.with(...) in the base class | every log line of a message carries the event ID and the order ID Why |
| twist | an integration test against a real queue | the journey test sends the payment twice through Floci | the order is paid once, and a message that is not the event reaches the dead-letter queue Why |
| twist | an SQS FIFO queue, used through AWSSDK.SQS | a FIFO queue and topic | exactly-once processing within a window, at a lower throughput, and the order service does not need it Why |
| twist | AddAWSMessageBus with AddSQSPoller | Spring Cloud AWS's listener container | both poll and delete for you, but only AWS.Messaging extends the visibility timeout Why |
Why every consumer needs an inboxnew#
The payment service publishes PaymentReceivedV1 to its topic in Amazon Simple
Notification Service (SNS). SNS copies it into the order service's queue in SQS. The order
service reads it,
and marks the order paid. SQS sometimes delivers the same message twice, and the payment
service's own outbox may send it twice as well.
The AWS documentation is direct about this. A standard queue gives at-least-once delivery. More than one copy of a message might be delivered, and the order is only best effort. It tells consumers to be idempotent. A consumer is idempotent when handling a message twice has the same effect as handling it once.
The journey test does what SQS may do. It sends the same payment event twice:
// The payment service's event arrives through SQS, twice, as at-least-once delivery allows.
String payment = paymentEvent(UUID.randomUUID().toString(), orderId);
aws.sqs.sendMessage(message -> message.queueUrl(aws.paymentQueueUrl).messageBody(payment));
aws.sqs.sendMessage(message -> message.queueUrl(aws.paymentQueueUrl).messageBody(payment));
await().atMost(Duration.ofSeconds(20)).untilAsserted(() ->
mvc.perform(get("/orders/" + orderId).with(agent)).andExpect(jsonPath("$.status").value("PAID")));String payment = paymentEvent(UUID.randomUUID().toString(), orderId);One event, with one
eventId. Both messages carry the same text.aws.sqs.sendMessage(message -> message.queueUrl(aws.paymentQueueUrl).messageBody(payment));The test writes straight to the queue, through the AWS SDK, as SNS would.
Later, the same test reads the audit log, and finds exactly one PAYMENT_RECORDED
for the order. So the service handled the event once, although it received it twice.
The order service uses two defences. The first is the inbox. An inbox is a collection where a consumer stores the ID of each event that it has handled. It stores the ID in the same transaction as the change. A copy that arrives later finds the ID, and is skipped. The second defence is the domain itself, which ignores a payment that is not newer than the last one. The sections below show both, after the listener that receives the message.
For messaging in general and Kafka, see the chapter Messaging and WebSockets. For the outbox on the sending side, see the chapter Publishing events: the outbox.
The listener: @SqsListenertwist#
public class PaymentReceivedHandler(IOrderService orders) : IMessageHandler<PaymentReceivedV1>
{
public async Task<MessageProcessStatus> HandleAsync(MessageEnvelope<PaymentReceivedV1> envelope,
CancellationToken token = default)
{
PaymentReceivedV1 e = envelope.Message;
await orders.RecordPaymentAsync(e.OrderId, e.PaymentReference, e.Amount, e.Currency, e.PaidAt);
return MessageProcessStatus.Success();
}
}/** The queue name comes from configuration, so each environment can name its own queue. */
@SqsListener("${messaging.queues.payment-received}")
void on(PaymentReceivedV1 event) {
process(event);
}@SqsListener("${messaging.queues.payment-received}")This annotation of Spring Cloud AWS 4.1 makes the method a listener of one queue. The value is a property placeholder, so the queue name comes from
application.yml.void on(PaymentReceivedV1 event) {The parameter's type tells Spring Cloud AWS how to read the message body. The JSON becomes a
PaymentReceivedV1.process(event);The method passes the event to the platform's base class. See the section The base class of every listener.
The C# handler returns a status. The Java method returns nothing, and an exception means
failure. The C# pane compiles with AWS.Messaging 1.6.0.
The queue name has a default in the service's settings, and each environment may override it with an environment variable:
messaging:
queues:
payment-received: ${PAYMENT_RECEIVED_QUEUE:order-service-payment-received}The variable PAYMENT_RECEIVED_QUEUE overrides the default name. The key messaging.queues.payment-received belongs to the service, not to Spring.
A run for this book confirmed that @SqsListener("${...}") resolves such a
placeholder. Behind the method, Spring Cloud AWS runs a listener container. The container polls
the queue, converts each message, calls the method, and deletes the message when the method
returns. In the same run, a JSON body with an extra field paidBy became the record, and the extra field was ignored.
The container also passes the message's metadata as headers. A parameter with
@Header(name = "eventType") receives an SQS message attribute. The run listed the
headers that the container adds:
| Header | What it holds |
|---|---|
| Sqs_Msa_ApproximateReceiveCount | how many times SQS has delivered this message, the first delivery included |
| Sqs_QueueName and Sqs_QueueUrl | the queue the message came from |
| Sqs_ReceiptHandle | the handle that a delete or a visibility change needs |
| Sqs_VisibilityTimeout | the visibility timeout of the receive |
| Sqs_RawMessageId | the message ID that SQS gave the message |
The service's own copy of the eventtwist#
@JsonIgnoreProperties(ignoreUnknown = true)
record PaymentReceivedV1(String eventId, Instant occurredAt, String orderId, String paymentReference,
BigDecimal amount, String currency, Instant paidAt) implements IntegrationEvent {The payment service owns PaymentReceivedV1. The order service does not share a
library with it. It declares its own record, with only the
fields it uses. The payment service can add fields, and the order service ignores them. The test
PaymentReceivedV1Test.readsTheEventAndIgnoresFieldsItDoesNotUse reads an event with
an extra field cardScheme.
The record is package-private, in order.messaging. Only the listener uses it. It
implements the platform's IntegrationEvent, so it has an eventId for the
inbox. The fields are plain types. amount is a BigDecimal, never a
double, because it is money.
The service also keeps a copy of the part of their schema that it relies on:
"$id": "payment-service/PaymentReceivedV1",
"title": "PaymentReceivedV1 (consumed)",
"description": "Owned by the payment service. This is the part of their contract that order-service relies on, kept here so a change on their side is easy to check. Received on the order-service-payment-received SQS queue, with raw message delivery.",
"type": "object",
"required": ["eventId", "occurredAt", "orderId", "paymentReference", "amount", "currency", "paidAt"],The required list is the order service's side of the contract. When the payment
service plans a change, both teams compare their schema with this file. For event contracts and
their checks, see the chapter API and event contracts.
The SNS envelope, and the null traptrap#
SNS wraps each message in a JSON envelope before it puts it on a queue, unless the
subscription asks for raw message delivery. The envelope holds fields such as Type, MessageId and TopicArn. It holds the event itself as a string, in the field Message. A run for this book sent such an envelope to a listener that expects the
event:
public record PaymentReceivedV1(String eventId, String orderId, BigDecimal amount) {}
// ...
@SqsListener("envelope-plain")
void onEnvelopePlain(PaymentReceivedV1 event) {
System.out.println(" envelope-plain listener got " + event);
}
// ...
var envelope = "{\"Type\":\"Notification\",\"MessageId\":\"m-1\",\"TopicArn\":\"arn:aws:sns:eu-west-2:000000000000:payment-events\","
+ "\"Message\":\"{\\\"eventId\\\":\\\"e-3\\\",\\\"orderId\\\":\\\"o-3\\\",\\\"amount\\\":5}\",\"Timestamp\":\"2026-10-25T10:00:00.000Z\","
+ "\"SignatureVersion\":\"1\",\"Signature\":\"x\",\"SigningCertURL\":\"https://x\",\"UnsubscribeURL\":\"https://x\"}";
SQS.send("envelope-plain", envelope, Map.of());Show the answer
PaymentReceivedV1[eventId=null, orderId=null, amount=null], with no error. The
message is acknowledged and deleted, so the payment is lost.Jackson 3 ignores unknown fields, so the envelope's fields are dropped, and the record's fields are simply absent. No field of the envelope has the name of a record component. Nothing fails, so the listener returns normally. The container acknowledges a message on success, and an acknowledgement deletes it.
Turn on raw message delivery on the subscription. And make the record refuse missing fields, so a wrong subscription fails loudly.
The local environment turns raw delivery on for every subscription it creates:
# subscribe <topic-arn> <queue-url>: raw delivery, so the message body is the event itself and
# not an SNS envelope around it.
subscribe() {
queue_arn=$(aws sqs get-queue-attributes --queue-url "$2" --attribute-names QueueArn --query Attributes.QueueArn --output text)
aws sns subscribe --topic-arn "$1" --protocol sqs --notification-endpoint "$queue_arn" \
--attributes RawMessageDelivery=true >/dev/null
}queue_arn=$(aws sqs get-queue-attributes --queue-url "$2" --attribute-names QueueArn --query Attributes.QueueArn --output text)A subscription needs the queue's Amazon Resource Name (ARN), not its URL. This line asks SQS for it.
--attributes RawMessageDelivery=true >/dev/nullWith raw delivery, SNS sends the message as it is, with no envelope. The AWS documentation says that SNS then strips its metadata.
The infrastructure code of each environment must set the same attribute. The service's README says that the subscription must use raw message delivery. But a setting in another repository can be forgotten, so the order service also defends itself in code:
PaymentReceivedV1 {
Objects.requireNonNull(eventId, "eventId");
Objects.requireNonNull(occurredAt, "occurredAt");
Objects.requireNonNull(orderId, "orderId");
Objects.requireNonNull(paymentReference, "paymentReference");
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(currency, "currency");
Objects.requireNonNull(paidAt, "paidAt");
}PaymentReceivedV1 {A compact constructor. It runs every time the record is created, also when Jackson creates it from JSON.
Objects.requireNonNull(eventId, "eventId");java.util.Objects.requireNonNullthrows aNullPointerExceptionwith the given message when the value is null.
Now an envelope cannot become a record. The conversion fails, the message is not acknowledged,
and SQS delivers it again. After the maximum number of receives, SQS moves it to the dead-letter
queue, where someone sees it. The test
PaymentReceivedV1Test.refusesAnSnsEnvelopeInsteadOfTheEvent reads an envelope, and
expects the root cause NullPointerException with the message
eventId. The journey test
aMessageThatIsNotTheEventEndsInTheDeadLetterQueue proves the whole path with
Floci.
A third way exists. The annotation @SnsNotificationMessage on the parameter unwraps
the envelope. In the same run, it gave PaymentReceivedV1[eventId=e-3, orderId=o-3,
amount=5]. Choose it only when raw delivery cannot be turned on. With raw delivery, the
same listener also works for a message that another source sends straight to the queue.
With raw delivery on, SNS can send at most 10 message attributes. The AWS documentation says that a message with more is discarded as a client-side error. Keep the attributes few.
Acknowledged on success, deleted in batchestwist#
* <p>If the handler throws, the transaction rolls back and the message is not acknowledged.
* SQS delivers it again after the visibility timeout, and after the queue's maximum receive
* count it moves the message to the dead-letter queue.The comment says that a failed message is not acknowledged. An acknowledgement is the container's signal that a message is done. In SQS, acknowledging a
message is the same as deleting it from the queue. The default acknowledgement mode of the
container is ON_SUCCESS. So the outcome of the listener method decides:
| The listener method | What the container does | What SQS does |
|---|---|---|
| returns normally | deletes the message, in a batch | nothing more, because the message is gone |
| throws an exception | nothing, so no delete and no visibility change | makes the message visible again when the visibility timeout ends |
A run for this book confirmed both rows. A successful message was followed by
deleteMessageBatch. A failing message was received again after each timeout, with no
delete and no call to change its visibility.
The deletes are batched. On a standard queue, the container deletes every second, or every 10 messages. On a FIFO queue, it deletes at once. In the run, the deletes came about 1.0 second after the receive.
Other defaults of the container, from its source and the same run:
| Default | Value | Why it matters |
|---|---|---|
| messages per poll | 10 | one receive call returns up to 10 messages |
| poll timeout | 10 seconds, which is long polling | an empty queue costs few requests |
| messages in flight per queue | 10 | at most 10 messages of one queue are handled at once |
| listener shutdown timeout | 20 seconds | the time that running handlers get when the service stops |
| message attributes | All | every SQS attribute reaches the listener as a header |
The other modes are ALWAYS and MANUAL. The order service keeps
ON_SUCCESS, because the outcome of the inbox's transaction must decide.
The visibility timeouttrap#
When a consumer receives a message, SQS hides it from other consumers for a while. That time is called the visibility timeout. Its default is 30 seconds, and its maximum is 12 hours. If the message is not deleted before the timeout ends, it becomes visible again, and another receive gets it.
A C# developer who used AWS.Messaging may expect the timeout to look after itself.
That framework extends the visibility timeout while a handler runs. Spring Cloud AWS's default
container does not. The run saw no visibility call at all.
A visibility timeout shorter than the handler plus about one second delivers every message twice, even when nothing fails. The container deletes in batches, up to one second after the handler ends. In a run with a simulated timeout of 700 ms, every successful message was delivered twice before its delete arrived. Size the queue's timeout for the slowest handler, plus the batch interval.
The local environment's script says the same where the queue is created:
# The visibility timeout must be longer than the slowest handler plus about 1 second: Spring
# Cloud AWS acknowledges (deletes) messages in batches, every second or every 10 messages, and
# does not extend the timeout while a handler runs. A shorter timeout delivers each message twice.
queue() {
dlq_url=$(aws sqs create-queue --queue-name "$1-dlq" --query QueueUrl --output text)
dlq_arn=$(aws sqs get-queue-attributes --queue-url "$dlq_url" --attribute-names QueueArn --query Attributes.QueueArn --output text)
aws sqs create-queue --queue-name "$1" \
--attributes "{\"VisibilityTimeout\":\"30\",\"RedrivePolicy\":\"{\\\"deadLetterTargetArn\\\":\\\"$dlq_arn\\\",\\\"maxReceiveCount\\\":\\\"5\\\"}\"}" \
--query QueueUrl --output text
}dlq_url=$(aws sqs create-queue --queue-name "$1-dlq" --query QueueUrl --output text)The dead-letter queue comes first, because the main queue's policy names it.
--attributes "{\"VisibilityTimeout\":\"30\",\"RedrivePolicy\":\"{\\\"deadLetterTargetArn\\\":\\\"$dlq_arn\\\",\\\"maxReceiveCount\\\":\\\"5\\\"}\"}" \Two attributes in one JSON text.
VisibilityTimeoutis 30 seconds. The redrive policy names the dead-letter queue and the receive count. The quoting looks hard, because the policy is JSON inside JSON inside a shell string.
The payment handler is short, so 30 seconds is plenty. A handler that calls a slow service needs a longer timeout, or a call that extends it. The duplicates of a short timeout reach the inbox, which skips them. But each one costs a read and a transaction.
Failures, redrive and the dead-letter queuetwist#
this.paymentDeadLetterQueueUrl = sqs.createQueue(queue -> queue.queueName(PAYMENT_QUEUE + "-dlq")).queueUrl();
String redrive = "{\"deadLetterTargetArn\":\"" + queueArn(paymentDeadLetterQueueUrl)
+ "\",\"maxReceiveCount\":\"" + MAX_RECEIVES + "\"}";
this.paymentQueueUrl = sqs.createQueue(queue -> queue.queueName(PAYMENT_QUEUE).attributes(Map.of(
QueueAttributeName.REDRIVE_POLICY, redrive,
QueueAttributeName.VISIBILITY_TIMEOUT, "2"))).queueUrl();String redrive = "{\"deadLetterTargetArn\":\"" + queueArn(paymentDeadLetterQueueUrl)The redrive policy is JSON text. It names the dead-letter queue by its ARN.
+ "\",\"maxReceiveCount\":\"" + MAX_RECEIVES + "\"}";The journey test uses 2 receives, so a bad message reaches the dead-letter queue in seconds.
QueueAttributeName.VISIBILITY_TIMEOUT, "2"))).queueUrl();A failed message becomes visible again after 2 seconds.
The journey test creates its queues as the local script does, with the AWS SDK for Java. A message that always fails is called a poison message. Without a limit, SQS would deliver it again and again, and it would use the listener's capacity for ever.
A dead-letter queue is a second queue that receives such messages. The source queue's redrive
policy names it, with a maxReceiveCount. The AWS documentation defines that count. It is
the number of times a consumer can receive a message before SQS moves it to the dead-letter queue.
The local queues use 5. The journey test uses 2, so it finishes quickly.
The move is a feature of SQS. Spring Cloud AWS does not do it, and the order service has no code for it. So the path of a failing payment is this:
- The handler throws, and the inbox's transaction rolls back. The order is unchanged, and no inbox key is written.
- The container does not acknowledge, so the message stays in the queue.
- After the visibility timeout, SQS delivers it again. The header
Sqs_Msa_ApproximateReceiveCountgrows by one. - After 5 receives, SQS moves the message to
order-service-payment-received-dlq. - Someone looks at the message, fixes the cause, and moves it back.
SQS can move messages from the dead-letter queue back to the source queue, with the API
StartMessageMoveTask. The dead-letter queue of a standard queue must be a standard queue,
and that of a FIFO queue must be a FIFO queue.
Alert on a dead-letter queue that is not empty. A message there means a payment that the order service never recorded. For metrics and alerts, see the chapter Logging, tracing and health.
A transient failure, such as a database that is briefly unavailable, takes the same path, and may succeed on a later receive. So the receive count also works as a slow retry. It does not need Resilience4j.
A queue that does not existtrap#
A deployment sets PAYMENT_RECEIVED_QUEUE with a typo. At startup, the container
looks up the queue's URL, and finds no queue. What it does then is a setting,
spring.cloud.aws.sqs.queue-not-found-strategy. The platform sets it to
fail:
# Stop at startup when a queue does not exist. The default creates the missing queue,
# which then has no dead-letter queue, so a failing message would be retried for ever.
spring.cloud.aws.sqs.queue-not-found-strategy=failThe comment names the danger. The property queue-not-found-strategy takes a value
of the type QueueNotFoundStrategy.
By default, Spring Cloud AWS creates a queue that does not exist. The default of
QueueNotFoundStrategy in the code is CREATE. The new queue has no
dead-letter queue and no subscription. So the service listens to an empty queue, and a poison message
on it would be retried for ever. The setting's metadata gives no default, and the code does.
With fail, the service stops at startup, and the orchestrator shows the error at
once. Queues belong to the infrastructure code and to the local script, never to the service. This
matches the rule for topics in the chapter Publishing events: the outbox,
with one difference. A topic name is resolved by CreateTopic, and no setting stops
that.
The inboxnew#
The same payment arrives twice. Without an inbox, the order service handles it twice. For a payment, the domain ignores the second copy. Most handlers are not so forgiving. A handler that sends an email or adds a line would act twice. So the platform gives every consumer an inbox:
public <T extends IntegrationEvent> void process(String consumer, T event, Consumer<T> handler) {
String key = consumer + ":" + event.eventId();
try {
transactions.executeWithoutResult(status -> {
if (mongo.exists(query(where("_id").is(key)), COLLECTION)) {
log.info("Skipping event {}, which was processed before", event.eventId());
return;
}
handler.accept(event);
mongo.insert(new Document("_id", key).append("processedAt", clock.instant()), COLLECTION);
});
} catch (DataIntegrityViolationException e) {
// DuplicateKeyException is a DataIntegrityViolationException; so is a translated write conflict.
if (e instanceof DuplicateKeyException || WriteConflicts.isWriteConflict(e)) {
log.info("Event {} was processed by another delivery at the same time", event.eventId());
throw new ConcurrentDeliveryException(key, e);
}
throw e;
}
}public <T extends IntegrationEvent> void process(String consumer, T event, Consumer<T> handler) {A generic method for any integration event. The handler is a
Consumer<T>, a function that takes the event and returns nothing. It does the same asAction<T>in C#.String key = consumer + ":" + event.eventId();The key combines the consumer's name and the event's ID, such as
order-service.payment-received:3f2a.... Two consumers of one event in the same service have two keys.transactions.executeWithoutResult(status -> {Everything in the lambda runs in one transaction of a
TransactionTemplate.if (mongo.exists(query(where("_id").is(key)), COLLECTION)) {The key is the document's
_idinprocessed_messages. If it exists, the event was handled before, and the method returns.handler.accept(event);The handler runs inside the same transaction. The use case that it calls joins it.
mongo.insert(new Document("_id", key).append("processedAt", clock.instant()), COLLECTION);The key is inserted after the handler, with the time. The commit stores the change and the key together.
The order of the steps matters. The key is written after the handler, in the same transaction. If the handler fails, the key rolls back with the change, so a redelivery runs the handler again. If the key were committed first, a failure after it would lose the event for ever.
The test InboxIT.processesARepeatedEventOnlyOnce handles one event twice against
a real MongoDB, and finds one stored parcel. rollsBackTheHandlersChangesWhenItFails
lets the handler throw after a write. Afterwards the test finds no write and no key.
The inbox is platform code, a few dozen lines long. MassTransit offers an inbox in .NET, as part of its consumer outbox. The platform did not use a library for it in Java. The research looked for one in October 2026, and found none that is maintained and supports MongoDB:
| Candidate | What the research found |
|---|---|
| Spring Modulith 2.1.2 | an outbox with a MongoDB store, and no inbox, which its source and its issues do not mention |
| Namastack Outbox 1.11.0 | an outbox for JDBC, JPA and MongoDB, with no inbox, and a guarantees page that asks for idempotent handlers |
| Spring Integration 7.1.2 | IdempotentReceiverInterceptor with a MetadataStore such as MongoDbMetadataStore, made for the message handlers of Spring Integration |
| small starters on GitHub with 2026 commits | an inbox for JDBC, R2DBC, PostgreSQL, MySQL or Oracle, and none for MongoDB |
Spring Integration's interceptor is the careful alternative. It is maintained, and its MongoDB store keeps the keys across restarts. It fits a service that already uses Spring Integration for its messages. The order service receives messages with Spring Cloud AWS's listener container, not with Spring Integration. Adding Spring Integration only for the inbox would bring a second messaging model into the service. The platform's inbox is short, it shares the use case's transaction, and its tests run against MongoDB. Re-evaluate when Spring Modulith or Namastack adds an inbox with a MongoDB store.
The inbox needs transactions, as the outbox does. The inbox and the outbox were tested on MongoDB 8.0.34, not on Amazon DocumentDB. Test both on a real DocumentDB cluster early in a project. For DocumentDB transactions, see the chapter Transactions in depth.
Two copies at the same momenttwist#
SQS can deliver two copies of one message to two instances at nearly the same time. Both
inboxes check the key, and both find nothing. Both handlers run. Both transactions try to insert
the same _id. A C# developer expects the second insert to fail with a duplicate-key
error. The platform's integration test records what MongoDB really does:
@Test
void aConcurrentDuplicateRollsBackAndIsSkippedOnRedelivery() throws Exception {
ParcelArrivedV1 event = new ParcelArrivedV1("event-3", Instant.EPOCH);
CyclicBarrier bothHandled = new CyclicBarrier(2);
Consumer<ParcelArrivedV1> handler = arrived -> {
storeParcel(arrived);
await(bothHandled);
};Show the answer
DataIntegrityViolationException. It is not a DuplicateKeyException. The
inbox then throws ConcurrentDeliveryException.Inside two open transactions, the collision on the key is a write conflict. The
first transaction has inserted the key and not yet committed. The second insert fails at once with
code 112 and the label TransientTransactionError. Spring translates code 112 to
DataIntegrityViolationException.
Only a copy that starts after the first commit gets DuplicateKeyException, code
11000. An inbox that catches only the duplicate key treats the race as an unknown failure. So the
platform checks for both.
The test then checks the outcome:
assertThat(failures).singleElement().isInstanceOfSatisfying(ConcurrentDeliveryException.class,
error -> assertThat(error.getCause().getMessage()).contains("WriteConflict"));
assertThat(mongo.findAll(Document.class, "parcels")).hasSize(1);
inbox.process(CONSUMER, event, this::storeParcel); // the redelivery of the losing copy
assertThat(mongo.findAll(Document.class, "parcels")).hasSize(1);assertThat(failures).singleElement().isInstanceOfSatisfying(ConcurrentDeliveryException.class,Exactly one delivery failed, with the platform's exception.
error -> assertThat(error.getCause().getMessage()).contains("WriteConflict"));Its cause names the write conflict.
assertThat(mongo.findAll(Document.class, "parcels")).hasSize(1);Both handlers wrote a parcel, but the loser's write rolled back. One parcel remains.
inbox.process(CONSUMER, event, this::storeParcel); // the redelivery of the losing copySQS would deliver the losing copy again. That delivery finds the key, and skips.
The platform's helper WriteConflicts.isWriteConflict walks the causes of the
exception. It looks for a MongoDB exception with code 112 or with the label
TransientTransactionError. The Spring type alone is not enough, because
DataIntegrityViolationException also covers real data errors.
The losing copy is not an error to alert on. ConcurrentDeliveryException reaches
the container, so the message is not acknowledged. Its redelivery finds the key of the winner, and
is skipped. MongoDB does not retry the transaction by itself, and Spring does not retry it either. The redelivery of SQS is
the retry.
The .NET driver reports the same server error, and its callback API retries the transaction. For that difference, see the section Write conflicts, and what Spring retries. The answer of DocumentDB to the same race was not measured.
The inbox cleans itself: a TTL indextwist#
@Override
public String id() {
return "P002-inbox-indexes";
}
@Override
public void migrate(MongoTemplate mongo) {
mongo.indexOps(Inbox.COLLECTION).createIndex(new Index()
.on("processedAt", Sort.Direction.ASC)
.expire(properties.getInbox().getRetention())
.named("processed_messages_ttl"));
}return "P002-inbox-indexes";This is a migration of the platform, with an ID that never changes. The migration runner applies it once. For the runner, see the chapter Migrations, indexes and seed data.
.expire(properties.getInbox().getRetention())A time-to-live (TTL) index deletes each document a fixed time after the date in the field
processedAt..named("processed_messages_ttl"));A named index can be found and changed later by its name.
Without the index, the collection would grow by one document for every message, for ever. The
retention is 14 days, from MessagingProperties.Inbox. SQS keeps a message for 4 days by
default, and for 14 days at most. So no copy of a message can arrive after its key is gone. A
queue with a shorter retention could use a shorter inbox retention.
The deletes are not exact. The DocumentDB documentation says that deletes from a TTL index are best effort, with no fixed timing. And DocumentDB lists TTL for single-field indexes, so the index has one field. For TTL indexes in full, see the chapter Migrations, indexes and seed data.
The base class of every listenernew#
Every listener needs the same steps. It needs the inbox, the event's IDs in the log, and the system as the actor. If each listener wrote these steps, one would forget the inbox. So the platform puts them in an abstract base class, and a listener writes only the part that differs:
public abstract class InboxMessageHandler<T extends IntegrationEvent> {
private final String consumer;
private final Inbox inbox;
/**
* @param consumer a stable name, such as {@code order-service.payment-received}; never change
* it, or the inbox treats every old event as new
* @param inbox the platform's inbox
*/
protected InboxMessageHandler(String consumer, Inbox inbox) {
this.consumer = consumer;
this.inbox = inbox;
}
protected final void process(T event) {
Map<String, String> context = new HashMap<>(logContext(event));
context.put("eventId", event.eventId());
SecurityContext callerContext = SecurityContextHolder.getContext();
SecurityContextHolder.clearContext();
try (LogContext ignored = LogContext.with(context)) {
inbox.process(consumer, event, this::handle);
} finally {
SecurityContextHolder.setContext(callerContext);
}
}
/** Translates the event and calls the application service. */
protected abstract void handle(T event);
/** Returns the business IDs to add to the MDC, such as the order ID. Only IDs, never personal data. */
protected Map<String, String> logContext(T event) {
return Map.of();
}
}public abstract class InboxMessageHandler<T extends IntegrationEvent> {A generic abstract class.
Tis the event type, and it must be an integration event, so it has aneventId.protected final void process(T event) {finalmeans that no subclass can override the method. So no listener can skip the inbox. It is like a method withoutvirtualin C#.SecurityContextHolder.clearContext();The work runs with no user, as the system actor. The
finallyblock restores the thread's earlier context.inbox.process(consumer, event, this::handle);this::handleis a method reference to the subclass'shandle, passed as the inbox'sConsumer.protected abstract void handle(T event);The one method that each listener must write.
protected Map<String, String> logContext(T event) {A listener may override it to add business IDs to the log. The default adds none.
The base class is an example of the template method pattern. A pattern of this name fixes the steps of an algorithm in a base class, and lets subclasses fill in one step. The order service's listener extends it:
@Component
class PaymentReceivedListener extends InboxMessageHandler<PaymentReceivedV1> {
/** The inbox key prefix. Never change it, or every old event looks new. */
static final String CONSUMER = "order-service.payment-received";
private final OrderService orders;
PaymentReceivedListener(Inbox inbox, OrderService orders) {
super(CONSUMER, inbox);
this.orders = orders;
}The constructor passes the consumer name to super, the base class's
constructor. The name is a constant, written once.
A new consumer name makes every old event look new. The inbox key starts with the name. After a rename, no old key matches. If SQS still holds a message that was handled before the deploy, it runs again. Treat the name like a database column name, and never change it. A refactoring that renames the class must keep the constant.
The security part has its home in another chapter. For why a message runs as the system actor, and what the audit log records, see the section Messages and jobs: the system actor.
An older design wrote each listener in full. The listener opened a TransactionTemplate
itself, asked the inbox alreadyProcessed(...), called the use case, and then called
record(...). That design is explicit, and a reader sees every step in one method. Its
weakness is repetition. Each listener must repeat the steps in the right order, and a listener that
records the key outside the transaction loses events. The base class keeps the order in one place,
and final keeps it there.
The strict rule. Add machinery only where a case needs it. A handler that is idempotent by nature, such as "set the status to PAID", needs no inbox.
What we do. Every listener extends InboxMessageHandler, so every
message costs a check and a write in processed_messages. The payment handler is
idempotent in its domain as well.
Why. Few handlers stay idempotent by nature. A later change, such as a notification email, makes a repeat visible. With one base class for every listener, nobody has to judge each handler again after each change.
When to stop. Stop for a queue with very high volume, where the extra write per message matters. Its handler must be idempotent by design, and tested as such. Then call the use case without the inbox, and write down why.
A thin listenertwist#
@Override
protected void handle(PaymentReceivedV1 event) {
Payment payment = new Payment(new PaymentReference(event.paymentReference()),
new Money(event.amount(), event.currency()), event.paidAt());
orders.recordPayment(OrderId.of(event.orderId()), payment);
}The method handle translates the event into value objects, and calls one use case.
It holds no rule. A listener is an adapter, like a controller. Its job is to turn a message into a
call of the application layer. OrderId.of checks the ID's format, so a bad ID fails
here, and the message goes the way of every failure.
The use case joins the inbox's transaction:
@Transactional
public void recordPayment(OrderId orderId, Payment payment) {
Order order = orders.get(orderId);
if (order.recordPayment(payment)) {
saveAndPublish(order, AuditActions.PAYMENT_RECORDED);
}
}@Transactional has the propagation REQUIRED by default. So
recordPayment runs in the transaction that the inbox opened. The order, its events in
the outbox, the audit record and the inbox key commit together. For propagation, see the chapter
Transactions in depth.
The test PaymentReceivedListenerTest.translatesTheEventAndCallsTheUseCase mocks the
inbox to run the handler, and checks the Payment that reaches the use case. The amount
12.5 arrives as Money.of("12.50", "GBP").
The domain is idempotent tootwist#
/**
* Records a payment, unless it is not later than the last one.
*
* <p>SQS can deliver a message twice, and messages can arrive out of order. Both cases leave
* the order unchanged. A payment for a shipped or cancelled order is still recorded, on
* purpose: the money was received, and the payment service handles any refund.
*
* @return whether the order changed, so the caller knows whether to save it
*/
public boolean recordPayment(Payment payment) {
if (lastPayment != null && !payment.paidAt().isAfter(lastPayment.paidAt())) {
return false;
}
lastPayment = payment;
if (status == OrderStatus.NEW) {
status = OrderStatus.PAID;
}
raise(new PaymentRecorded(id(), payment.reference(), payment.amount(), payment.paidAt()));
return true;
}The aggregate's method recordPayment ignores a payment that is not later than the
last one. It returns false, and the use case saves nothing. So a repeat of the same
payment changes nothing, and a late, older payment changes nothing either.
The inbox and the domain guard against different cases:
| Case | The inbox | The domain |
|---|---|---|
| the same message twice, within 14 days | skips it by its key | ignores it, because paidAt is not later |
| the same payment with a new eventId, from a bug in the payment service | does not recognise it | ignores it |
| an older payment that arrives after a newer one | does not recognise it, because the ID is new | ignores it |
| the same message after the inbox key expired | does not recognise it | ignores it |
So the second defence covers what the inbox cannot. The inbox stays the general defence,
because most handlers have no field like paidAt to compare. For rules in the
aggregate, see the chapter The domain model in plain Java.
Log lines that name the messagetwist#
@Override
protected Map<String, String> logContext(PaymentReceivedV1 event) {
return Map.of("orderId", event.orderId());
}A message arrives on a thread of the listener container, with no HTTP request around it. So
nothing puts a trace ID or an order ID into the
mapped diagnostic context (MDC). The base class adds the
eventId itself, and the listener's logContext adds the
orderId. Every log line of the handler then carries both.
The platform's LogContext sets the entries for a block, and puts the earlier values
back when the block ends. So nested blocks with the same key work, and the container's thread is
clean for the next message. Put only IDs into the MDC, never personal data, because every log line
repeats them. For the MDC, structured logs and tracing, see the chapter Logging,
tracing and health.
ILogger.BeginScope in ASP.NET Core has the same purpose. A support engineer then
searches the logs for an order ID, and finds the agent's request, the payment service's log lines,
and this message.
Testing the consumertwist#
@Test
void aMessageThatIsNotTheEventEndsInTheDeadLetterQueue() {
String envelope = """
{ "Type": "Notification", "MessageId": "%s", "Message": "{}" }""".formatted(UUID.randomUUID());
aws.sqs.sendMessage(message -> message.queueUrl(aws.paymentQueueUrl).messageBody(envelope));
await().atMost(Duration.ofSeconds(60)).untilAsserted(() ->
assertThat(aws.sqs.receiveMessage(request -> request.queueUrl(aws.paymentDeadLetterQueueUrl)).messages())
.extracting(Message::body).anySatisfy(body -> assertThat(body).contains("Notification")));
}The test aMessageThatIsNotTheEventEndsInTheDeadLetterQueue sends an envelope to the payment queue, and waits until it appears in the dead-letter queue. The journey's queue uses a visibility timeout of 2 seconds and a receive count of 2, so the
test needs seconds, not minutes. JourneyAwsResources creates the queues in Floci, as the
local script does. The consumer has tests at every level:
| Test | Level | What it proves |
|---|---|---|
| PaymentReceivedV1Test | unit, no Spring | unknown fields are ignored, and an envelope fails with eventId |
| PaymentReceivedListenerTest | unit, with Mockito | the event becomes value objects and one use case call |
| InboxTest | unit, with Mockito | a new event runs and records its key, and a known one is skipped |
| InboxIT | MongoDB in Docker | a repeat runs once, a failure leaves nothing, and a concurrent copy is a write conflict |
| OrderJourneyIT | the whole service, MongoDB and Floci | a payment sent twice records once, and a bad message reaches the dead-letter queue |
Most tests need no queue. The wiring test starts the whole service with the SQS auto-configuration turned off:
cloud:
aws:
sqs:
enabled: falseThe line enabled: false sets spring.cloud.aws.sqs.enabled, which turns off the SQS client, the template and the
listener container factory. So no container tries to reach a queue. What an @SqsListener bean does with no factory was not run, but the wiring test starts.
On a developer's machine, a payment can be sent as the payment service would send it. The local environment's README shows the command:
docker run --rm --network local-dev -e AWS_ACCESS_KEY_ID=local -e AWS_SECRET_ACCESS_KEY=local \
-e AWS_DEFAULT_REGION=eu-west-2 -e AWS_ENDPOINT_URL=http://floci:4566 amazon/aws-cli:2.27.0 \
sns publish --topic-arn arn:aws:sns:eu-west-2:000000000000:payment-events --message \
'{"eventId":"e-1","occurredAt":"2026-10-25T09:00:00Z","orderId":"<order id>","paymentReference":"PAY-1","amount":12.50,"currency":"GBP","paidAt":"2026-10-25T09:00:00Z"}'docker run --rm --network local-devThe AWS command-line tool runs in a container on the local network, so it reaches Floci by its container name.
sns publish --topic-arn arn:aws:sns:eu-west-2:000000000000:payment-events --message \The command publishes to the topic, not to the queue. So the test also passes through the subscription with raw delivery.
Replace <order id> with the ID of an order, then read the order. Its status
is PAID. Run the command twice, and the audit log still shows one payment. For the
local environment, see the chapter Running locally and shipping. For the test
kinds, see the chapter Testing in depth.
FIFO queues, the other choicenew#
SQS has a second kind of queue. A FIFO queue keeps the order of messages within a message
group, and the AWS documentation promises exactly-once processing. Its name must end in
.fifo. So why does the order service not use one, and drop the inbox?
The careful version of that choice is strong. A FIFO topic feeds a FIFO queue, and the message
group is the order ID. Payments of one order then arrive in order, and SQS removes duplicates. The
container acknowledges at once, with no batch. On the sending side, Spring Modulith's routing key
gives the group ID, as in "order-events.fifo::#{orderId()}".
The book still recommends a standard queue with an inbox for the order service, for these reasons:
| Point | FIFO queue | Standard queue and inbox |
|---|---|---|
| duplicates | removed by SQS, within a window of 5 minutes | skipped by the inbox, within 14 days |
| throughput | 300 transactions per second for each API action, or up to 3,000 messages with batches of 10 | not limited by the FIFO quota |
| order | kept within a message group | best effort, so the domain handles a late message |
| setup | a FIFO dead-letter queue, and a message group ID on every message of a FIFO topic | standard queues and topics, with no group ID |
| the sender's outbox | a resend after 5 minutes is not removed as a duplicate | the inbox skips it by eventId |
The last row decides for the order service. The outbox resends an event after five minutes. A duplicate after that window passes the FIFO deduplication. So the consumer would need its inbox anyway. High-throughput mode raises the FIFO quota in some regions, to up to 70,000 transactions per second in us-east-1, us-west-2 and eu-west-1.
Choose FIFO when the order of messages within a group is a business rule that the domain cannot restore, and the volume fits the quota. For the routing key, see the section @Externalized: from the outbox to SNS. The order service's own topic is a standard one:
public static final String ORDER_EVENTS = "order-events";The constant ORDER_EVENTS names a standard topic. A FIFO topic would need the
suffix .fifo and a routing key on each event.
Compared with .NET consumerstwist#
An ASP.NET Core developer consumes SQS in one of two ways. The plain way calls
ReceiveMessageAsync and DeleteMessageAsync of AWSSDK.SQS in a
loop. AWS's own example is a console loop. Running that loop in a hosted service of the application is a pattern of this book, not of an AWS page. The other way is the AWS Message Processing Framework for
.NET, AWS.Messaging:
builder.Services.AddAWSMessageBus(bus =>
{
bus.AddSQSPoller("https://sqs.eu-west-2.amazonaws.com/000000000000/order-service-payment-received");
bus.AddMessageHandler<PaymentReceivedHandler, PaymentReceivedV1>();
});bus.AddSQSPoller("https://sqs.eu-west-2.amazonaws.com/000000000000/order-service-payment-received");The poller is a long-running background service of the application, which receives from the queue.
bus.AddMessageHandler<PaymentReceivedHandler, PaymentReceivedV1>();This registers the handler of the section The listener: @SqsListener for the message type.
AWS.Messaging reached version 1.0.0, its first stable release, on 5 May 2025. The latest is 1.6.0, from 10
August 2026. The two frameworks compare like this:
| Point | AWS.Messaging 1.6.0 | Spring Cloud AWS 4.1.0 |
|---|---|---|
| the handler | IMessageHandler<T>, which returns MessageProcessStatus | a method with @SqsListener, which returns or throws |
| success | MessageProcessStatus.Success() | the method returns, and the message is deleted in a batch |
| failure | MessageProcessStatus.Failed() leaves the message in the queue | an exception leaves the message in the queue |
| visibility timeout | extended while a handler runs | not extended, so size the queue's timeout |
| duplicates | your own inbox, or MassTransit's | the platform's inbox |
The row about the visibility timeout is the one to remember. A .NET habit of long handlers on a
short timeout turns into duplicates in Spring. For a whole inbox and outbox in .NET, MassTransit's
consumer outbox is the closest match. Its inbox state tracks received messages by
MessageId for each endpoint. For its licence as of 2026, see the section
Compared with MassTransit and NServiceBus.
1An expert asksThe order service has an inbox. Why does Order.recordPayment still
ignore a payment that is not later than the last one?
Order.recordPayment still
ignore a payment that is not later than the last one?eventId, and only for 14 days. A repeated
payment with a new event ID, an older payment that arrives late, or a message after its key expired
all pass the inbox. The domain compares paidAt, so it ignores all of them. The two
defences cover different cases.2An expert asksWhy can a queue with a visibility timeout of one second deliver every successful
message twice in a Spring Cloud AWS service?
3A developer renames the listener class, and its consumer name changes with it. What happens to
messages that are still in the queue?
4TranslateWrite this C# handler as a Java listener of the order service, using the
platform's base class:
public class PaymentReceivedHandler(IOrderService orders) : IMessageHandler<PaymentReceivedV1>
{
public async Task<MessageProcessStatus> HandleAsync(MessageEnvelope<PaymentReceivedV1> envelope,
CancellationToken token = default)
{
PaymentReceivedV1 e = envelope.Message;
await orders.RecordPaymentAsync(e.OrderId, e.PaymentReference, e.Amount, e.Currency, e.PaidAt);
return MessageProcessStatus.Success();
}
}
public class PaymentReceivedHandler(IOrderService orders) : IMessageHandler<PaymentReceivedV1>
{
public async Task<MessageProcessStatus> HandleAsync(MessageEnvelope<PaymentReceivedV1> envelope,
CancellationToken token = default)
{
PaymentReceivedV1 e = envelope.Message;
await orders.RecordPaymentAsync(e.OrderId, e.PaymentReference, e.Amount, e.Currency, e.PaidAt);
return MessageProcessStatus.Success();
}
}@SqsListener("${messaging.queues.payment-received}")
void on(PaymentReceivedV1 event) {
process(event);
}
@Override
protected void handle(PaymentReceivedV1 event) {
Payment payment = new Payment(new PaymentReference(event.paymentReference()),
new Money(event.amount(), event.currency()), event.paidAt());
orders.recordPayment(OrderId.of(event.orderId()), payment);
}The class extends InboxMessageHandler<PaymentReceivedV1>. The method with
@SqsListener passes the event to process, which runs the inbox. Success is a
normal return, and failure is an exception.
Quick reference#
The settings and values that the consumer depends on:
| Setting or value | Where | Value | What it does |
|---|---|---|---|
| messaging.queues.payment-received | application.yml | order-service-payment-received | the queue that @SqsListener reads |
| spring.cloud.aws.sqs.queue-not-found-strategy | the platform's messaging.properties | fail | stops the service when the queue is missing |
| spring.cloud.aws.sqs.enabled | the wiring test profile | false | turns off the whole SQS auto-configuration |
| platform.messaging.inbox.retention | MessagingProperties | 14 days | how long the inbox keeps each key |
| VisibilityTimeout | the queue, in init.sh | 30 seconds | how long a received message stays hidden |
| maxReceiveCount | the redrive policy, in init.sh | 5 | the receives before SQS moves a message to the dead-letter queue |
| RawMessageDelivery | the subscription, in init.sh | true | the message body is the event, with no SNS envelope |
The SQS limits that the chapter uses, from the AWS documentation:
| Limit | Value |
|---|---|
| visibility timeout | 30 seconds by default, 12 hours at most |
| message retention | 4 days by default, 14 days at most |
| FIFO deduplication window | 5 minutes |
| FIFO throughput | 300 transactions per second for each API action, or 3,000 messages with batches of 10 |
| message attributes with raw delivery | at most 10 |
Logging, tracing and health#
When an order fails in production, support staff need to find out what happened. The order service writes JSON log lines, and every line carries a trace ID and the order's ID. Traces follow a request into other services. Metrics count orders, and health probes tell the orchestrator when to send traffic. A separate audit log stores who changed what.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | no equivalent | the five tools, and who reads each | logs, traces, metrics and probes come from the platform, and the audit log from each use case Why |
| same | ILogger<T> and {Named} placeholders | an SLF4J Logger and {} placeholders | the logging basics are taught in the chapter Actuator and observability |
| twist | catch (Exception e) { log.LogWarning(e, "...") } | log.warn("Dependency {} is unavailable", name, ex) | SLF4J takes the exception as the last argument, with no placeholder, and prints its stack trace Why |
| twist | "Acme.Billing": "Debug" in appsettings.json | logging.level.com.acme: DEBUG | a level for one package and every package below it, set in the local profile only Why |
| twist | AddJsonConsole() in Program.cs | JSON (ECS) logs switched on by the platform | every service writes JSON with no setting, and the local profile turns it off for readable logs Why |
| twist | the members of the JSON console formatter | logging.structured.json.exclude and .rename | Boot changes the members of its built-in JSON formats with settings, not with code Why |
| twist | logger.BeginScope(new Dictionary<string, object> { ... }) | MDC.put(key, value) | the mapped diagnostic context holds key and value pairs for one thread, and JSON lines show each pair Why |
| new | a middleware that calls BeginScope | PathVariableMdcInterceptor | the interceptor copies orderId from the URL into the MDC, so no controller writes logging code Why |
| trap | a value added by code that runs before the controller | an MDC value set in a HandlerInterceptor | no interceptor runs for a 405, so its log lines lack the value, and a servlet filter sees every request Why |
| twist | using (logger.BeginScope(...)) { } | try (LogContext ignored = LogContext.with(...)) { } | the platform's own AutoCloseable, so a message handler gets the event's IDs in every line Why |
| trap | nested scopes with the same key stack | MDC.putCloseable inside another value for the key | closing the inner value removes the key, so the outer value is lost, and LogContext puts it back Why |
| trap | a scope flows into Task.Run | the MDC stays on its thread | a new thread or a pool thread sees no MDC values unless code copies them Why |
| twist | a record's generated ToString() | a record's toString(), overridden to print nothing personal | both languages print every member by default, so a value object with personal data overrides it Why |
| new | no equivalent | PersonalDataMaskingCustomizer | the platform masks members such as email, and e-mail addresses in messages, in every JSON line Why |
| twist | AddOpenTelemetry().WithTracing(...) | spring-boot-starter-opentelemetry | the starter adds tracing with no code, and traceId and spanId appear in every log line Why |
| twist | a sampler set in code | management.tracing.sampling.probability | Boot samples 10% by default, and the platform samples every request Why |
| trap | no trace without sampling | a traceId in the logs of an unsampled request | the trace ID is in the MDC and the logs even when the trace is not exported Why |
| trap | AddOtlpExporter() only when you call it | the starter pushes metrics to localhost:4318 | with no collector, the log shows a warning, so the platform switches the metrics export off Why |
| trap | new HttpClient() sends traceparent | only the injected RestClient.Builder sends traceparent | a client built with the static RestClient.builder() starts a new trace in the next service Why |
| twist | meter.CreateCounter<long>("shop.orders") | registry.counter("shop.orders", "event", "placed") | the counters are increased by a listener that runs after the commit, so a rolled-back order is never counted Why |
| twist | the CloudWatch exporter of OpenTelemetry | micrometer-registry-cloudwatch2 from Spring Cloud AWS | Boot has no CloudWatch registry of its own, so the service exports nothing until a deployment adds one Why |
| twist | MapHealthChecks with a tag predicate | the readiness group with readinessState,mongo | readiness includes the database and liveness does not, so a database outage stops traffic but restarts nothing Why |
| twist | every endpoint you map is reachable | exposure.include=health,info and show-details=never | the platform adds info to the endpoints of Boot 4.1 and restates its other defaults in one file Why |
| twist | HostOptions.ShutdownTimeout of 30 seconds | spring.lifecycle.timeout-per-shutdown-phase=20s | graceful shutdown is already the default in Boot 4.1, and the platform shortens the wait Why |
| new | an audit row added before SaveChangesAsync() | AuditLog.record(action, type, id) in the use case | a business record in the database, written in the same transaction as the change, and never a log line Why |
| twist | no equivalent | WireMock started with --verbose | the local stub server logs every request and the stub that answered it Why |
What the service keeps, and who reads itnew#
A customer calls the shop. Their order says "placed", but the warehouse never saw it. A support engineer opens the logs. They need every line about that order, from every service the request reached, in the order they were written. Later the business asks who cancelled the order. That answer must be exact, and it must still exist in a year.
These are different questions, and the service answers each one with a different tool:
| Tool | What it answers | Who reads it | Kept for |
|---|---|---|---|
| log lines, in JSON | what happened in this request, step by step | developers and support | days or weeks |
| a trace | where the time went, across services | developers | days |
| metrics | how many orders, how many errors, how fast | operations and the business | months |
| health probes | can this instance take traffic now | the orchestrator | not kept |
| the audit log | who changed which order, and when | support, auditors, the business | as long as the data |
Most of this comes from the platform library, shop-platform. Two
starters in its build file bring the tools:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- ... -->
<!-- Tracing: trace and span IDs in every log line, passed on with every HTTP call -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-opentelemetry</artifactId>
</dependency><artifactId>spring-boot-starter-actuator</artifactId>The Actuator starter adds the health endpoints and Micrometer, the metrics library. For the Actuator endpoints in general, see the chapter Actuator and observability.
<artifactId>spring-boot-starter-opentelemetry</artifactId>This starter adds tracing with OpenTelemetry. It is new in Boot 4. See the section Tracing with the OpenTelemetry starter.
The service itself adds very little. It names the path variables to log, it counts its orders, and each use case writes an audit entry. Everything else is a platform default that every shop service gets.
SLF4J and Logback in the servicetwist#
The platform's exception handler logs a failure of the stock service. Each class keeps one
logger in a static final field:
public class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> log)
{
public void Downstream(DownstreamServiceException ex) =>
log.LogWarning(ex, "Dependency {Dependency} is unavailable", ex.Dependency);
}private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
// ...
@ExceptionHandler(DownstreamServiceException.class)
ResponseEntity<ProblemDetail> downstream(DownstreamServiceException ex) {
log.warn("Dependency {} is unavailable", ex.dependency(), ex);LoggerFactory.getLogger(GlobalExceptionHandler.class);The code calls the Simple Logging Facade for Java (SLF4J). Logback does the writing, because Boot uses it by default. The logger's name is the class's full name, so the level setting of its package applies.
log.warn("Dependency {} is unavailable", ex.dependency(), ex);The
{}is filled with the first argument. The last argument is an exception, and no placeholder is left for it. So SLF4J prints the exception's stack trace under the message.
A run for this book logged the same call. It printed the message, then the exception and its stack trace:
WARN cf.Slf4jRunTest -- Dependency stock is unavailable
java.lang.IllegalStateException: connection refused
at cf.Slf4jRunTest.run(Slf4jRunTest.java:9)C# passes the exception first, as its own parameter. SLF4J takes it last. A Java message
template has no names, so the JSON line has no Dependency member. The value is only in the message text.
A .NET run of the C# pane kept it as a member of "State".
The same class has a handler for every other exception. It calls
log.error("Unexpected error", ex), so the stack trace is in the log. The 500 response
holds a traceId, but never the exception's message. The test
OrderControllerTest.anUnexpectedErrorIsA500WithoutItsMessage checks that the body
does not contain the message. For the rules, see the section What a problem
never contains.
The local profile raises the level for the shop's own code:
logging:
level:
com.acme: DEBUGThe setting logging.level.com.acme applies to the package com.acme and
every package below it. So the platform and the service both log at DEBUG on a
developer's machine. Other environments do not set it.
JSON logs, switched on by the platformtwist#
In production, a log collector reads each service's console output. It can search a field
such as orderId only if each line is JSON. Boot writes JSON itself with one
setting, logging.structured.format.console. The platform sets it for every service,
unless the service switches it off:
if (environment.getProperty("platform.logging.structured", Boolean.class, true)) {
environment.getPropertySources().addLast(new MapPropertySource("platform-defaults:structured-logging",
Map.of("logging.structured.format.console", "ecs")));
}environment.getProperty("platform.logging.structured", Boolean.class, true)This line reads the platform's own setting. Its default is
true, so a service that says nothing gets JSON logs.addLast(new MapPropertySource("platform-defaults:structured-logging",The setting is added last, with the lowest precedence. So a service can still set
logging.structured.format.consoleto another format in itsapplication.yml.Map.of("logging.structured.format.console", "ecs")));ecsstands for the Elastic Common Schema (ECS). It is one of the three JSON formats that Boot writes.
A class that Spring Boot calls before the application starts, and before logging starts, is called an environment post-processor. This code runs in one. A properties file cannot say "only if another setting is true", so this one default is code. For how the platform adds its other defaults, see the chapter Configuration in depth.
The platform's setting is bound to a small class, so it appears in the IDE's completion:
@ConfigurationProperties("platform.logging")
public class PlatformLoggingProperties {
/** Write JSON (ECS) logs. Set it to false locally for readable console logs. */
private boolean structured = true;
/** Path variables to copy into the MDC for each request, such as {@code orderId}. */
private List<String> mdcPathVariables = new ArrayList<>();The field structured starts as true, so JSON is the default. The list
mdcPathVariables names the path variables that go into the
mapped diagnostic context (MDC). An
interceptor copies them there, as the section
Path variables in the MDC, with an interceptor shows.
The local profile and the tests switch JSON off. A developer then reads plain lines on the console:
platform:
logging:
structured: false # readable console logsWith structured: false, the post-processor adds nothing, and Boot writes its
plain-text format.
The test PlatformDefaultsEnvironmentPostProcessorTest checks both cases. With no
setting, logging.structured.format.console is ecs. With
platform.logging.structured=false, it is not set at all.
A run for this book logged one line in each of Boot's three formats. Here is the ECS line of a
request that put orderId into the logging context:
{"@timestamp":"2026-10-25T19:46:49.460581Z","log":{"level":"INFO","logger":"rw.logging.LogWebMain"},
"process":{"pid":77217,"thread":{"name":"http-nio-auto-1-exec-1"}},
"service":{"name":"order-service","node":{}},
"message":"Handling order 42 (MDC traceId=4acdfe93505af32476c1dc16458821e3)",
"traceId":"4acdfe93505af32476c1dc16458821e3","spanId":"898fa9e5bedf816f","orderId":"42",
"ecs":{"version":"8.11"}}"service":{"name":"order-service","node":{}},ECS takes the service name from
spring.application.name. So the collector can tell the services apart."traceId":"4acdfe93505af32476c1dc16458821e3","spanId":"898fa9e5bedf816f","orderId":"42",Every value in the logging context becomes a member at the top level. The trace ID is written as
traceId, its name in the context, not as ECS's own nametrace.id.
The three formats differ in their member names:
| Format | A context value orderId=42 appears as | Service name |
|---|---|---|
| ecs | "orderId":"42" at the top level | "service":{"name":"order-service"} |
| logstash | "orderId":"42" at the top level | not written |
| gelf | "_orderId":"42", with an underscore before each extra member | "host":"order-service" |
Boot can change the members with settings. In the run, each of these worked:
| Setting | Effect in the run |
|---|---|
| logging.structured.json.exclude=log.logger,ecs.version | removed the members, but left empty objects such as "ecs":{} |
| logging.structured.json.rename.orderId=order.id | wrote "order.id":"42" |
| logging.structured.json.customizer=a class name | ran code that adds or changes members, as the platform's masking does |
In ASP.NET Core, a service calls AddJsonConsole() in Program.cs. With
IncludeScopes = true, the scope values appear in a "Scopes" array. In
Spring Boot, the service writes no code for it. The context values are members at the top
level of the line.
The MDC: values for every line of one threadtwist#
Every log line about an order should carry its ID. Passing the ID into every log call would be easy to forget. So Java logging keeps a map of values for the current thread. This map is called the mapped diagnostic context (MDC). Each log line includes what the map holds when the line is written.
The MDC has three methods that matter. The audit log uses one of them to read the trace ID:
String traceId = MDC.get(ProblemDetails.TRACE_ID);
if (traceId != null) {
entry.append("traceId", traceId);
}MDC.get returns null when the thread has no trace ID, so the audit entry then has no
traceId field.
| Call | What it does |
|---|---|
| MDC.put("orderId", id) | adds a value for the current thread, until code removes it |
| MDC.get("orderId") | reads the value, or returns null |
| MDC.remove("orderId") | removes the value |
MDC.put is like logger.BeginScope in C#, but it returns nothing to
dispose.
MDC is in the package org.slf4j. Logback stores the map in a
ThreadLocal, so each thread has its own map. A run
showed that the class behind it is ch.qos.logback.classic.util.LogbackMDCAdapter.
A value stays until code removes it. Spring reuses each request thread for many requests. So a value that nobody removes appears in the lines of the next request on that thread, which may be about another order. Every piece of code that puts a value must remove it. The next three sections show how the platform does that in each case.
Path variables in the MDC, with an interceptornew#
Support staff search the logs for one order. Most requests name the order in the URL, as in
POST /orders/{orderId}/ship. The platform copies such values into the MDC once,
for every controller. A Spring model-view-controller (MVC) interceptor does this. An
object whose methods Spring MVC calls before and after a controller method is called an
interceptor.
The platform's interceptor is PathVariableMdcInterceptor. Its method
preHandle runs before the controller method. It reads the path variables of the
request, and puts each configured name into the MDC with MDC.put. Its method
afterCompletion runs at the end of the request, also after an exception. It removes
the same names, so the thread is clean for the next request.
For the code of the interceptor, its callbacks, and how the platform registers it with Spring MVC, see the section The MDC interceptor. The registration needs no code in the service.
The service names its variables in one setting:
platform:
logging:
# Added to every log line of a request, next to traceId
mdc-path-variables: orderId, lineId, noteIdThe value of mdc-path-variables is a list separated by commas. It binds to a
List<String> in PlatformLoggingProperties, and the spaces are
trimmed. The names must match the names in the controllers' paths, such as
{orderId}.
The interceptor reads the path variables from a request attribute that Spring MVC fills. The attribute's value depends on the request. A run for this book recorded three cases:
| The request | The attribute in preHandle | What reaches the MDC |
|---|---|---|
| a path with variables, such as GET /orders/42 | a map, {orderId=42} | orderId=42 |
| a path with no variables, such as POST /orders | an empty map | nothing |
| a path with no controller, a 404 | null, because the handler for static files took the request | nothing |
The null attribute is harmless. The interceptor tests the attribute with
instanceof, and that test is false for null. So the log lines of a 404 simply have
no orderId.
Other requests skip some of the callbacks. For every case that the runs recorded, see the section What an interceptor sees when a request fails. One more case matters for the MDC. It is a 405, and it is the case to understand.
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE) instanceof Map<?, ?> variables) {
for (String name : names) {
if (variables.get(name) instanceof String value) {
MDC.put(name, value);
}
}
}
return true;
}Show the answer
Spring MVC rejects a wrong HTTP method while it looks for the controller method.
An interceptor belongs to a matched controller method. A request with the wrong method matches
none. So Spring MVC throws HttpRequestMethodNotSupportedException before any
interceptor runs. A run recorded only the filter and the exception handler for the 405.
A value that every request needs belongs in a servlet filter. A filter runs for every request, before Spring MVC.
The platform accepts this gap. A path variable exists only after a match, so the 405 cannot
know it anyway. The trace ID is different, because every request needs it. The tracing setup
puts it into the MDC without the interceptor. A run for this book sent the same 405 to an
application with the OpenTelemetry starter. The exception handler for the 405 found a
traceId in the MDC.
A servlet filter sees every request, also a 405. It runs before Spring MVC and again after it, so it also sees the final status after the advice wrote the problem. The order service needs no other value for every request, so it has no filter of its own. If a service needs one, such as a tenant ID from a header, write a servlet filter. For filters and the request path, see the section One request through Spring MVC.
The platform's interceptor ignores the parameter ex of
afterCompletion, and removes its values in every case. That is on purpose. An
exception that an advice handled reaches afterCompletion as null. For the
prediction about it, see the section What an interceptor sees when a
request fails.
The test PathVariableMdcInterceptorTest needs no Spring. It sets the attribute on a
MockHttpServletRequest itself, and checks three things.
Only the configured names are copied. The values are removed when the request completes, even
with an exception. And a request without path variables adds nothing.
In ASP.NET Core, a middleware can read context.Request.RouteValues["orderId"]
and open a logging scope with ILogger.BeginScope. A scope ends when it is disposed.
The MDC has no scope object. The interceptor must remove its values itself. In Spring, the
interceptor is the place where the matched path variables are known. Scopes and the MDC also
differ for nested values and for other threads. See the sections
The log context for message handlers and
The MDC does not follow work to other threads.
The log context for message handlersnew#
A payment arrives from a queue. No HTTP request and no interceptor take part. The handler's
lines still need the order ID and the event ID. The platform has a small class for this,
LogContext. It puts values into the MDC for one block of code, and puts the MDC back
as it was:
public final class LogContext implements AutoCloseable {
private final Map<String, String> previous = new HashMap<>();
private LogContext(Map<String, String> entries) {
entries.forEach((key, value) -> {
if (value != null) {
previous.put(key, MDC.get(key));
MDC.put(key, value);
}
});
}
/** Adds the entries to the MDC until {@link #close()}. */
public static LogContext with(Map<String, String> entries) {
return new LogContext(Objects.requireNonNull(entries, "entries"));
}
@Override
public void close() {
previous.forEach((key, earlierValue) -> {
if (earlierValue == null) {
MDC.remove(key);
} else {
MDC.put(key, earlierValue);
}
});
}
}public final class LogContext implements AutoCloseable {AutoCloseabledoes the same asIDisposablein C#. Its one method isclose().if (value != null) {A null value is left out. So a missing ID never makes the caller fail.
previous.put(key, MDC.get(key));Before it puts a value, the class remembers the value that the key had. That is null when the key had none.
MDC.put(key, earlierValue);On close, a key that had a value gets that value back. A key that had none is removed.
The class is used with a try-with-resources
block. That block is the Java form of a using block. It calls
close() at the end, also after an exception. The platform's base class for
message listeners opens one around every event:
protected final void process(T event) {
Map<String, String> context = new HashMap<>(logContext(event));
context.put("eventId", event.eventId());
SecurityContext callerContext = SecurityContextHolder.getContext();
SecurityContextHolder.clearContext();
try (LogContext ignored = LogContext.with(context)) {
inbox.process(consumer, event, this::handle);
} finally {
SecurityContextHolder.setContext(callerContext);
}
}Map<String, String> context = new HashMap<>(logContext(event));logContextis a method that a listener may override, to add its business IDs.try (LogContext ignored = LogContext.with(context)) {The variable is called
ignored, because the block only needs itsclose(). Every line that the inbox and the handler write carries the IDs.SecurityContextHolder.clearContext();The same method also runs the handler as the system actor. See the section Messages and jobs: the system actor.
The payment listener adds the order ID:
protected Map<String, String> logContext(PaymentReceivedV1 event) {
return Map.of("orderId", event.orderId());
}So a payment's log lines carry eventId and orderId, as a request's
lines carry orderId. Support staff search one field, whatever started the work.
Nested blocks with the same key work as nested scopes do in C#:
using (log.BeginScope(new Dictionary<string, object> { ["eventId"] = "outer" }))
{
using (log.BeginScope(new Dictionary<string, object> { ["eventId"] = "inner" }))
{
log.LogInformation("Inner scope");
}
log.LogInformation("After inner scope"); // the scope holds only "outer"
}try (LogContext outer = LogContext.with(Map.of("eventId", "outer"))) {
try (LogContext inner = LogContext.with(Map.of("eventId", "inner"))) {
assertThat(MDC.get("eventId")).isEqualTo("inner");
}
assertThat(MDC.get("eventId")).isEqualTo("outer");
}
assertThat(MDC.get("eventId")).isNull();The Java pane is the test LogContextTest.restoresTheOuterValueOfANestedBlock. After
the inner block, MDC.get("eventId") is outer again, as the scope in C#
holds only outer again. The two languages behave differently inside the inner
block. A .NET run with
AddJsonConsole(IncludeScopes = true) wrote both values in the
"Scopes" array of the inner line, because scopes stack. The MDC holds one value for
each key, so the inner block replaces the outer value until it closes.
SLF4J's own MDC.putCloseable loses the outer value. SLF4J has a
method that returns a closeable value. A run nested it with the same key, as
orderId=outer and then inner. After the inner one closed,
orderId was null, not outer. Its close() only removes the
key. The outer code then logs without the order ID, and nothing warns you. Use a helper that
remembers the earlier value, as LogContext does.
Put only IDs into the MDC. Every line repeats each value, and logs are copied to many places. A name or an e-mail address there would be in every line of the request. The section Personal data in logs shows the other defences.
The MDC does not follow work to other threadstrap#
In ASP.NET Core, a log scope flows with the ExecutionContext. A .NET run for this
book logged inside Task.Run and on a new Thread. Both lines still had
the scope's orderId:
using (log.BeginScope(new Dictionary<string, object> { ["orderId"] = "42" }))
{
await Task.Run(() => log.LogInformation("On a thread-pool thread")); // has orderId
var t = new Thread(() => log.LogInformation("On a new thread")); t.Start(); t.Join(); // has orderId
}The comments after Task.Run and the new thread say what the run printed. The MDC
belongs to one thread, so it does the opposite. A Java run put
orderId=42 on the calling thread, and then read it elsewhere:
| Where the code ran | What MDC.get("orderId") returned |
|---|---|
| the calling thread | 42 |
| a new virtual thread | null |
| a task on a thread pool | null |
| a virtual thread that first called MDC.setContextMap(copy) | 42 |
So a line written on another thread has no order ID, and no other value of the MDC. The fix is a copy. The
caller takes MDC.getCopyOfContextMap(), and the task calls
MDC.setContextMap(copy) before it logs. The task must also clear the map at its end,
or the pool thread keeps the values for its next task.
This is the same rule as for the caller's identity. For the measured cases with a wrapper, see the section The caller does not follow work to other threads. The order service starts no threads in its use cases, so it needs no copy. Spring Modulith runs the metrics listener on another thread. Its lines carry no order ID, and it writes none.
Personal data in logsnew#
A developer writes log.info("Placing an order for {}", contact) while tracking a
problem. The log now holds a customer's name and e-mail address. Logs are kept for weeks, copied
to a log service, and read by many people. So personal data must not reach them.
Java and C# records behave alike here. Each one prints every component by default. A run printed the same record in both languages:
| Language | What printing the record wrote |
|---|---|
| C# | CustomerContact { Name = Jo Bloggs, Email = jo@example.com } |
| Java | CustomerContact[name=Jo Bloggs, email=jo@example.com] |
The first defence is in the value object itself. CustomerContact overrides
toString():
public record CustomerContact(String name, String email) implements ValueObject {
// ...
@Override
public String toString() {
return "CustomerContact[masked]";
}
}So logging a contact by mistake prints CustomerContact[masked]. The Note entity does the same in another way. Its toString() prints the
note's ID, the order's ID and the length of the text, but not the text. Views and integration
events leave the customer out completely.
The second defence is a safety net in the JSON writer. Boot lets a class change the members of each structured log line. The platform's class masks personal data:
public class PersonalDataMaskingCustomizer implements StructuredLoggingJsonMembersCustomizer<Object> {
/** Member names whose values are always personal data. */
static final Set<String> PERSONAL_FIELDS = Set.of("email", "phone", "address", "customerName");
private static final Pattern EMAIL = Pattern.compile("[\\w.+-]+@[\\w-]+(\\.[\\w-]+)+");
private static final Pattern LONG_NUMBER = Pattern.compile("\\d[\\d -]{7,}\\d");
@Override
public void customize(JsonWriter.Members<Object> members) {
members.applyingValueProcessor(JsonWriter.ValueProcessor.of(String.class, value -> "[masked]")
.whenHasPath(path -> PERSONAL_FIELDS.contains(path.name())));
members.applyingValueProcessor(JsonWriter.ValueProcessor.of(String.class, PersonalDataMaskingCustomizer::mask)
.whenHasPath("message"));
}implements StructuredLoggingJsonMembersCustomizer<Object> {Boot calls a class of this interface when it builds the JSON writer for the log lines. The class can add, remove or change members.
members.applyingValueProcessor(JsonWriter.ValueProcessor.of(String.class, value -> "[masked]")A value processor changes a value just before it is written. This one replaces any string with
[masked]..whenHasPath(path -> PERSONAL_FIELDS.contains(path.name())));The first processor applies only to members with a personal name, such as
email. Such members appear when code puts them into the MDC..whenHasPath("message"));The second processor applies to the message text. Its
maskmethod replaces e-mail addresses with[email]. It replaces long numbers, such as a phone number, with[number]. A long number is a run of at least nine digits, spaces and dashes that starts and ends with a digit.
One line of the platform's defaults switches it on for every service:
# Mask personal data in JSON logs (a safety net; see the class).
logging.structured.json.customizer=com.acme.platform.core.logging.PersonalDataMaskingCustomizerThe setting logging.structured.json.customizer names the class, and Boot creates it
when logging starts.
The test PersonalDataMaskingCustomizerTest builds a JSON writer shaped like a log
line. The message "Contact jo.bloggs@example.com on 07700 900123 about order 42" becomes
"Contact [email] on [number] about order 42". A member email becomes
"[masked]", and other members stay as they are.
A value processor without a path condition masks every string. A research
run added a processor with no whenHasPath. It masked the service name too, so the
line said "service":{"name":"***"}. Always limit a processor to the members it is
meant for.
The net has holes, and the platform's class says so. It cannot recognise a name or a street
address inside a message. It works only for the JSON formats, so the readable local console is
not masked. So the main defence stays in the code. Value objects with personal data override
toString(), and code never logs a whole request body.
Tracing with the OpenTelemetry starternew#
An order is placed. The order service calls the stock service, which calls the warehouse. One of the three is slow. Each service has its own logs, so the engineer needs one ID that all three share. That ID is called the trace ID. Each service's part of the work is called a span, and has a span ID.
The starter spring-boot-starter-opentelemetry does this with no code. It brings
Micrometer Tracing and the OpenTelemetry libraries. Each incoming request gets a trace ID, or
keeps the one that the caller sent. The IDs go into the MDC, so every log line carries them.
Outgoing calls through Boot's HTTP clients pass them on in the W3C traceparent
header.
In ASP.NET Core, the service writes the setup itself:
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddOtlpExporter());The calls AddAspNetCoreInstrumentation() and AddOtlpExporter() choose
what is traced and where it goes. In the order service, no line of code mentions tracing. The platform's defaults set one
value:
# Tracing. Every request gets a traceId in the MDC, even when it is not sampled.
# Lower the sampling rate in production if volumes are high.
management.tracing.sampling.probability=1.0The setting management.tracing.sampling.probability is a share from 0 to 1.
Sampling decides which traces are recorded and sent to a tracing system. Boot's default is
0.1, so it samples 10% of requests. The platform samples every request. The default sampler is
named parent-based-trace-id-ratio. Its name says that it follows the decision of the
caller, when the caller sent one.
A trace ID exists even when the trace is not sampled. A run with the default 0.1 read
MDC.get("traceId") in a controller, and got the trace ID. The plain-text log line
carried it too, in brackets with the span ID. So every log line and every problem response has a
traceId, whatever the sampling rate:
INFO 77203 --- [o-auto-1-exec-1] [60a2b527bd6d398c29ef021abb69176e-351506590893af3a] rw.logging.LogWebMain : Handling order 42The first part, 60a2b527bd6d398c29ef021abb69176e, is the trace ID. The part after
the dash is the span ID. Only the outgoing traceparent header shows whether the trace is sampled.
It ended in -02 with the default sampling, and in -03 with sampling 1.0.
The last bit of that flag says "sampled".
The platform's problem responses read the same MDC value:
/** The MDC key under which Micrometer Tracing stores the current trace ID. */
public static final String TRACE_ID = "traceId";ProblemDetails reads the MDC value under TRACE_ID, and adds the value to every
problem response. A caller who gets an error quotes this ID to support. Support finds every line of the request,
in every service it reached. For the problem format, see the chapter The web
layer in depth.
The strict rule. Sample a fraction of the requests, as Boot does by default, so the tracing system stores and costs less.
What we do. The platform sets management.tracing.sampling.probability
to 1.0, so every request is traced once an export is configured.
Why. A shop service handles a modest number of requests. A support case
starts from one traceId, and an engineer wants the whole trace for that request, not
a 10% chance of it.
When to stop. Stop when the tracing system's storage or bill grows with the
traffic. Then lower the rate per service in its application.yml. The log lines keep
their trace IDs at any rate.
Where traces and metrics go: off until an endpoint is settrap#
Traces and metrics leave the service through the OpenTelemetry Protocol (OTLP). A collector
receives them and forwards them to a tracing system. The trace export needs the setting
management.opentelemetry.tracing.export.otlp.endpoint. It has no default, so no
trace leaves the service until a deployment sets it. A run without it logged no errors.
The metrics export behaves differently:
<!-- Tracing: trace and span IDs in every log line, passed on with every HTTP call -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-opentelemetry</artifactId>
</dependency>Show the answer
The starter also brings the OTLP metrics registry, and switches it on. The
setting management.otlp.metrics.export.enabled is true by default. With no URL set,
the registry pushes to a collector on localhost:4318. In a container, nothing
listens there, so every push fails with a warning.
Switch the export off until a real collector exists, as the platform does.
The platform switches the metrics export off, and says how to switch it on:
# Traces are exported only when management.opentelemetry.tracing.export.otlp.endpoint is set.
# Metrics would be exported to localhost:4318 by default, so they are off until a service
# sets management.otlp.metrics.export.enabled=true and management.otlp.metrics.export.url.
management.otlp.metrics.export.enabled=falseWith management.otlp.metrics.export.enabled=false, nothing is pushed. So locally
and in tests, no warning appears. A deployment that runs a collector sets
the two endpoints as environment variables. The metrics still exist inside the service, so a test
can read them. See the section Business metrics.
The Boot 3 key for the trace endpoint was management.otlp.tracing.endpoint. Boot
4.0 deprecated it at level error, so the old name no longer works. The new key is
management.opentelemetry.tracing.export.otlp.endpoint. Logs have an exporter of their
own, management.opentelemetry.logging.export.otlp.endpoint, which the service does
not use. Its logs go to the console, where a log collector reads them.
The trace follows only the injected client buildertrap#
The trace ID must travel with every call to another service. Boot adds the
traceparent header through the RestClient.Builder bean that it creates.
A client built in any other way sends no header. A run for this book compared three ways to build a client:
| How the client was built | traceparent sent | Trace in the called service |
|---|---|---|
| from Boot's injected RestClient.Builder | 00-7d38...1265-5a72...dd38-02 | the same trace ID as the caller |
| RestClient.create(base) | none | a new trace ID |
| the static RestClient.builder() | none | a new trace ID |
In the end-to-end run, a request with trace 94d3...b992 called itself twice. The
call through the static builder started the new trace 5daa...705b. The call through
Boot's builder kept 94d3...b992. Nothing failed, and nothing warned. The link
between the two services was simply gone.
So the platform builds every client from the injected builder. Its client factory asks the container for a new builder each time:
@Bean
@ConditionalOnMissingBean
HttpClientFactory httpClientFactory(ObjectProvider<RestClient.Builder> restClientBuilders,
ObjectProvider<ClientRegistrationRepository> registrations,
ObjectProvider<OAuth2AuthorizedClientService> authorizedClients) {The parameter ObjectProvider<RestClient.Builder> lets the factory ask for a
builder whenever it builds a client. The builder bean has the scope prototype, so each request for it returns a new
builder. For the client factory and the stock client, see the chapter
Calling other services.
A C# developer does not expect this trap. A .NET 10 run with only the ASP.NET Core tracing
instrumentation called a second endpoint with new HttpClient(). The request carried
a traceparent header, and the called endpoint saw the same trace ID. In Spring, the
way a client is built decides whether the trace continues.
Business metricstwist#
Boot already records HTTP, client and MongoDB metrics. The business also wants to know how many orders are placed, shipped and cancelled. The service counts them with a listener for its domain events:
@Component
class OrderMetrics {
private final Counter placed;
private final Counter shipped;
private final Counter cancelled;
OrderMetrics(MeterRegistry registry) {
this.placed = registry.counter("shop.orders", "event", "placed");
this.shipped = registry.counter("shop.orders", "event", "shipped");
this.cancelled = registry.counter("shop.orders", "event", "cancelled");
}
@ApplicationModuleListener
void on(OrderPlaced event) {
placed.increment();
}registry.counter("shop.orders", "event", "placed");One metric named
shop.orders, with a tagevent. Each tag value is its own series. The tag has three values, so the number of series stays small.@ApplicationModuleListenerThis Spring Modulith annotation makes the method a listener that runs after the transaction commits, on another thread. So an order that was rolled back is never counted.
For counters and tags in general, see the section Metrics. Spring Modulith stores each delivery to the listener in the outbox, and retries it. For more, see the chapter Publishing events: the outbox. The journey test reads the counter at the end of an order's life:
assertThat(meters.counter("shop.orders", "event", "shipped").count()).isGreaterThanOrEqualTo(1);The test checks isGreaterThanOrEqualTo(1), because other tests of the same run also ship orders.
The counters exist inside the service. To reach a monitoring system, a registry must export them. Boot 4.1.2 has export registries for Prometheus, OTLP, Datadog and many others, but none for Amazon CloudWatch. CloudWatch export comes from Spring Cloud AWS, as of October 2026:
| Need | Artifact | Note |
|---|---|---|
| the auto-configuration | io.awspring.cloud:spring-cloud-aws-starter-metrics | latest 4.2.0, and the service uses Spring Cloud AWS 4.1.0 |
| the registry itself | io.micrometer:micrometer-registry-cloudwatch2 | the Micrometer registry that writes to CloudWatch |
The service adds no registry. Its README says that export stays off until a deployment sets an OTLP endpoint, or adds the CloudWatch registry. So nothing is exported locally or in tests, and the counters are still there for the test.
The health probes: liveness without the databasetwist#
The orchestrator asks each instance two questions. Is the process alive, or should it be
restarted? Is it ready for traffic, or should requests go elsewhere? Actuator answers at two
paths, /actuator/health/liveness and /actuator/health/readiness.
The database is down for five minutes. If liveness included the database, every instance would report "not alive". The orchestrator would restart all of them, again and again. The restarts fix nothing, because the database is still down. So liveness must not depend on any other system. Readiness does include the database. While it is down, no traffic is sent, and the instances keep running until it returns.
The platform's mongo defaults add the database to readiness:
builder.Services.AddHealthChecks()
.AddCheck("mongo", () => HealthCheckResult.Unhealthy("database down"), tags: ["ready"]);
app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = check => check.Tags.Contains("ready") });# The service is ready for traffic only when the database is reachable.
# Liveness deliberately leaves the database out: a database outage must not restart every instance.
management.endpoint.health.group.readiness.include=readinessState,mongomanagement.endpoint.health.group.readiness.include=readinessState,mongoA health group is a named set of health checks. This line puts two checks into the readiness group.
readinessStateis the readiness state that Boot keeps for the application.mongois the health check that Boot provides for MongoDB.Predicate = _ => falseIn ASP.NET Core, liveness runs no check at all. A .NET run added an unhealthy
mongocheck. The path/health/liveanswered 200Healthy. The path/health/readyanswered 503Unhealthy.
Boot also keeps other checks out of its two probes. By default, it puts no other
health indicator into the two groups. A run with a
failing check named stock measured two cases:
| Setting | /actuator/health | liveness | readiness |
|---|---|---|---|
| none | 503 DOWN | 200 UP | 200 UP |
| readiness.include=readinessState,stock | 503 DOWN | 200 UP | 503 DOWN |
So without the platform's line, readiness would stay UP while the database is down. The orchestrator would keep sending requests that all fail. The service's README states the result for each dependency. When DocumentDB is down, readiness is DOWN, and liveness stays UP.
The core defaults choose what is reachable:
# Health probes for the orchestrator (a Boot default, restated):
# /actuator/health/liveness - is the process alive? Never include other services or the database.
# /actuator/health/readiness - can it take traffic? The mongo defaults add the database.
management.endpoint.health.probes.enabled=true
management.endpoints.web.exposure.include=health,info
management.endpoint.health.show-details=nevermanagement.endpoint.health.probes.enabled=trueThis adds the two probe paths. It is already the default in Boot 4.1.
management.endpoints.web.exposure.include=health,infoOnly these two endpoints are reachable over HTTP. Boot's default is
healthalone, so the platform addsinfo.management.endpoint.health.show-details=neverThe answer says only UP or DOWN, with no details of the checks. This is also the default of Boot.
Two of the three lines restate a default of Boot 4.1. The file's own comment gives the reason. The behaviour of every service is visible in one file. And a Boot upgrade that changes a default cannot change the platform's behaviour.
The probes need no token. The platform's security chain lets
/actuator/health/** and /actuator/info through, as the section
The platform's filter chain shows. The journey test checks this. A
request to /orders without a token gets 401, and both probes answer 200, with
readiness UP.
The service's own docker-compose.yml uses readiness as the container's health
check:
healthcheck:
# The image has no curl, so bash opens the TCP connection itself. "Ready" means the database is reachable.
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/localhost/8080 && printf 'GET /actuator/health/readiness HTTP/1.0\\r\\n\\r\\n' >&3 && grep -q UP <&3"]
interval: 10s
retries: 12exec 3<>/dev/tcp/localhost/8080Bash treats the path
/dev/tcp/host/portas a network connection. This line opens one as file descriptor 3, for reading and writing.printf 'GET /actuator/health/readiness HTTP/1.0\\r\\n\\r\\n' >&3The command writes a minimal HTTP request to the connection.
grep -q UP <&3grepreads the answer and succeeds only if it containsUP.
The image has no curl, and a smaller image has fewer tools to patch. For the
startup probe that allows for the migration lock, see the chapter Migrations,
indexes and seed data.
Graceful shutdowntwist#
A deployment stops an old instance while it still handles requests. Graceful shutdown lets those requests finish first:
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(20));# Finish the requests in flight when the service is stopped (graceful is a Boot default, restated).
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=20sGraceful shutdown is the default in Boot 4.1, with all three embedded web servers. The value
server.shutdown=immediate turns it off. So the line server.shutdown=graceful
restates the default. The line spring.lifecycle.timeout-per-shutdown-phase=20s is a
real change. Boot waits at most 30 seconds by default, and the platform waits 20.
A .NET run printed the default of HostOptions.ShutdownTimeout, which is also 30
seconds.
Every run for this book logged two lines when it stopped:
INFO o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
INFO o.s.boot.tomcat.GracefulShutdown : Graceful shutdown completeGracefulShutdown is the class in Boot that waits for Tomcat's requests. Keep the
wait shorter than the time the orchestrator allows before it kills the process.
Otherwise the process is killed in the middle of the wait.
The audit log: a business record, not a log linenew#
An order was cancelled, and the customer says they did not do it. The business needs to know who cancelled it, and when. A log line is the wrong place for that answer. Logs are kept for weeks, can be sampled or lost, and may say that something happened when the transaction then rolled back. So the service keeps an audit log. It is a collection in the database, written in the same transaction as the change.
The platform defines the audit log as one small interface:
/**
* Records who did what to which resource. This is a business record for support and audit,
* not a log line, so it is stored in the database in the same transaction as the change.
*
* <p>Never put personal data in the action or the resource ID.
*/
public interface AuditLog {
// ...
void record(String action, String resourceType, String resourceId);
}The method record takes three strings. The caller names only what happened, and to
which resource. The implementation adds who acted,
when, and the trace ID. Each record in the collection audit_log has six fields:
| Field | Where it comes from | Example |
|---|---|---|
| action | the use case | ORDER_SHIPPED |
| resourceType | the use case | order |
| resourceId | the use case | the order's ID |
| actor | CurrentActor, the token's subject or system | agent-1 |
| at | the Clock bean | the time of the change |
| traceId | the MDC, when tracing has one | the request's trace ID |
For the code that writes the record, and for where the actor comes from, see the section
The current actor, and the audit record. The record is written with
MongoTemplate, which joins the transaction that runs on the thread. So the record is
stored only if the change is stored. For transactions, see the chapter Transactions
in depth.
The platform provides the storage as an auto-configuration that a service can replace:
@AutoConfiguration(after = PlatformMongoAutoConfiguration.class)
public class PlatformMongoAuditAutoConfiguration {
@Bean
@ConditionalOnMissingBean(AuditLog.class)
MongoAuditLog auditLog(MongoTemplate mongo, CurrentActor currentActor, Clock clock) {
return new MongoAuditLog(mongo, currentActor, clock);
}
@Bean
AuditLogIndexes auditLogIndexes() {
return new AuditLogIndexes();
}
/** A use case writes its audit record inside its transaction. See {@link CreateCollections}. */
@Bean
CreateCollections auditLogCollection() {
return new CreateCollections("P000-audit-log-collection", List.of("audit_log"));
}
}@AutoConfiguration(after = PlatformMongoAutoConfiguration.class)This class is processed after the platform's MongoDB setup, which creates the transaction manager and the template that it needs.
@ConditionalOnMissingBean(AuditLog.class)A service that defines its own
AuditLogbean replaces this one. For example, a service could send its records to a central audit service.AuditLogIndexes auditLogIndexes() {A migration creates an index on the type, the ID and the time, to find the history of one resource quickly. For migrations, see the chapter Migrations, indexes and seed data.
CreateCollections auditLogCollection() {A second migration creates the collection
audit_logitself, before any use case writes a record inside its transaction. Amazon DocumentDB cannot create a collection inside a transaction. For that limit, see the section The limits of a transaction on Amazon DocumentDB.
The action names are constants in one class, so they stay the same everywhere and a search finds them:
public final class AuditActions {
public static final String ORDER = "order";
public static final String NOTE = "note";
public static final String ORDER_PLACED = "ORDER_PLACED";
public static final String LINE_PICKED = "LINE_PICKED";
public static final String ORDER_SHIPPED = "ORDER_SHIPPED";
public static final String ORDER_CANCELLED = "ORDER_CANCELLED";
public static final String PAYMENT_RECORDED = "PAYMENT_RECORDED";
public static final String NOTE_ADDED = "NOTE_ADDED";
public static final String NOTE_ACTION_RESOLVED = "NOTE_ACTION_RESOLVED";A use case passes AuditActions.ORDER and a constant such as
ORDER_PLACED, never a string typed in place.
The audit is the last step of every change. Each use case loads the aggregate, lets the domain apply its rules, saves it, publishes its events, and records the audit. The order feature does the last three steps in one private method:
private Order saveAndPublish(Order order, String auditAction) {
List<DomainEvent> raised = order.pullEvents();
Order saved = orders.save(order);
events.publish(raised);
audit.record(auditAction, AuditActions.ORDER, saved.id().value());
return saved;
}Every caller of saveAndPublish runs inside a transaction. So the order, its events
in the outbox and its audit record are stored together, or not at all. NoteService
has the same method for notes. For the steps of a use case, see the chapter Use
cases: the application service.
The journey test checks the notes' records too:
assertThat(mongo.find(query(where("resourceId").is(noteId)), Document.class, "audit_log"))
.extracting(entry -> entry.getString("action")).containsExactly("NOTE_ADDED", "NOTE_ACTION_RESOLVED");A note gets NOTE_ADDED when it is added, and NOTE_ACTION_RESOLVED when
its action is resolved. The same test checks the order's records and their actors. The agent appears for each change,
and system for the payment, which came from a queue.
Keep personal data out of the action and the resource ID. The audit log keeps its records as long as the data, and longer than any log. The actor is the token's subject, which is an ID at the issuer, not a name.
ASP.NET Core with Entity Framework Core uses the same design. The code adds an audit row to the
same DbContext before it calls SaveChangesAsync(). The EF Core
documentation says that all changes in one call of SaveChanges are applied in one
transaction, when the database supports transactions. The idea is the same in both. The audit
record is data, and it commits with the change it describes.
Seeing what the stubs did, locallytwist#
Locally, WireMock replaces the stock service. When a call fails, a developer needs to see
which stub answered. The local environment starts WireMock with --verbose:
wiremock:
image: wiremock/wiremock:3.13.0
command: ["--global-response-templating", "--verbose"]A run of WireMock 3.13.2 for this book compared the two modes. The local environment pins
3.13.0. Without
--verbose, WireMock logged only a request that no stub matched, with a table of the
closest stub. With --verbose, it also logged every request, the stub that matched it,
and the response, with the header Matched-Stub-Id. Use it locally only. For the local
environment, see the chapter Running locally and shipping.
1The platform samples every request, but a team lowers the sampling rate to 0.1. Do the log
lines of an unsampled request still carry a traceId?
traceId?traceparent header shows it in its last flag, -02 for not sampled and
-03 for sampled.2An expert asksWhy does the order service's liveness probe leave out the database, while its
readiness probe includes it?
3An expert asksWhy is the audit record written to the database in the use case's transaction,
and not as a log line?
4TranslateWrite this C# in the order service's platform, for a message handler that
runs the inbox with the event's ID in every log line:
var scope = new Dictionary<string, object> { ["eventId"] = message.EventId };
using (logger.BeginScope(scope))
{
inbox.Process(consumer, message, Handle);
}
var scope = new Dictionary<string, object> { ["eventId"] = message.EventId };
using (logger.BeginScope(scope))
{
inbox.Process(consumer, message, Handle);
}Map<String, String> context = new HashMap<>(logContext(event));
context.put("eventId", event.eventId());
// ...
try (LogContext ignored = LogContext.with(context)) {
inbox.process(consumer, event, this::handle);
} finally {
SecurityContextHolder.setContext(callerContext);
}The finally block restores the security context, which the C# code does not
touch. LogContext is the platform's AutoCloseable. It puts the values into
the MDC and restores the earlier values on close. The try-with-resources block calls
close(), like the end of the using block. this::handle is a method reference to the handler.
Quick reference#
The observability settings of the order service, with Boot 4.1.2's defaults:
| Setting | Boot default | The platform or the service | What it does |
|---|---|---|---|
| logging.structured.format.console | none, so plain text | ecs, unless platform.logging.structured=false | writes JSON log lines in ECS |
| platform.logging.structured | true | false in the local profile and in tests | the platform's switch for JSON logs |
| platform.logging.mdc-path-variables | none | orderId, lineId, noteId | path variables copied into the MDC |
| logging.structured.json.customizer | none | PersonalDataMaskingCustomizer | masks personal data in JSON lines |
| logging.level.com.acme | not set | DEBUG in the local profile | the level of the shop's own code |
| management.tracing.sampling.probability | 0.1 | 1.0 | the share of requests whose traces are exported |
| management.opentelemetry.tracing.sampler | parent-based-trace-id-ratio | not set | follows the caller's sampling decision |
| management.opentelemetry.tracing.export.otlp.endpoint | none | set by a deployment | where traces are sent |
| management.otlp.metrics.export.enabled | true | false | pushes metrics over OTLP |
| management.otlp.metrics.export.url | none, so localhost:4318 | set by a deployment | where metrics are sent |
| management.endpoint.health.probes.enabled | true | true, restated | adds the liveness and readiness paths |
| management.endpoint.health.group.readiness.include | readinessState | readinessState,mongo | puts the database into readiness |
| management.endpoints.web.exposure.include | health | health,info | the endpoints reachable over HTTP |
| management.endpoint.health.show-details | never | never, restated | shows only UP or DOWN |
| server.shutdown | graceful | graceful, restated | lets requests in flight finish |
| spring.lifecycle.timeout-per-shutdown-phase | 30s | 20s | the longest wait during shutdown |
Caching and scheduling#
The order service reads the same lookup lists on almost every request, so it keeps them in memory. One annotation on the repository does that. The service also runs a job every minute, which sends again the events that were not sent. This chapter shows where the cache belongs, why it never expires, and how one lock keeps several instances from running the same job.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | no equivalent | lookup lists read on every order | the delivery method of each new order is checked against a list that changes only with a deployment Why |
| twist | builder.Services.AddMemoryCache() | @EnableCaching and spring-boot-starter-cache | the annotation switches the caching proxy on, and the starter brings the cache manager Why |
| twist | an IMemoryCache with no size limit | ConcurrentMapCacheManager | with no cache library present, Boot keeps each cache in a ConcurrentHashMap that it makes on first use Why |
| new | cache.GetOrCreateAsync(type, ...) in a repository class | @Cacheable("reference-data") on the Spring Data repository method | the cache is a storage concern, and its callers are other beans, so every call passes the proxy Why |
| trap | a field of a cached object | a public field read through the proxy | the proxy's own fields are never set, so the read returns null, and code must call methods Why |
| twist | the key passed to GetOrCreate | the method's one argument | one argument is the key itself, and two arguments make a SimpleKey of both Why |
| new | AbsoluteExpirationRelativeToNow on each entry | no expiry and no eviction | the lists change only with a deployment, and a deployment restarts every instance Why |
| twist | cache.Remove(key) | @CacheEvict, or spring.cache.type=caffeine with a spec | an eviction reaches one instance only, so data that changes at run time needs an expiry Why |
| twist | HybridCache, two levels in one API | Caffeine or Redis behind @Cacheable | Boot has no two-level cache provider, so a service chooses one store Why |
| trap | a BackgroundService that the service registers | @EnableScheduling inside the platform's auto-configuration | scheduling is on in every service that has the resender, so its own @Scheduled methods run too Why |
| twist | new PeriodicTimer(TimeSpan.FromMinutes(1)) | @Scheduled(fixedDelayString = "${...:PT1M}") | the interval is an ISO-8601 duration from a setting, with a default after the colon Why |
| trap | lock (gate) { }, which waits for its turn | the resender's ShedLock lock | an instance that finds the lock taken skips its run, and does not wait or run later Why |
| twist | Hangfire or Quartz.NET | Quartz, or JobRunr | libraries that keep jobs in a database, for jobs whose runs must not be lost Why |
| new | no equivalent | a job calls the use cases as the system actor | the application services check no permission, so a job calls them with no identity Why |
Why the service caches its lookup listsnew#
An agent places an order with the delivery method EXPRESS. Before anything else,
the service checks that EXPRESS is an active delivery method. The list of delivery
methods is in the database, in the collection reference_data. Every order placement
reads it, and so does every call of GET /reference-data/{type}.
The reference feature reads and checks these lists. It uses the simple level, one of
the three levels of Domain-Driven Design (DDD) in this part, with no aggregate and no events. For the levels, see the chapter
How much DDD: three levels in one service. Its service has two
methods:
public List<ReferenceItemView> list(String type) {
if (!ReferenceTypes.ALL.contains(type)) {
throw new UnknownReferenceTypeException(type);
}
return items.findByTypeOrderByCodeAsc(type).stream().map(ReferenceItemView::from).toList();
}
/**
* Throws a 400 for the field unless the code is an active value of the list.
*
* @param field the request field the client must correct, such as {@code deliveryMethod}
*/
public void requireActive(String type, String code, String field) {
boolean known = items.findByTypeOrderByCodeAsc(type).stream()
.anyMatch(item -> item.code().equals(code) && item.active());
if (!known) {
throw new InvalidValueException(field, "is not a known value");
}
}items.findByTypeOrderByCodeAsc(type)itemsis the feature's Spring Data repository. Both methods call the same repository method with the type, such asDELIVERY_METHOD..anyMatch(item -> item.code().equals(code) && item.active());A code passes only if it is in the list and active. An old code stays in the list, inactive, because stored orders still use it.
throw new InvalidValueException(field, "is not a known value");The platform turns this exception into a 400 that names the field.
The lists are small, and they change only when a deployment runs a seed migration. Reading them from the database on every request costs a round trip each time, for the same answer. So the service keeps them in memory. For the seed migration, see the chapter Migrations, indexes and seed data.
Switching caching on, and the default cache managertwist#
Caching in Spring has two parts. An annotation switches the caching proxy on, and a cache manager stores the entries. The reference feature switches the proxy on in its own configuration class:
builder.Services.AddMemoryCache();@Configuration(proxyBeanMethods = false)
@EnableCaching
class ReferenceConfig {
}@EnableCachingThis annotation makes Spring wrap every bean that has a cache annotation in a proxy. Without it, every cache annotation does nothing. For that trap, see the section Caching.
@Configuration(proxyBeanMethods = false)The class declares no
@Beanmethods, so it needs no proxy of its own.falsetells Spring not to create one.
The class is in the reference package, because that feature is the only user of
the cache. It is still a setting for the whole application. Any bean of the service with a cache annotation now gets a proxy.
The cache manager comes from a starter. The service's build file adds one dependency on top of the platform:
<!-- Only what this service needs on top. Test libraries come from the parent. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>With spring-boot-starter-cache and no cache library such as Caffeine, Boot
creates a ConcurrentMapCacheManager. It keeps each cache in a
ConcurrentHashMap, inside the process. A run for this book listed the caches of such
a manager. It made each cache on first use, by its name, with no setting. The map has no size
limit and no expiry. For the reference lists, that is exactly right. The section
Why the cache never expires explains why.
Where the cache goes: on the repositorynew#
The cache could go on the service or on the repository. The order service puts it on the repository method:
/**
* Reads lookup values, and caches each list by its type.
*
* <p>The cache is on the repository, not on the service, for a reason. {@code @Cacheable} works
* through a proxy around the bean, so only calls from another bean pass through it. The service
* calls this method from two of its own methods; if the cache were on the service, the call from
* {@code requireActive} to {@code list} would skip the proxy and the cache.
*
* <p>The cached list is shared by every caller, so callers must not change it.
*/
public interface ReferenceItemRepository extends MongoRepository<ReferenceItemDocument, String> {
@Cacheable("reference-data")
List<ReferenceItemDocument> findByTypeOrderByCodeAsc(String type);
}public interface ReferenceItemRepository extends MongoRepository<ReferenceItemDocument, String> {A Spring Data repository is an interface. Spring Data writes its implementation at startup, from the method's name.
@Cacheable("reference-data")@Cacheable names the cache. Before the method runs, the proxy looks for the argument in the cache
reference-data. On a hit, the database is not read. On a miss, the method runs, and the proxy stores its result.List<ReferenceItemDocument> findByTypeOrderByCodeAsc(String type);The name means "find by type, ordered by code, ascending". Spring Data builds the query from it.
There are two reasons for this place. Both are worth knowing, because the same choice comes back with every cache.
The cache is a storage concern. Its job is to save database reads. The service decides what the lists mean, and it should not care where they come from. With the cache on the repository, the service reads the same way with or without it. In a feature of the full style, the place would be the persistence adapter, for the same reason. The domain and the use cases never see it.
The proxy rule. @Cacheable works through a proxy around the
bean. Only a call from another bean passes through the proxy. A call from a method to another
method of the same object skips it. This is called
self-invocation. A run for this book called a
cached method twice from inside its own bean. The method loaded twice, so the cache did nothing.
For the same trap with @Transactional, see the prediction in the chapter
How Spring actually works.
Here, the service is the only caller of the repository, and it is another bean. So every call
passes the proxy. Picture the cache on the service's list method instead. If
requireActive called list, that call would skip the cache, and every
order placement would read the database again.
The careful version of "cache on the service" has a real point. A cache on the service can store the views, which are already mapped. Then a hit does less work. It is safe if every caller of the cached method is another bean, and a test proves the hits. The book still recommends the repository, because the next developer who adds a call inside the service breaks the cache, and nothing fails. Put the cache on the service only when the mapping is the expensive part.
A field of a proxied bean reads as null. A run gave a cached bean a public
field and read it through the injected bean. The injected bean was the class
CacheApp$ReferenceData$$SpringCGLIB$$0, a subclass made for the proxy. The field
read returned null, because nothing ever sets the proxy object's own fields. Call methods on a
proxied bean, never read its fields.
The cached list is one object, shared by every caller. A caller that changed it would change
it for everyone. The service never changes it. It maps each item to a new
ReferenceItemView with stream().map(...).toList(), and
toList() returns a list that cannot be changed.
The journey test checks the cache with the real application:
@Test
void lookupValuesAreSeededAndCached() throws Exception {
mvc.perform(get("/reference-data/DELIVERY_METHOD").with(agent))
.andExpect(status().isOk())
.andExpect(jsonPath("$[?(@.code == 'SECOND_CLASS_POST')].active").value(false));
assertThat(caches.getCache("reference-data").get("DELIVERY_METHOD")).isNotNull();
}mvc.perform(get("/reference-data/DELIVERY_METHOD").with(agent))The request reads the delivery methods through the controller, the service and the repository.
.andExpect(jsonPath("$[?(@.code == 'SECOND_CLASS_POST')].active").value(false));The seed data has an inactive method, and the list still contains it.
assertThat(caches.getCache("reference-data").get("DELIVERY_METHOD")).isNotNull();cachesis theCacheManagerbean. The entry under the keyDELIVERY_METHODexists, so the proxy stored the list.
Cache keystwist#
The test above finds the entry under the plain string DELIVERY_METHOD. That is the
default key for a method with one argument:
public Task<List<ReferenceItem>?> FindByType(string type) =>
cache.GetOrCreateAsync(type, _ => store.FindByTypeAsync(type));@Cacheable("reference-data")
List<ReferenceItemDocument> findByTypeOrderByCodeAsc(String type);In C#, GetOrCreateAsync takes the key as its first argument. In Spring, the proxy
builds the key from the arguments. A run for
this book printed the default keys:
| Arguments | The key |
|---|---|
| one, such as "delivery" | the argument itself, a java.lang.String |
| two, such as "delivery" and "en" | SimpleKey [delivery, en] |
| none | SimpleKey [] |
A key needs a correct equals and hashCode. A string, a
record and a typed ID such as OrderId all have
them. The cache name separates the lists of different methods. So two methods may cache by the
same type string, if their caches have different names. For keys written as expressions, see the
section Cache keys, conditions and eviction.
Why the cache never expiresnew#
A cache that never expires sounds like a bug. Here it is the design. The lookup lists change in one way only. A developer adds a seed migration, and a deployment runs it. A deployment starts new instances and stops the old ones. Each new instance starts with an empty cache, and reads the new lists on first use. The class that switches caching on says so:
/**
* Turns on Spring's caching, which the reference repository uses.
*
* <p>Lookup values change only when a deployment runs a seed migration, and a deployment
* restarts every instance, which empties the cache. So the cache needs no expiry and no
* eviction. With no cache library on the classpath, Spring Boot uses a simple in-memory map.
*/The comment names the condition that makes this safe, in the words restarts every
instance, which empties the cache. The data changes only together with a restart of every
instance. There is no admin endpoint that changes a list while the service runs.
The default map has no size limit, but the seed data holds nine values in two lists, so
their size is fixed.
The strict rule. Every cache has an expiry and a size limit, so stale data disappears and memory stays bounded. The section Choosing the cache store calls the unbounded default map a memory leak.
What we do. The reference lists stay in Boot's default map until the instance stops. No entry expires, and no code evicts one.
Why. The lists change only with a deployment, which restarts every instance. Their number and size are fixed by the seed data. An expiry would only add database reads and a library.
When to stop. Stop when anything changes the lists while the service runs, such as an admin endpoint or another service. Stop also when a cache holds data with no fixed size, such as one entry for each order. Then add an expiry and a size limit, as the next section shows.
When the data changes while the service runstwist#
Suppose the business wants to switch a delivery method off at once, without a deployment. An admin endpoint changes the list in the database. The cached copy is now stale. There are three ways to deal with it, and they differ in what they guarantee:
| Choice | How | What it guarantees |
|---|---|---|
| evict on change | @CacheEvict on the method that changes the data | this instance is fresh at once, and the other instances are not |
| expire after a time | spring.cache.type=caffeine with an expireAfterWrite in its spec | every instance is fresh within that time |
| one shared cache | Redis behind @Cacheable | every instance reads the same entries |
@CacheEvict(value = "reference-data", key = "#type") removes one entry when its
method runs. A run for this book on Spring 7.0.9 confirmed that it removes the entry, and that the
next call loads again. For its attributes, see the section Cache keys,
conditions and eviction.
The instances are the problem. Each instance of the service has its own map in its own memory. The
admin request reaches one instance, so only that instance evicts. The other instances keep the old
list until something else clears it. A C# developer with IMemoryCache has the same limit, because that cache is also kept in one process:
public class ExpiringReferenceItems(IMemoryCache cache, IReferenceStore store)
{
public Task<List<ReferenceItem>?> FindByType(string type) =>
cache.GetOrCreateAsync(type, entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return store.FindByTypeAsync(type);
});
public void Changed(string type) => cache.Remove(type);
}In C#, AbsoluteExpirationRelativeToNow sets the expiry for each entry, in code. In
Spring, the expiry belongs to the cache store, and is a setting. A run for this book on Boot 4.1.2
used Caffeine 3.2.4, the version that Boot manages. It set
spring.cache.type=caffeine and
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=1s. Boot then created a
CaffeineCacheManager. Two calls loaded once. A third call after 1.5 seconds loaded
again. The dependency is com.github.ben-manes.caffeine:caffeine, with no version,
because Boot manages it. A service that needs an expiry adds that dependency and the two
settings, and its @Cacheable methods stay as they are. The order service has no
Caffeine, for the reason in the first item below, so the companion has no code for it.
The recommendation follows from the guarantee that the data needs:
- Data that changes only with a deployment needs nothing. This is the order service.
- Data where a few minutes of staleness are harmless needs an expiry. Caffeine with
expireAfterWriteis enough, and an eviction on change makes the instance that took the change fresh at once. - Data that must be fresh everywhere at once does not belong in an in-process cache. Use Redis, or read the database every time.
The .NET caches, comparedtwist#
.NET has several cache APIs, and Spring has one annotation over several stores. Two of the .NET APIs matter here:
| .NET | Spring | Note |
|---|---|---|
| IMemoryCache | @Cacheable with the default map, or with Caffeine | one process, and Caffeine adds size limits and expiry |
| HybridCache | no single provider | HybridCache has two levels, over IMemoryCache and IDistributedCache |
HybridCache has a call much like the code of the order service:
public class HybridReferenceItems(HybridCache cache, IReferenceStore store)
{
public ValueTask<List<ReferenceItem>> FindByType(string type) =>
cache.GetOrCreateAsync(type, async _ => await store.FindByTypeAsync(type));
}Its GetOrCreateAsync returns a ValueTask. HybridCache keeps its entries at two levels, in memory and in a distributed
cache. Boot lists Caffeine and Redis among its cache
providers, but no provider with two levels. A Spring service that needs two levels must build
them itself. The order service needs only the level in memory.
The bigger difference is where the code is. In C#, the cache call is inside the method. In Spring, the annotation leaves the method's body as a plain database read, and the proxy adds the cache around it. That is why the proxy rule of the section Where the cache goes: on the repository matters in Spring and never comes up in C#.
Scheduling, switched on by the platformtrap#
Events that the outbox could not send must be sent again. The platform's
OutboxResender does this every minute. For what the resender sends, and why, see the
chapter Publishing events: the outbox. This chapter covers how the job is
scheduled.
A scheduled method runs only when something switches scheduling on, with
@EnableScheduling. The order service never writes that annotation. The platform
does, inside its messaging auto-configuration:
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(name = "platform.messaging.outbox.resend-enabled", havingValue = "true", matchIfMissing = true)
@EnableScheduling
static class OutboxResendConfiguration {
/** ShedLock's lock in MongoDB, in the collection {@code scheduler_locks}. */
@Bean
@ConditionalOnMissingBean
LockingTaskExecutor lockingTaskExecutor(MongoTemplate mongo) {
return new DefaultLockingTaskExecutor(new MongoLockProvider(mongo.getCollection("scheduler_locks")));
}
@Bean
OutboxResender outboxResender(IncompleteEventPublications incompletePublications, LockingTaskExecutor locks,
MessagingProperties properties, Clock clock) {
return new OutboxResender(incompletePublications, locks, properties, clock);
}
}@ConditionalOnProperty(name = "platform.messaging.outbox.resend-enabled", havingValue = "true", matchIfMissing = true)The nested class applies only when the property is
true, or missing. So the resender is on by default.@EnableSchedulingThe annotation is on the same nested class as the resender. So scheduling is on exactly when the resender is on.
OutboxResender outboxResender(IncompleteEventPublications incompletePublications, LockingTaskExecutor locks,The resender is a @Bean, not a scanned class. Its
@Scheduledmethod still runs. A research run confirmed that Spring finds scheduled methods on any bean, however it was registered.
@EnableScheduling is not limited to the class that carries it. A research run put
it into a library's auto-configuration only, and the application had none. Spring still
registered its scheduling processor for the whole application. So every
@Scheduled method of the service runs, although the
service never switched scheduling on.
Scheduling also runs a task that the service never sees. The platform's defaults set
spring.modulith.events.staleness.resubmitted=PT15M, so Spring Modulith adds its
staleness monitor. The monitor registers through SchedulingConfigurer, an interface
that the scheduling processor reads. So it runs only when scheduling is on. A run of the local
environment logged its check once a minute, on the thread scheduling-1. That is the
one thread of the scheduler, which the resender uses too. For what the monitor does, see the
section Resending what was not sent.
A service's own job can depend on a platform setting without saying so.
Suppose the order service adds a nightly job with @Scheduled, and writes no
@EnableScheduling. The job runs, because the platform switched scheduling on. The
tests set platform.messaging.outbox.resend-enabled=false, so that no background job
runs during a test. That setting also switches scheduling off for the whole application. And a
service with platform.messaging.enabled=false gets no scheduling at all. The job then
stops, and nothing warns you. A service that has a job of its own writes
@EnableScheduling itself.
The test profile switches the resender off:
messaging:
outbox:
resend-enabled: falseWith resend-enabled: false, the wiring test starts the whole application with no
job and no lock. Scheduling is off too, so the staleness monitor does not run either. The journey
test sets the same property.
The strict rule. An application switches on the features it uses itself. A library adds beans, and does not change how the whole application behaves.
What we do. The platform's messaging auto-configuration carries
@EnableScheduling, next to the resender, behind one property.
Why. The resender is part of the outbox, which every service with events
needs. Without scheduling, unsent events would wait for ever, and a service author would have to
remember one more annotation. Tying it to resend-enabled keeps it visible in one
property.
When to stop. Stop when a service has jobs of its own. That service writes
@EnableScheduling in its own configuration, so its jobs do not depend on the
platform's property.
@Scheduled with durations from settingstwist#
public class OutboxResender : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await ResendAsync(stoppingToken);
}
}
}@Scheduled(initialDelayString = "${platform.messaging.outbox.resend-interval:PT1M}",
fixedDelayString = "${platform.messaging.outbox.resend-interval:PT1M}")
void resend() {
LockConfiguration lock = new LockConfiguration(clock.instant(), LOCK_NAME,
properties.getLockAtMostFor(), properties.getResendInterval().dividedBy(2));
locks.executeWithLock((Runnable) this::resendNow, lock);
}fixedDelayString = "${platform.messaging.outbox.resend-interval:PT1M}")The next run starts one interval after the previous run ends. The value is a placeholder for the setting. The part after the colon,
PT1M, is the default.initialDelayString = "${platform.messaging.outbox.resend-interval:PT1M}",The first run waits one interval after startup too. Without it, the first run would start right after startup.
locks.executeWithLock((Runnable) this::resendNow, lock);The job takes a lock before it does any work. See the section Several instances, and the lock.
PT1M is an ISO-8601 duration, which means one minute. PT30S means 30
seconds, and PT1H one hour. A research run confirmed that @Scheduled
accepts placeholders with ISO durations in its ...String attributes. The setting
itself is bound to a Duration field with the same default:
/** Turns the scheduled resender on. Tests turn it off, so no background job runs. */
private boolean resendEnabled = true;
/** How often the resender runs. */
private Duration resendInterval = Duration.ofMinutes(1);
/** Only events older than this are resent, so events still being sent are left alone. */
private Duration resendOlderThan = Duration.ofMinutes(5);
/** The longest one instance may hold the resender lock, in case it dies while holding it. */
private Duration lockAtMostFor = Duration.ofMinutes(5);The annotation reads resendInterval as a placeholder, and the method reads it from
properties. Both have the default of one minute, so they agree when the setting is
missing. A service changes both with one line, such as
platform.messaging.outbox.resend-interval: PT5M.
The C# loop and the Spring method behave alike in one way. Each one waits for its work to finish before the next run can start. Spring's scheduler has one thread by default. So a slow job delays every other job of the service. For that and for cron expressions, see the section Scheduling.
Several instances, and the locktrap#
The order service runs as three instances. Each one has the same @Scheduled
method. Every instance runs its own copy of the job, as a BackgroundService does in
each instance of an ASP.NET Core service. A research run measured it with two instances on one database for 3
seconds. Without a lock, the job ran 7 times in one instance and 6 times in the other.
For the resender, that is waste and a risk. Without a lock, every instance reads the same
unsent rows each minute, and sends them again. So the resender takes a lock first. A C# developer
expects the second instance to wait for the lock, as lock does in C#. ShedLock
behaves differently. The library is ShedLock, version
7.10.1 as of October 2026. Its 7.x line supports Spring Boot 4. The platform uses its core and its
MongoDB provider:
<!-- A lock in MongoDB, so only one instance runs the outbox resender at a time -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-core</artifactId>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-mongo</artifactId>
</dependency>The artifact shedlock-provider-mongo has no version here. The version comes from
shedlock-bom 7.10.1, which the parent
Project Object Model (POM) imports. The
provider keeps each lock as a document in the collection scheduler_locks.
void resend() {
LockConfiguration lock = new LockConfiguration(clock.instant(), LOCK_NAME,
properties.getLockAtMostFor(), properties.getResendInterval().dividedBy(2));
locks.executeWithLock((Runnable) this::resendNow, lock);
}Show the answer
ShedLock skips a run when the lock is taken. The LockingTaskExecutor tries once to take the lock. If another instance holds it, the task is not run, and the call returns. B's next chance
is its next scheduled run.
A research run with the lock ran the job 7 times in total, all in one instance. The other instance skipped every time. So a lock fits a job that may skip a turn. A job that must run once for every trigger needs a job store, such as Quartz.
The two durations of the lock protect against two different problems:
| Value | In the platform | What it protects against |
|---|---|---|
| lockAtMostFor | lock-at-most-for, PT5M by default | an instance that dies while it holds the lock, because the lock is freed after this time |
| lockAtLeastFor | half the resend interval | clocks that differ a little, so a second instance does not run just after the first |
The resender calls ShedLock's LockingTaskExecutor directly. ShedLock also offers
an annotation, @SchedulerLock, that does the same through a proxy and needs
@EnableSchedulerLock. The platform's README gives the reason for the direct call. The
library then needs no proxy and no global annotation in each service.
Two tests cover the lock. OutboxResenderTest gives the resender a lock that is free,
then one that is taken:
@Test
void skipsTheRunWhenAnotherInstanceHoldsTheLock() {
LockProvider takenLock = configuration -> Optional.empty();
resender(takenLock).resend();
verifyNoInteractions(publications);
}A LockProvider returns an empty Optional when the lock is taken. The
test then checks with verifyNoInteractions that nothing was resent.
OutboxResenderLockIT uses the real MongoDB provider in a test container. One
LockingTaskExecutor holds the lock, and a second one, on another thread, gets no
run. The test needs the second thread. ShedLock remembers the locks that a thread holds. When
the same thread asks for the same lock again, ShedLock runs the task without a check. A real
second instance is another process, so another thread is the closer copy.
ShedLock on Amazon DocumentDB was not verified. The provider ran against
MongoDB in the integration test. The ShedLock README does not mention DocumentDB, and no run for
this book used it. Run OutboxResenderLockIT against a DocumentDB cluster once before
you rely on it there.
A lock is enough when a skipped turn is harmless. The resender is such a job, because the next run sends what this one skipped. Some jobs need more, such as retries of one run, a history, or a run that must not be lost:
| Need | .NET | Java |
|---|---|---|
| a job that may skip a turn, once across instances | a BackgroundService with a lock of your own | @Scheduled with ShedLock |
| jobs kept in a database | Hangfire, which stores each recurring job in persistent storage | JobRunr, which keeps jobs in your database and has a dashboard |
| a scheduler with a cluster of nodes and a shared job store | Quartz.NET, clustered with its AdoJobStore | Quartz, with spring-boot-starter-quartz and job-store-type jdbc |
Quartz.NET clusters only with its AdoJobStore, and its documentation names load
balancing and fail-over as the benefits. Boot's Quartz
starter keeps jobs in memory by default, and in a database with
spring.quartz.job-store-type=jdbc.
A job acts as the systemnew#
A scheduled job runs on a scheduler thread. No user started it, and the thread has no security
context. If the job called a method that checks a permission, the call would fail with
AuthenticationCredentialsNotFoundException. The order service avoids this by design.
Its application services check no permission:
* <p>The class has no permission annotation. Permissions are checked on the controllers, where
* users enter. Every method here can therefore be called by another feature or by a message
* listener without a second, unprotected copy of the method.With no permission annotation on the class, nothing on the scheduler thread asks for
a caller. So a future job, such as one that cancels orders unpaid after a week, calls
OrderService.cancel directly. The audit log then records the actor
system, because the thread has no caller. That is the truth, because no user acted.
For the reasons, see the sections Messages and jobs: the system actor
and Why an identity-swapping helper is a warning sign.
The resender itself calls no use case. It asks Spring Modulith to resend publications, and the publications carry the events that a use case already recorded.
1An expert asksWhy does the order service put @Cacheable on the repository method
and not on the service method that lists the values?
@Cacheable on the repository method
and not on the service method that lists the values?@Cacheable works through a proxy. Only calls from another bean pass
it. The service calls the repository from two of its own methods, and each call passes the proxy.
A cache on a service method would be skipped by any call from inside the service.2An expert asksAn admin endpoint changes a lookup list, and its method carries
@CacheEvict. The service runs as three instances with the default cache. Why do some
requests still see the old list?
@CacheEvict. The service runs as three instances with the default cache. Why do some
requests still see the old list?expireAfterWrite, bounds how long they keep it. A shared cache such as
Redis, or no cache, makes every instance fresh at once.3The order service writes no @EnableScheduling. What switches scheduling on, and
which setting switches it off again?
@EnableScheduling. What switches scheduling on, and
which setting switches it off again?@EnableScheduling, on the
nested configuration of the outbox resender. The setting
platform.messaging.outbox.resend-enabled=false removes that configuration, and with
it scheduling for the whole application. platform.messaging.enabled=false does the
same.4TranslateThe order service reads lookup lists through a Spring Data repository. Write
the Java for this cached read:
public Task<List<ReferenceItem>?> FindByType(string type) =>
cache.GetOrCreateAsync(type, _ => store.FindByTypeAsync(type));
public Task<List<ReferenceItem>?> FindByType(string type) =>
cache.GetOrCreateAsync(type, _ => store.FindByTypeAsync(type));public interface ReferenceItemRepository extends MongoRepository<ReferenceItemDocument, String> {
@Cacheable("reference-data")
List<ReferenceItemDocument> findByTypeOrderByCodeAsc(String type);
}The proxy uses the argument type as the key, and stores the result in the cache
reference-data. Spring Data writes the query from the method's name. The application
also needs @EnableCaching once, on a configuration class.
Quick reference#
The caching and scheduling settings of the order service, with their defaults:
| Setting | Default | In the order service | What it does |
|---|---|---|---|
| spring.cache.type | the first provider found, else simple | not set, so simple | chooses the cache store |
| spring.cache.caffeine.spec | none | not used | size and expiry for every Caffeine cache |
| platform.messaging.outbox.resend-enabled | true | false in the tests | the resender, and scheduling with it |
| platform.messaging.outbox.resend-interval | PT1M | not changed | how often the resender runs |
| platform.messaging.outbox.resend-older-than | PT5M | not changed | only older publications are resent |
| platform.messaging.outbox.lock-at-most-for | PT5M | not changed | the longest one instance holds the lock |
| platform.messaging.enabled | true | not changed | the whole outbox and inbox, with scheduling |
| spring.task.scheduling.pool.size | 1 | not changed | the scheduler's threads |
Writing the platform library#
The platform library holds the code that every shop service needs, such as errors, security, storage and messaging. A service adds one dependency, and Spring Boot sets everything up through auto-configuration. The service writes no line of setup. This chapter shows how the library is built, ordered and switched. It shows how a service replaces a default, and what must never go in the library.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | Shop.Platform, a NuGet package | shop-platform, in five packages | domain, core, mongo, messaging and test, and each package is one area that a service gets Why |
| new | a shared Domain library with Money and OrderId | no business code and no domain model in the platform | services share contracts, never domain classes, because shared models tie releases together Why |
| twist | builder.Services.AddShopPlatform() in Program.cs | an entry in AutoConfiguration.imports | Boot reads the file from every jar at startup, so the service calls nothing Why |
| trap | no equivalent | a misspelt name of the imports file | Boot reports nothing and loads none of the library's classes Why |
| new | no equivalent | eight auto-configuration classes | each one sets up one area, and each states its conditions and its order Why |
| twist | no equivalent | a WebMvcConfigurer bean | a library adds to Spring MVC by declaring a bean, not by calling a method Why |
| trap | two AddSingleton calls: the last one wins | a default and a provider, with no order between them | Spring registers both beans, and a service that asks for one gets NoUniqueBeanDefinitionException Why |
| twist | no equivalent | before, after, beforeName and afterName on @AutoConfiguration | they order the auto-configurations, and a name that matches no class is not reported Why |
| new | no equivalent | @ConditionalOnMissingBean, @ConditionalOnProperty, @ConditionalOnWebApplication, @ConditionalOnClass | each condition decides whether a bean or a whole class is created Why |
| twist | services.TryAddSingleton(TimeProvider.System) | @Bean @ConditionalOnMissingBean Clock clock() | the service's own bean wins in both, but .NET needs the service to register it first Why |
| new | Configuration.Sources.Insert(0, defaults) | one defaults file for each package, added last by an EnvironmentPostProcessor | the platform's defaults have the lowest precedence, so application.yml always wins Why |
| twist | no equivalent | spring.factories for the EnvironmentPostProcessor | the imports file is only for auto-configurations, so this one class is still registered the old way Why |
| twist | no equivalent | platform.messaging.enabled=false, with three Spring properties | a service with no events switches the outbox, the inbox and Spring Cloud AWS off together Why |
| new | a shared test helper package | @PlatformWebMvcTest, @PlatformDataMongoTest, test containers and ArchitectureRules | the library ships its test support, so every service tests the same way Why |
| twist | no equivalent | test support in the library's main source, not in its tests | a service can use only main code, so the library compiles against the test libraries without passing them on Why |
| new | a package version on NuGet | semantic versions, with services at most one minor version behind | a breaking change comes only in a major version, with upgrade notes Why |
| new | one NuGet package per concern | one library, with its packages kept apart | one dependency and one version, and messaging can be switched off Why |
| new | no equivalent | build the library with the first services | until it exists, put shared code in the service's shared package, with the future package names Why |
What the library gives a servicenew#
The shop has ten services. Each one needs the same error format, the same token checks and
the same way to store an aggregate. If each team wrote its own, the ten versions would differ
within a year. So the shop writes that code once, in the library shop-platform. Its
Project Object Model (POM) lists its five
packages:
<!--
The platform library: the one dependency that every shop service adds.
com.acme.platform.domain DDD building blocks in plain Java (no Spring imports)
com.acme.platform.core errors, security, logging, tracing, HTTP clients, audit, OpenAPI
com.acme.platform.mongo DocumentDB settings, transactions, base persistence classes, migrations
com.acme.platform.messaging the outbox to SNS and the inbox for SQS
com.acme.platform.test test annotations, test containers and architecture rules
Every feature is Spring Boot auto-configuration. The library holds no business code.
-->
<!-- ... -->
<artifactId>shop-platform</artifactId>The artifact is named shop-platform, and its comment lists the packages. Each package is one area. A service's code uses classes from domain, such as
AggregateRoot, and the others give it beans at startup. The platform's README lists
what a service gets from each package:
| Package | What a service gets |
|---|---|
| com.acme.platform.domain | AggregateRoot, Entity, Identifier, ValueObject, DomainEvent, DomainEventPublisher, Repository, Guard, PageQuery, PageResult and the three exception kinds, in plain Java |
| com.acme.platform.core | problem responses with traceId, the resource server chain, method security, the MDC interceptor, LogContext, JSON log masking, HttpClientFactory, ResilientCalls, Clock, CurrentActor, AuditLog and the shared OpenAPI parts |
| com.acme.platform.mongo | the transaction manager, the DocumentDB driver settings, BaseDocument, MongoAggregateRepository, DocumentMapper, the migration runner and seed files, the audit log store and the 409 handler |
| com.acme.platform.messaging | the outbox, with OutboxDomainEventPublisher and OutboxResender, and the inbox, with Inbox and InboxMessageHandler |
| com.acme.platform.test | @PlatformWebMvcTest, @PlatformDataMongoTest, the MongoDB and Floci test containers, NoOpTransactionManager and ArchitectureRules |
Domain-Driven Design (DDD) is the approach of the whole part. The building blocks in the first row are its base classes. For each of them, see the chapter The domain model in plain Java. Each other row has a home chapter too. The mapped diagnostic context (MDC) and the log masking are in the chapter Logging, tracing and health. The clients are in the chapter Calling other services. This chapter explains how the library is put together, not what each class does.
The README is also the place to look up a setting. Its properties table is in the quick reference at the end of this chapter.
What never goes in the platform librarynew#
The order service and the invoice service both need Money. Both need an order ID. A
developer suggests a shared library with Money, OrderId and perhaps
Order itself. Then nobody writes them twice. The shop says no, and writes the rule
at the top of the platform's README:
The parent POM and the library that every shop service uses. It holds no business code and no
domain model: services share contracts (OpenAPI files and event schemas), never domain classes,
because a shared domain library would tie their release cycles together.The README says that the library holds no business code. The rule has two parts. The platform holds no business code. And no other shared library holds
domain classes either. Services share contracts. A contract is the OpenAPI file of an API, or the
JSON schema of an event.
The reason is the release cycle. Suppose Order is in a shared library. The order
service adds a field for a new rule. Now the invoice service must take a new version of the
library, though nothing changed for it. Or it stays on the old version, and the two services
disagree about what an order is. Either way, a change in one service touches the others. A
contract is different. The order service can add a field to OrderPlacedV1, and a
consumer that does not read the field does not change.
The careful version of a shared domain library has a fair point. Some types really are the
same everywhere, such as an amount of money with a currency. A team that writes
Money in every service writes the same rounding rule ten times, and one copy will be
wrong. The book's answer is to look at what a shared type must contain. The platform's
domain package holds building blocks with no business meaning. It holds the base
class of an aggregate, an identifier and a guard for simple checks. Those change rarely, and they
mean the same in every service:
/**
* The building blocks of a domain model, in plain Java.
*
* <p>Nothing in this package imports Spring, MongoDB or Jackson. A service's {@code domain}
* packages may use these classes and still stay plain Java. The classes hold no business
* concepts: they remove code that every aggregate would otherwise repeat, such as equality by
* identity, the version for optimistic locking, the list of raised events and simple checks.
*/
package com.acme.platform.domain;The comment draws the line. It says that the classes hold no business concepts. The package removes code that every aggregate repeats, such as
equality by identity. It never says what an order is. Money is a business concept,
with a rule about decimals that belongs to the shop's own pricing. So each service that needs it
writes it, and each copy can follow its own service's rules.
The other parts of the library follow the same test. Problem responses, token checks and the outbox are technical, and identical in every service. A rule such as "an order ships only when it is paid" is business, and it stays in the order service.
The strict rule. One concept has one implementation, so a fix is made once.
What we do. Each service writes its own value objects, such as
Money. The platform shares only building blocks with no business meaning.
Why. A shared business type ties every service to one release cycle. A copy of a small record costs a few lines, and each service can change its copy alone.
When to stop. Stop when the same rule inside such a type is copied into many services, and the copies must never differ. A legal rounding rule is an example. Then make it a small library of its own, with no other business types in it, and version it carefully.
How Spring Boot finds the library's setupnew#
The order service's main class is in com.acme.orderservice.
Component scanning looks only in that package and
below it. The platform's classes are in com.acme.platform, so the scan never finds
them. Yet the service gets the platform's filter chain, its error handler and its transaction
manager.
The answer is a file in the library's Java archive (JAR). Spring Boot reads every file with this name on the classpath at startup:
com.acme.platform.core.PlatformCoreAutoConfiguration
com.acme.platform.core.PlatformWebAutoConfiguration
com.acme.platform.core.PlatformSecurityAutoConfiguration
com.acme.platform.core.openapi.PlatformOpenApiAutoConfiguration
com.acme.platform.mongo.PlatformMongoAutoConfiguration
com.acme.platform.mongo.PlatformMongoAuditAutoConfiguration
com.acme.platform.mongo.PlatformMongoWebAutoConfiguration
com.acme.platform.messaging.PlatformMessagingAutoConfigurationThe file is META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports.
Each line names one class, such as com.acme.platform.core.PlatformCoreAutoConfiguration. Boot treats each class as an
auto-configuration. It sorts them, checks their
conditions and creates their beans. A line that starts with # is a comment. For the
basic idea, see the section Auto-configuration.
The main class of the service says why it registers nothing:
* <p>{@code @SpringBootApplication} scans this package and every package below it for
* components. The platform's beans come from its auto-configuration instead, because the
* platform's packages are not below this one.The comment says that @SpringBootApplication scans only its own package and the
packages below it. So the library's beans are added in a different way from the service's own beans. The scan
finds @Component classes in the service. The imports file names the platform's
configuration classes. An auto-configuration runs after the service's own configuration, so its
conditions can see the service's beans.
In ASP.NET Core, a library offers an extension method on IServiceCollection, and Program.cs calls it. A
run for this book wrote one:
public static class ShopPlatformServiceCollectionExtensions
{
// An extension method: Program.cs calls builder.Services.AddShopPlatform().
public static IServiceCollection AddShopPlatform(this IServiceCollection services)
{
services.TryAddSingleton(TimeProvider.System);
return services;
}
}@AutoConfiguration(afterName = "org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration")
@EnableConfigurationProperties({PlatformLoggingProperties.class, ResilienceProperties.class})
public class PlatformCoreAutoConfiguration {
/** The clock that the domain reads the time from. Code never calls {@code Instant.now()}. */
@Bean
@ConditionalOnMissingBean
Clock clock() {
return Clock.systemUTC();
}The C# library does nothing until the service calls AddShopPlatform(). The Java
library needs no call. Its class is in the imports file, so adding the dependency is enough. The
@Bean method clock() does the same job as
TryAddSingleton. For the conditions and the order, see the sections below.
A misspelt imports file is silently ignored. A run with the file named
...AutoConfigurations.imports, with one letter too many, gave no error. The class was
not loaded. The file spring.factories is not read for auto-configurations either,
though older tutorials use it. If the platform's beans are missing, check the file name first. The
conditions report of --debug lists every auto-configuration that Boot considered. A
class that is missing from the report was never found. For the report, see the section
Asking Boot what it configured.
The eight auto-configurationsnew#
The library has one auto-configuration class for each area. Each class says when it applies, and where it runs in the order:
| Class | Applies when | Order | Beans |
|---|---|---|---|
| PlatformCoreAutoConfiguration | always | after Boot's OAuth2 client setup | Clock, CurrentActor, HttpClientFactory, ResilientCalls |
| PlatformWebAutoConfiguration | in a servlet web application | no order | GlobalExceptionHandler, and a WebMvcConfigurer for the MDC interceptor |
| PlatformSecurityAutoConfiguration | in a servlet web application | before Boot's security chains | the SecurityFilterChain, with method security on |
| PlatformOpenApiAutoConfiguration | when springdoc's OpenAPI class is on the classpath | no order | an OpenApiCustomizer with the shared parts |
| PlatformMongoAutoConfiguration | always | before Boot's MongoDB and transaction setup | MongoTransactionManager, the DocumentDB customizer, the migration runner |
| PlatformMongoAuditAutoConfiguration | always | after PlatformMongoAutoConfiguration | MongoAuditLog, AuditLogIndexes |
| PlatformMongoWebAutoConfiguration | in a servlet web application | no order | OptimisticLockingExceptionHandler |
| PlatformMessagingAutoConfiguration | unless platform.messaging.enabled is false | after PlatformMongoAutoConfiguration | the outbox publisher, the inbox, and the resender with its lock |
The core class holds the beans that every service needs, web or not:
/**
* The beans every service gets, whether it is a web application or not.
*
* <p>Each bean is {@link ConditionalOnMissingBean}: a service that defines its own bean of the
* same type replaces the platform's one. A test, for example, defines a fixed {@link Clock}.
*/
@AutoConfiguration(afterName = "org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration")
@EnableConfigurationProperties({PlatformLoggingProperties.class, ResilienceProperties.class})
public class PlatformCoreAutoConfiguration {
// ...
@Bean
@ConditionalOnMissingBean
CurrentActor currentActor() {
return new SecurityContextCurrentActor();
}@AutoConfiguration(afterName = "org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration")@AutoConfigurationmarks the class as an auto-configuration. It does what@Configurationdoes, and it also takes ordering attributes.afterNamemakes Boot process this class after the named one, which sets up the OAuth2 client registrations.@EnableConfigurationProperties({PlatformLoggingProperties.class, ResilienceProperties.class})This line creates the two
@ConfigurationPropertiesbeans and binds them from the settings, such asplatform.resilience.retry.max-attempts. For binding, see the chapter Configuration in depth.@ConditionalOnMissingBeanWithout a type, the condition takes the method's return type. So this bean is created only when no
CurrentActorbean exists yet.
The class name of an auto-configuration ends in AutoConfiguration, by convention.
The class is public, but its @Bean methods are package-private. Spring
can still call them, and no service needs to.
The web class shows how a library adds to Spring model-view-controller (MVC):
/** The web defaults: one error format for every service, and path variables in the MDC. */
@AutoConfiguration
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@EnableConfigurationProperties(PlatformLoggingProperties.class)
public class PlatformWebAutoConfiguration {
@Bean
@ConditionalOnMissingBean
GlobalExceptionHandler globalExceptionHandler() {
return new GlobalExceptionHandler();
}
/** Registers the MDC interceptor. A {@link WebMvcConfigurer} bean is how a library adds to Spring MVC. */
@Bean
WebMvcConfigurer platformMdcWebMvcConfigurer(PlatformLoggingProperties properties) {
PathVariableMdcInterceptor interceptor = new PathVariableMdcInterceptor(properties.getMdcPathVariables());
return new WebMvcConfigurer() {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(interceptor);
}
};
}
}@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)The class applies only in a servlet web application. A batch job that uses the library gets no error handler and no interceptor. A run for this book checked the condition. In an application of the type
NONE, the bean was absent. In a servlet application, it was there.WebMvcConfigurer platformMdcWebMvcConfigurer(PlatformLoggingProperties properties) {Spring MVC collects every
WebMvcConfigurerbean and calls each one. So the library adds an interceptor by declaring a bean. The service's own configurers still work beside it.return new WebMvcConfigurer() {This is an anonymous class. It implements the interface on the spot, with one method.
The bean platformMdcWebMvcConfigurer has no @ConditionalOnMissingBean.
A service may have many WebMvcConfigurer beans, so the condition would switch the
platform's interceptor off by accident. A bean that is one of many has no such condition.
The 409 handler for optimistic locking is in the mongo package, not in the web package:
/**
* Registers the 409 handler for optimistic locking, only in web applications.
*
* <p>It lives in the mongo package, next to the technology that throws the exception, so the
* platform's web code knows nothing about MongoDB.
*/
@AutoConfiguration
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public class PlatformMongoWebAutoConfiguration {So PlatformMongoWebAutoConfiguration is a small class of its own. If the library
later splits into modules, the web module needs no MongoDB, and the mongo module brings its own
handler.
The messaging class has a nested configuration:
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(name = "platform.messaging.outbox.resend-enabled", havingValue = "true", matchIfMissing = true)
@EnableScheduling
static class OutboxResendConfiguration {A nested configuration is a static class inside the auto-configuration. Its
conditions apply only to its own beans. Here @EnableScheduling is on the nested
class. So scheduling is on only when the resender is on, and a service controls both with one
property. For the resender, see the chapter Publishing events: the
outbox.
The security class and its order are taught in the section Why the platform's chain is created before Boot's.
The order of auto-configurations, and its trapstrap#
A condition such as "no Clock bean exists yet" depends on what Spring has already
processed. So the order of the auto-configurations decides the result. Spring Boot's reference
says the same, and it recommends bean conditions only on auto-configuration classes. Two
libraries offer a greeter. One offers a default with a condition, and one offers its own bean.
The two libraries do not say which one comes first:
@AutoConfiguration
public class AaaTrapDefaultsAutoConfiguration {
@Bean
@ConditionalOnMissingBean
TrapGreeter defaultTrapGreeter() { return new TrapGreeter("default"); }
}
@AutoConfiguration
public class CccTrapProviderAutoConfiguration {
@Bean
TrapGreeter providedTrapGreeter() { return new TrapGreeter("provided"); }
}Show the answer
NoUniqueBeanDefinitionException: "expected single matching bean
but found 2: defaultTrapGreeter,providedTrapGreeter".The condition is checked when its own class is processed, not at the end. With no order, Boot processed the default first. No greeter existed then, so the default was created. The provider then added its unconditional bean. Both beans were registered.
In .NET, two AddSingleton calls for one type give no error. A run showed that
GetRequiredService returns the last one.
Order the default after the provider, with after or afterName. The
same run then left one bean, the provider's.
The classes in the prediction are from a run for this book. Their code is shortened here. The run also found a worse effect. In its first version, a third class declared an order against the provider. Boot's sorting then moved the provider ahead of the default as well, and the trap did not appear. So a default can work by luck, because of another class's order. The luck ends when that other class changes.
An auto-configuration states its order with four attributes of
@AutoConfiguration:
| Attribute | Takes | Use it when |
|---|---|---|
| before | classes | the other class is always on the library's compile classpath |
| after | classes | the same, for a class that must come first |
| beforeName | class names as strings | the other class is in a module that may be missing, or the library should not compile against it |
| afterName | class names as strings | the same, for a class that must come first |
The platform uses each kind. The audit class names a class of its own library:
/** Stores the audit log in MongoDB, unless the service defines its own {@link AuditLog}. */
@AutoConfiguration(after = PlatformMongoAutoConfiguration.class)
public class PlatformMongoAuditAutoConfiguration {after = PlatformMongoAutoConfiguration.class is a class literal, so the compiler
checks it. The mongo class names Boot's classes as strings:
/**
* Sets up DocumentDB for every service: transactions, the driver settings DocumentDB needs,
* and the migration runner.
*
* <p>Spring Boot creates no transaction manager for MongoDB. Without one, {@code @Transactional}
* silently does nothing: each write commits on its own. This class registers a
* {@link MongoTransactionManager} before Spring Boot's transaction setup runs, so
* {@code @Transactional} and {@code TransactionTemplate} both use it.
*/
@AutoConfiguration(beforeName = {
"org.springframework.boot.data.mongodb.autoconfigure.DataMongoAutoConfiguration",
"org.springframework.boot.transaction.autoconfigure.TransactionAutoConfiguration"})
@EnableConfigurationProperties(DocumentDbProperties.class)
public class PlatformMongoAutoConfiguration {
@Bean
@ConditionalOnMissingBean(TransactionManager.class)
MongoTransactionManager transactionManager(MongoDatabaseFactory databaseFactory) {
return new MongoTransactionManager(databaseFactory);
}"org.springframework.boot.transaction.autoconfigure.TransactionAutoConfiguration"})Boot's transaction setup creates a
TransactionTemplateonce a transaction manager exists. A run with Boot 4.1.2 showed it. With no manager, there was no template either. With the manager, Boot added the beantransactionTemplate. So the platform's manager must exist before Boot's class runs.@ConditionalOnMissingBean(TransactionManager.class)The condition names a type. A service that declares any transaction manager of its own keeps it, and the platform creates none.
The names in beforeName are checked by no compiler. The run for this book
confirmed that each of these names exists in Boot 4.1.2. A second run gave the web class a
beforeName that names no class at all. The service started, and its wiring test
passed. Nothing reported the wrong name.
A wrong name in beforeName or afterName is not
reported. The order is then simply not applied. Boot 4 moved many classes into new
packages, so a name copied from a Boot 3 library is often wrong. Check each name against the Boot
jars after every major upgrade. A wiring test that starts the whole service, such as
ApplicationWiringTest, catches the effect when an ordering matters for a bean.
The wiring test checks the result of two orderings at once:
@Test
void theContextStartsWithThePlatformsBeans() {
assertThat(context.getBean(PlatformTransactionManager.class)).isInstanceOf(MongoTransactionManager.class);
assertThat(context.getBeansOfType(SecurityFilterChain.class)).containsOnlyKeys("platformSecurityFilterChain");
}The first line checks that the transaction manager is the platform's
MongoTransactionManager. The second line checks that exactly one filter chain exists,
the platform's own. If an order broke, one of the two lines would fail. For the wiring test, see the
chapter Testing in depth.
Conditions: when a bean or a class appliesnew#
A condition decides whether Spring creates a bean, or applies a whole class. The platform uses four of them:
| Condition | The bean or class is created when | Used by the platform for |
|---|---|---|
| @ConditionalOnMissingBean | no bean of the type exists yet, and the type is the return type when none is named | every default that a service may replace, such as Clock and AuditLog |
| @ConditionalOnProperty | a setting has the given value, compared without case, and matchIfMissing decides when it is absent | the migrations, the messaging and the resender, which a service can switch off |
| @ConditionalOnWebApplication | the application has the given web type, such as SERVLET | the error handler, the security chain and the 409 handler |
| @ConditionalOnClass | a class is on the classpath | the OpenAPI customizer, which needs springdoc |
A switch with @ConditionalOnProperty looks like this:
/** Runs the migrations at startup. Slice and wiring tests turn it off with the property. */
@Bean
@ConditionalOnProperty(name = "platform.mongo.migrations.enabled", havingValue = "true", matchIfMissing = true)
MongoMigrationRunner mongoMigrationRunner(MongoTemplate mongo, ObjectProvider<MongoMigration> migrations, Clock clock,
@Value("${platform.mongo.migrations.lock-ttl:PT15M}") Duration lockTtl) {
return new MongoMigrationRunner(mongo, migrations.orderedStream().toList(), clock, lockTtl);
}havingValue = "true", matchIfMissing = true)The bean is created when the setting is
true, and also when the setting is absent. A run for this book checked the three cases. With the setting missing, the bean was present. Withfalse, it was absent. WithTRUEin capitals, it was present.ObjectProvider<MongoMigration> migrationsAn
ObjectProviderasks for beans when the code needs them.orderedStream()returns everyMongoMigrationbean, sorted by@Order. ForObjectProvider, see the chapter Wiring the service: the container in depth.@Value("${platform.mongo.migrations.lock-ttl:PT15M}") Duration lockTtl)One setting, read directly, with the default after the colon.
The OpenAPI class depends on a class from another library:
@AutoConfiguration
@ConditionalOnClass(OpenAPI.class)
public class PlatformOpenApiAutoConfiguration {@ConditionalOnClass(OpenAPI.class) applies the class only when springdoc's
OpenAPI class is on the classpath. The library adds springdoc today. The condition
keeps the class safe if a service ever leaves springdoc out.
Boot's reference recommends bean conditions, such as @ConditionalOnMissingBean,
only on auto-configuration classes. A @Configuration class of the service is processed
before the auto-configurations. So a bean condition on it sees only part of the beans. The platform
keeps every bean condition on its auto-configurations.
Replacing a default: a bean of your own, or a settingtwist#
A test needs a fixed time. A service needs its audit log in another store. Each of them replaces one default of the platform. There are two ways. A service defines its own bean of the same type, or it sets a property.
var services = new ServiceCollection();
services.AddSingleton<TimeProvider>(new FixedTime());
services.AddShopPlatform();/** The clock that the domain reads the time from. Code never calls {@code Instant.now()}. */
@Bean
@ConditionalOnMissingBean
Clock clock() {
return Clock.systemUTC();
}In the run for this book, the service's FixedTime won, and only one
TimeProvider was registered. TryAddSingleton adds nothing when the type
is registered already. But the order matters in .NET. When the service registered its own clock
after AddShopPlatform(), both registrations existed. GetRequiredService
returned the service's one, because the last one wins.
In Spring, the order of the service's code does not matter. The service's own configuration is
processed before the auto-configurations. So @ConditionalOnMissingBean always sees the
service's Clock, and the platform's one is never created. A run for this book showed
that a user's bean makes the condition back off, and one bean remains.
These are the beans a service can replace:
| Type | The platform's default | A reason to replace it |
|---|---|---|
| Clock | Clock.systemUTC() | a test that needs a fixed time |
| CurrentActor | SecurityContextCurrentActor | a service that reads the actor from somewhere else |
| AuditLog | MongoAuditLog, in the collection of the platform | an audit store outside the service's database |
| SecurityFilterChain | the resource server chain | a service with other rules, such as URL rules |
| TransactionManager | MongoTransactionManager | a service that does not use MongoDB |
| DomainEventPublisher | OutboxDomainEventPublisher | a service with another way to publish |
| GlobalExceptionHandler | the platform's problem responses | rarely, because every service should answer errors the same way |
A setting is the lighter way. The platform reads its settings from
@ConfigurationProperties classes. A service sets
platform.security.public-paths, for example, and keeps the platform's chain. Prefer a
setting when one exists. A bean of your own takes over the whole object, and the platform's later
fixes to it no longer reach the service.
A service finds the settings in its IDE. The shop's parent POM runs Spring Boot's configuration
processor when the library is compiled. The processor writes the file
META-INF/spring-configuration-metadata.json into the library's jar. The file holds
each key's type and description, and the default of most keys. So an IDE completes platform.security.public-paths and shows
what it does. Write a Javadoc comment on every field of a properties class, because that comment
becomes the key's description. For the file and its limits, see the section
Completion in the IDE: the configuration metadata.
Default settings, loaded with the lowest precedencenew#
Every service needs some settings that the platform chooses. Indexes must never be created at
startup. A missing queue must stop the service. Health probes must be on. If each service wrote
those lines in its application.yml, some would forget them. So the platform keeps
them in files of its own:
# Defaults that every service gets from the platform's mongo package.
# Indexes are created by migrations (MongoMigration), never by Spring at startup.
# Startup index creation is invisible, nobody reviews it, and it can block on a large collection.
spring.data.mongodb.auto-index-creation=false
# The service is ready for traffic only when the database is reachable.
# Liveness deliberately leaves the database out: a database outage must not restart every instance.
management.endpoint.health.group.readiness.include=readinessState,mongoEach package has its own file, core.properties, mongo.properties and
messaging.properties. Each line carries the reason for the setting. The key
spring.data.mongodb.auto-index-creation switches off the indexes that Spring Data
would create at startup.
Some lines restate a default of Spring Boot. The core file says why:
# Defaults that every service gets from the platform's core package.
# They have the LOWEST precedence: a service's application.yml overrides any of them.
# Some keys restate a Spring Boot 4.1 default. They stay here on purpose: the platform's
# behaviour is visible in one file, and a Boot upgrade that changes a default cannot change it.The comment says that the defaults have the LOWEST precedence. A Boot upgrade could change a default. A line in the file keeps the platform's choice, so
server.shutdown=graceful stays graceful after any upgrade.
The files are not read by Spring Boot on its own. An environment post-processor of the platform,
PlatformDefaultsEnvironmentPostProcessor, adds them to the environment, before the
application context exists. It adds each file
with addLast, so the defaults have the lowest precedence, and every setting of the
service wins. For the class, its order and its test, see the section
The platform's defaults, below everything else.
For the author of the library, three rules follow from that design:
- A new package of the library adds its own file in
META-INF/platform-defaults. The processor finds every file in that folder, in every jar, so no code changes. - Each key carries a comment with its reason. Then a reviewer can tell a decision from a leftover.
- A default that depends on another setting cannot be a line in a file. It goes into the processor's code. The switch to JSON logs in the Elastic Common Schema (ECS) is an example.
The class is not in the imports file. An imports file names auto-configurations, and this class
must run before any of them. So Boot finds it through the older file
META-INF/spring.factories:
# EnvironmentPostProcessor is still registered here, not in the AutoConfiguration.imports file.
org.springframework.boot.EnvironmentPostProcessor=\
com.acme.platform.core.config.PlatformDefaultsEnvironmentPostProcessorThe key is the interface's full name, org.springframework.boot.EnvironmentPostProcessor.
The old key, in the package org.springframework.boot.env, still works in Boot 4.1.2,
but it is deprecated. So a library registers its processor under the new key.
Switching a whole area offtwist#
A small service sends and receives no events. With one library, it still has the outbox, the inbox and Spring Cloud AWS on its classpath. It switches them off with four settings:
A service that sends and receives no events sets these four properties together:
`platform.messaging.enabled=false`, `spring.cloud.aws.sns.enabled=false`,
`spring.cloud.aws.sqs.enabled=false` and `spring.modulith.events.externalization.enabled=false`.The first setting, platform.messaging.enabled=false, is the platform's own. It switches off the whole messaging class, because the
class carries the condition:
@AutoConfiguration(after = PlatformMongoAutoConfiguration.class)
@ConditionalOnProperty(name = "platform.messaging.enabled", havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(MessagingProperties.class)
public class PlatformMessagingAutoConfiguration {The condition is on the class PlatformMessagingAutoConfiguration itself. A condition on the class applies to every bean in it, and to its nested configuration. So
platform.messaging.enabled=false removes the outbox publisher, the inbox and the
resender together.
The other three settings belong to Spring Cloud AWS and Spring Modulith. They are settings of those libraries, so the service sets them too. Turning messaging off completely takes four properties, because three libraries are involved. Each property switches off one part:
| Property | Switches off |
|---|---|
| platform.messaging.enabled=false | the platform's outbox publisher, the inbox and the resender |
| spring.cloud.aws.sns.enabled=false | the SNS client of Spring Cloud AWS |
| spring.cloud.aws.sqs.enabled=false | the SQS client and the listener containers |
| spring.modulith.events.externalization.enabled=false | the sending of events to other services by Spring Modulith |
The README lists the four
together, because one without the others leaves a part running. The wiring profile of the order
service sets spring.cloud.aws.sqs.enabled: false, so its test needs no queue.
The migrations and the resender have switches of the same kind,
platform.mongo.migrations.enabled and
platform.messaging.outbox.resend-enabled. The slice tests and the wiring test use
them.
Test support, shipped in the librarynew#
Each service tests its controllers with the platform's security and error handling, as in
production. Each service tests its persistence against MongoDB in a container. If each team wrote
that setup, the tests would differ. So the library ships the setup in its package
com.acme.platform.test. The web slice is an annotation:
/**
* A web slice test with the platform's security, error handling and MDC interceptor, the same
* as in production. Only the controllers and Spring MVC start, so the test runs in about a
* second.
*
* <pre>{@code
* @PlatformWebMvcTest(OrderController.class)
* class OrderControllerTest { ... }
* }</pre>
*
* <p>{@code @WebMvcTest} loads no auto-configuration from libraries, so this annotation imports
* the platform's web-related ones by name.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@WebMvcTest
@ImportAutoConfiguration({
PlatformCoreAutoConfiguration.class,
PlatformWebAutoConfiguration.class,
PlatformSecurityAutoConfiguration.class,
PlatformMongoWebAutoConfiguration.class})
@Import(TestJwtDecoderConfiguration.class)
public @interface PlatformWebMvcTest {
/** The controllers to test. */
@AliasFor(annotation = WebMvcTest.class, attribute = "controllers")
Class<?>[] value() default {};
}@WebMvcTest starts a test slice with only
Spring MVC and the named controllers. It does not start the auto-configurations of a library. So
the annotation @PlatformWebMvcTest carries @WebMvcTest, and names the
platform's four web-related auto-configurations in @ImportAutoConfiguration. A service
writes one annotation, and its slice runs the platform's security and error handling. The
chapter on testing explains each line of the annotation. It also shows what goes wrong with a plain
@WebMvcTest. See the section API slices through the platform's own annotation.
The package holds these classes:
| Class | What a service's test gets |
|---|---|
| @PlatformWebMvcTest | a controller slice with the platform's security, errors and MDC |
| @PlatformDataMongoTest | a persistence slice against MongoDB in Docker, with the platform's transaction manager and migrations off |
| MongoTestContainerConfiguration | MongoDB 8.0.34 as a one-node replica set, connected with @ServiceConnection |
| AwsTestContainerConfiguration | the Floci emulator for SNS and SQS |
| TestJwtDecoderConfiguration | a decoder that refuses every real token, for tests that use jwt() |
| NoOpTransactionManager | a transaction manager that does nothing, for unit tests of a use case |
| ArchitectureRules | the design rules as ArchUnit rules |
This code is in the library's main source, not in its tests. Only then can a service use it. So the library must compile against the test libraries, without passing them on to a service. For how the POMs do that, see the chapter The build: a parent POM and a platform library.
The library tests itself too, in its own repository. As of October 2026, it has 16 test classes
and four integration tests.
ResilientCallsTest, AggregateRootTest and SeedFilesTest are
three of the test classes. A library has no application class, so
its slice tests start from one in the test folder:
/**
* The configuration that the platform's own slice tests start from. A library has no
* application class, but {@code @DataMongoTest} looks upwards from the test's package for one.
*/
@SpringBootApplication
class PlatformTestApplication {
}PlatformTestApplication is in the package com.acme.platform, the root of
every test package of the library. So every slice test of the library finds it. For the other kinds
of test, see the chapter Testing in depth.
Versions, and the rules for upgradingnew#
The platform changes a default. The service that upgrades first is surprised in production. So the platform's versions follow rules, written in its README:
Semantic versions. A breaking change comes only in a major version, with upgrade notes. Services
stay at most one minor version behind. Upgrading Spring Boot for every service means changing
the parent and releasing a new platform version.The README says Semantic versions. A semantic version has three numbers, such as 1.4.2. The first is the major version, the second
the minor and the third the patch. A patch fixes a bug. A minor version adds something, and changes
nothing that a service relies on. A major version may break things.
For a library like this one, "breaking" covers more than a Java signature. These changes are breaking, so they wait for a major version:
- A property is renamed or removed, such as
platform.security.public-paths. - A default changes its value, such as the public paths or the retry count.
- A bean is removed, or a bean loses its
@ConditionalOnMissingBean. - A class or a method of the
domainortestpackages changes its signature. - A Spring Boot major version, such as Boot 3 to Boot 4.
These changes are minor, because no service breaks:
- A new bean with
@ConditionalOnMissingBean, which a service may replace. - A new property with a default that keeps today's behaviour.
- A new architecture rule. A service runs a rule only after it adds a test that calls it, and it can freeze its old violations first. For freezing, see the chapter Testing in depth.
The rule "at most one minor version behind" keeps all services close together. A security fix then needs at most one upgrade step for any service. A service that falls three versions behind must make three sets of changes at once, at the worst moment.
The upgrade notes are the platform team's duty. Each note says what changed, why, and what a service must do. With the parent and the library at the same version, a service changes one number. For the steps, see the section Upgrading Spring Boot for every service.
One library, not one per concernnew#
Spring Boot itself has a starter per concern, such as web, security and data. A platform could do
the same, with one module per package. The modules would have names such as
shop-platform-core and shop-platform-mongo. A service would add only
what it uses. The shop chose one library. Its README says why:
- **One library, not one per concern.** One dependency and one version for every service. The
cost is that every capability is on the classpath, which is why messaging can be switched off.
The packages stay separate, so the library can be split later.The README names the cost: every capability is on the classpath. The case for modules is strong. A service with no events would not carry the AWS libraries.
Its startup would check fewer conditions. And the boundaries between the areas would be enforced by
the build, not by discipline.
The case for one library is stronger while the shop is small. Every service has the same set of platform code. A bug report names one version, not four that must match. A new service adds one dependency. And nearly every shop service uses all the areas. The cost is the classpath, and the switch for messaging removes most of it.
The design keeps a later split easy. Each area is a package with its own auto-configurations. The 409 handler is in the mongo package, so the web code has no MongoDB import. A split would move each package to a module, with its own imports file, and almost no code would change.
Split the library when one of these happens:
- Several services do not use an area, and its libraries slow their startup or enlarge their image.
- An area needs its own release rhythm, for example because the messaging changes every month.
- Services in another language or framework need part of it. Then that part is no longer a Spring Boot library at all.
When to build the library, and what to do before it existsnew#
A company starts its first DDD services. The platform library does not exist yet. Two mistakes are common. One team waits for a complete platform before it writes a service. Or every team writes its own cross-cutting code, and eight or ten services later the company has eight or ten error formats.
The better path is to build the library together with the first services. The first service needs an error handler, so the error handler is written once, in a form that the library can take. The second service then uses it.
Until the library exists, a service keeps the shared code in its shared package,
with the package names that the library will have. The service's own
shared package already says where such code belongs:
/**
* Small helpers that every feature of this service uses: the permission annotations, the audit
* names and the OpenAPI description. It holds no business logic. Code that several services
* need belongs in the platform instead.
*/
package com.acme.orderservice.shared;The comment's last sentence is the rule. Code that several services need belongs in the
platform. Before the platform exists, such code waits in shared, under names such as
shared.platform.core.error. When the library arrives, the code moves, and only the
imports change.
The strict rule. Cross-cutting code comes from the platform library, and a service holds none of it.
What we do. Before the library exists, the first services keep such code in
their shared package. It uses the package names that the library will use.
Why. A library built before any service guesses what services need. A library built from the first two services contains what they really used.
When to stop. Stop when a second service needs the same code. Then move it into the library, release it, and delete the copies.
1An expert asksThe platform's Clock bean has @ConditionalOnMissingBean.
Why does a service's own Clock bean always win, whatever the order of the service's
code?
Clock bean has @ConditionalOnMissingBean.
Why does a service's own Clock bean always win, whatever the order of the service's
code?Clock already exists, and the
platform's bean is not created. In .NET, TryAddSingleton backs off only if the
service registered its own bean before calling the library.2An expert asksWhy does a shared domain library tie the release cycles together, when a
shared OpenAPI file or event schema does not?
3An expert asksThe platform's Clock bean carries @ConditionalOnMissingBean,
but its bean platformMdcWebMvcConfigurer does not. Why?
Clock bean carries @ConditionalOnMissingBean,
but its bean platformMdcWebMvcConfigurer does not. Why?WebMvcConfigurer beans of its own, and Spring MVC calls
each one. With the condition, any configurer of the service would switch off the platform's MDC
interceptor by accident. A bean that is one of many has no such condition.4Why is PlatformDefaultsEnvironmentPostProcessor registered in
spring.factories, and not in the imports file?
PlatformDefaultsEnvironmentPostProcessor registered in
spring.factories, and not in the imports file?org.springframework.boot.EnvironmentPostProcessor in
META-INF/spring.factories.5TranslateWrite this .NET registration as a bean of the platform's core
auto-configuration. A service may replace it with its own:
services.TryAddSingleton(TimeProvider.System);
services.TryAddSingleton(TimeProvider.System);@Bean
@ConditionalOnMissingBean
Clock clock() {
return Clock.systemUTC();
}@ConditionalOnMissingBean with no type takes the return type, Clock.
The class that holds the method is listed in the imports file, so no service calls it.
Quick reference#
The platform's settings, from its README, as of October 2026:
| Property | Default | Meaning |
|---|---|---|
| platform.logging.structured | true | JSON (ECS) console logs, and set it to false locally |
| platform.logging.mdc-path-variables | none | the path variables copied into the MDC |
| platform.security.public-paths | /actuator/health/**, /actuator/info | the paths that need no token, which are the health probes by default |
| platform.resilience.retry.max-attempts | 3 | the calls in total, so up to 2 retries |
| platform.resilience.retry.initial-interval | 200ms | the first wait, and later waits grow, with random spread |
| platform.resilience.retry.multiplier | 2.0 | how much each wait grows, compared with the one before it |
| platform.resilience.circuit-breaker.failure-rate-threshold | 50 | the failure rate, in percent, that opens the breaker |
| platform.resilience.circuit-breaker.sliding-window-size | 20 | the number of calls that the rate is computed over |
| platform.resilience.circuit-breaker.minimum-number-of-calls | 10 | no rate is computed before this many calls |
| platform.resilience.circuit-breaker.wait-duration-in-open-state | 30s | how long the breaker stays open |
| clients.<name>.base-url | none | the base URL of one HTTP client |
| clients.<name>.connect-timeout | 2s | the connect timeout of one HTTP client |
| clients.<name>.read-timeout | 5s | the read timeout of one HTTP client |
| clients.<name>.token-registration-id | none | the client registration whose token each call carries |
| platform.mongo.documentdb.ca-bundle | none | the AWS certificate bundle for TLS |
| platform.mongo.migrations.enabled | true | runs the migrations at startup, and tests switch it off |
| platform.mongo.migrations.lock-ttl | PT15M | how long a migration lock lasts, longer than the slowest migration |
| platform.messaging.enabled | true | switches the outbox and the inbox on or off together |
| platform.messaging.outbox.resend-enabled | true | the scheduled resender, and scheduling with it |
| platform.messaging.outbox.resend-interval | PT1M | how often the resender looks for events that were not sent |
| platform.messaging.outbox.resend-older-than | PT5M | only events older than this are resent |
| platform.messaging.outbox.lock-at-most-for | PT5M | the longest time one instance holds the resender lock |
| spring.modulith.events.staleness.resubmitted | PT15M | a row stuck in RESUBMITTED for this long goes back to FAILED, so keep it longer than lock-at-most-for |
| platform.messaging.inbox.retention | P14D | how long processed event IDs are kept, with a TTL index |
Testing in depth#
The order service has eleven kinds of test. Most of them run in milliseconds, with no Spring and no Docker. A few start only the web layer of Spring. One starts the whole service with nothing to connect to. Three need Docker. This chapter shows each kind, who owns it, what it proves and what it costs. It ends with the architecture rules that keep the design in place.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | a test project for each layer, by habit | eleven kinds of test, each with an owner and a cost | each kind proves one thing, and most kinds need no Spring and no Docker Why |
| twist | [Trait("Category", "Integration")] with dotnet test --filter | a class name that ends in Test, or in IT for a test that needs Docker | mvn verify runs every *Test class, and mvn verify -Pit adds every *IT class Why |
| new | a domain test that starts a host | a plain JUnit test of the aggregate, with no Spring and no mocks | if a domain test needs Spring, the code under test is in the wrong place Why |
| twist | a TimeProvider passed to the code | Clock.fixed(NOW, ZoneOffset.UTC) in TestData | every time in a test is known, and each test starts from an aggregate with no events Why |
| twist | Moq, Verify and Times.Never() | Mockito, verify, never() and verifyNoInteractions | the use case runs with mocks of its ports and the real domain, and proves that nothing was saved Why |
| twist | MockSequence with strict mocks | InOrder and ArgumentCaptor | the test checks the order of the steps, and reads the ID that the use case chose Why |
| new | no equivalent | a real TransactionTemplate with NoOpTransactionManager | the transactional lambda runs in a unit test, and nothing touches a database Why |
| twist | WebApplicationFactory with ConfigureTestServices | @PlatformWebMvcTest(OrderController.class) | one annotation starts Spring MVC with the platform's security and error handling Why |
| trap | no equivalent | a plain @WebMvcTest in a service that uses the platform | the platform's error handler is missing, so a missing order throws in the test instead of answering 404 Why |
| twist | assertions on an HttpResponseMessage | MockMvc with status(), header() and jsonPath() | one chain of calls checks the status, the headers and the problem body Why |
| twist | a test authentication handler | jwt() from spring-security-test | it puts a caller into the security context, and never calls the token decoder Why |
| twist | WireMock.Net in a client test | a JDK HttpServer on port 0, with fast retries | the real client, its JSON, its token and its retries run, and no retry waits 200 ms Why |
| twist | a unit test of a message handler | a mapper test, and a listener test with an inbox that passes the event through | the listener only translates, so its test replaces the inbox with an answer that calls the handler Why |
| trap | no equivalent | new MongoDBContainer(image) in Testcontainers 2 | the new class starts no replica set until withReplicaSet() is called, so every transaction fails Why |
| twist | a repository test against a container | @PlatformDataMongoTest with @Import(adapter, mapper) | the persistence slice with the platform's MongoDB setup, named *IT because it needs Docker Why |
| new | no equivalent | one test of optimistic locking for each aggregate | each aggregate proves that a stale copy is refused, in the style that its feature uses Why |
| new | WebApplicationFactory<Program>, which starts the host | ApplicationWiringTest, with nothing to connect to | every bean and setting is checked in seconds, with no Docker Why |
| twist | WebApplicationFactory<Program> with containers | OrderJourneyIT, with MongoDB and Floci in Docker | one journey runs through the whole service, and the test checks the audit log and the outbox too Why |
| twist | no equivalent | OpenApiContractTest | the build fails when the committed contract differs from the code Why |
| twist | ArchUnitNET | ArchitectureRules, in the platform's test package | the design rules fail the build, and every service calls the same rules Why |
| new | no equivalent | a custom ArchCondition | it states a rule that the fluent sentences cannot state, such as "features meet only through services" Why |
| new | no equivalent | the guardrail rules | five rules catch the mistakes that a review misses most often Why |
| twist | no equivalent | FreezingArchRule.freeze(rule) | an existing service records its old violations, and only new ones fail the build Why |
| new | no equivalent | architecture rules that are optional or required | if a team adopts only one rule, it is "a domain package is plain Java" Why |
| twist | unit tests of a shared NuGet package | the platform library's own tests | the library tests its code with small classes declared inside each test Why |
| new | no equivalent | what to test first in an older service | start with the tests that cost least and protect the most, then add more where you change code Why |
| same | Playwright tests owned by QA | Playwright tests owned by QA | end-to-end tests across services belong to QA, and run against the local environment |
The kinds of test, who owns each, and what each costsnew#
A developer changes the shipping rule and runs the tests. If every test starts the whole service and a database, the run takes minutes. The developer stops running the tests before each commit. So the service splits its tests into kinds. Each kind proves one thing, and starts only what that one thing needs.
The basics of each tool are taught elsewhere. For JUnit, Mockito and Testcontainers, see the
chapter Testing. For test slices and @SpringBootTest, see the
chapter Testing Spring Boot. This chapter shows how the order
service combines them.
These are the kinds. The time is what the test reports gave for each class in one run of the companion on 25 October 2026. Read the times as an order of size, not as a promise:
| Kind | Example | What starts | Owner | Time for one class | Command |
|---|---|---|---|---|---|
| domain unit | OrderTest, 15 tests | nothing | the feature's developer | 0.05 s | mvn verify |
| application unit | OrderServiceTest, 7 tests | nothing, with Mockito mocks | the feature's developer | 0.5 s | mvn verify |
| API slice | OrderControllerTest, 23 tests | the web layer and the platform's web beans | the feature's developer | 6.1 s | mvn verify |
| HTTP client | StockAdapterTest, 6 tests | an HTTP server from the Java library | the developer who adds the client | 0.4 s | mvn verify |
| messaging unit | PaymentReceivedListenerTest, 1 test | nothing | the feature's developer | 0.05 s | mvn verify |
| mapping | NoteMappingTest, 2 tests | the MongoDB converter, with no database | the feature's developer | 0.2 s | mvn verify |
| wiring | ApplicationWiringTest, 2 tests | the whole service, connected to nothing | the service team | 4.0 s | mvn verify |
| contract | OpenApiContractTest, 1 test, and PublishedEventSchemaTest, 5 tests | the whole service for the API, and only the JSON mapper for the events | the service team | 2.0 s and 0.5 s | mvn verify |
| architecture | ArchitectureTest, 10 tests | nothing, ArchUnit reads the compiled classes | the platform team writes the rules, and each service calls them | 1.0 s | mvn verify |
| persistence | OrderPersistenceAdapterIT, 6 tests | MongoDB in Docker | the feature's developer | 9.7 s | mvn verify -Pit |
| journey | OrderJourneyIT, 7 tests | the whole service, MongoDB and Floci in Docker | the service team | 16.6 s | mvn verify -Pit |
| end-to-end | Playwright tests across services | every service and the local environment | the QA team, in its own repository | not measured here | QA's own command |
In that run, the 112 tests of mvn verify took about 18 seconds in total. The
19 tests that need Docker took about 38 seconds more. The domain and application tests are the
largest group, and they are the fastest. That is on purpose. A rule that is tested in
milliseconds is tested on every change.
The last row belongs to another team. A Playwright test drives the shop's web pages, and the pages call several services. Such a test proves that the services work together. It does not belong in the repository of one service, because no single service owns it. QA runs it against the shared local environment. For that environment, see the chapter Running locally and shipping.
Each test class of the order feature is in the same package as the code it tests:
src/test/java/com/acme/orderservice/order/
domain/model/OrderTest.java domain unit
domain/service/ShippingPolicyTest.java domain unit
application/OrderServiceTest.java application unit
api/OrderControllerTest.java API slice
client/StockAdapterTest.java HTTP client
messaging/PaymentReceivedListenerTest.java messaging unit
persistence/OrderPersistenceAdapterIT.java persistence, needs DockerThe folder src/test/java mirrors src/main/java. A test in the same
package can call package-private code, so the classes under test need not be public.
In ASP.NET Core, a team often makes one test project for each layer, such as
Orders.Domain.Tests and Orders.Api.Tests. Maven has one test folder
for each module. So the order service puts every kind in src/test/java, in the
package of the code under test. The kind shows in the
class name and in what the class starts, not in a project name.
*Test and *IT: the name decides when a test runstwist#
Every test class of the order service ends in Test or in IT. The
suffix decides which command runs the class. The shop's parent
Project Object Model (POM) tells the surefire plugin to skip
every *IT class:
<!-- mvn verify runs the fast tests only: *Test.java. The *IT.java tests need Docker. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar}</argLine>
<excludes>
<exclude>**/*IT.java</exclude>
</excludes>
</configuration>
</plugin>So mvn verify runs every *Test class, and needs no Docker. A
developer runs it before every commit. mvn verify -Pit also runs every
*IT class, through the failsafe plugin in the profile it. The build
server runs it on every pull request. A class with another name, such as
OrderCheck, never runs, and nothing warns you. The build chapter owns this topic.
For the trap and the plugins, see the section The name of a test class
decides whether it runs. For the two commands, see the section Fast
tests and integration tests.
The same parent POM gives every service its test libraries, with the scope
test. They are JUnit, AssertJ, Mockito, the Spring test slices, spring-security-test,
Testcontainers, ArchUnit and Awaitility. A service declares none of them. See the section
Test libraries for every service, from the parent.
In .NET, a test can carry [Trait("Category", "Integration")], and the build filters
on it with dotnet test --filter. In Maven, the class name is the filter.
Domain tests: plain JUnit, no Spring, no mocksnew#
The business says that an order ships only when it is paid and every line is picked. That
rule is in the Order aggregate, so the test of the rule needs only an
Order. It needs no database, no Spring and no mock. The class comment of the test
says so:
/**
* The order's rules, in plain JUnit: no Spring, no mocks, a few milliseconds. If a test here
* needed Spring, the code under test would be in the wrong place.
*/
class OrderTest {
// ...
@Nested
class Shipping {
@Test
void shipsAPaidOrderWithEveryLinePicked() {
Order order = readyToShip();
order.ship(CLOCK);
assertThat(order.status()).isEqualTo(OrderStatus.SHIPPED);
assertThat(order.shippedAt()).isEqualTo(NOW);
assertThat(order.pullEvents()).containsExactly(new OrderShipped(order.id(), NOW));
}
@Test
void refusesAnUnpaidOrder() {
assertThatThrownBy(() -> newOrder().ship(CLOCK)).isInstanceOf(OrderNotPaidException.class);
}@NestedJUnit runs the tests of an inner class as a group. The report shows
OrderTest, thenShipping, then each test. The test class has five groups, one for each behaviour. They are placing, picking, shipping, cancelling and payments. A group is named after a behaviour, not after a method.Order order = readyToShip();This method comes from the shared test data, with a static import. It returns an order that is paid and fully picked. The next section shows it.
assertThat(order.pullEvents()).containsExactly(new OrderShipped(order.id(), NOW));The test checks the event too.
pullEvents()returns the events that the order raised, and clears them.containsExactlypasses only for exactly this list, in this order. The event is a record, so it compares by value.assertThatThrownBy(() -> newOrder().ship(CLOCK)).isInstanceOf(OrderNotPaidException.class);This AssertJ check runs the lambda and expects it to throw this type.
The domain tests use a small set of AssertJ checks again and again. Each one reads as a sentence:
| AssertJ check | What it checks | Where the service uses it |
|---|---|---|
| assertThat(x).isEqualTo(y) | equal by equals | almost every test |
| assertThatThrownBy(() -> ...).isInstanceOf(T.class) | the code throws this type | every rule that refuses a change |
| .hasMessage("...") or .hasMessageContaining("...") | the exception's message, whole or in part | ValueObjectTest, for the text of each refusal |
| assertThatCode(() -> ...).doesNotThrowAnyException() | the code throws nothing | ShippingPolicyTest, the allowed case |
| containsExactly(a, b) | exactly these elements, in this order | the raised events |
| singleElement().satisfies(x -> ...) | exactly one element, which passes the checks inside | the one line of a new order |
| extracting(Order::id) | a list of one value from each element | the page of orders in the persistence test |
| isInstanceOfSatisfying(T.class, x -> ...) | this type, then the checks inside on it | the mapper test of the published events |
| hasToString("...") | the result of toString() | CustomerContact hides its personal data |
| hasValue(3) | the value of an AtomicInteger | counting the calls in the client test |
Two other domain tests follow the same style. ValueObjectTest checks
Money, CustomerContact and the codes. ShippingPolicyTest
checks the domain service with the rule across an order and its notes. The light-style
NoteTest is plain JUnit too, because the note entity still holds its own rules.
For the levels of Domain-Driven Design (DDD), see the chapter How much DDD: three levels in one
service.
The class comment of OrderTest states a rule about design. If a domain test
needs Spring, the code under test is in the wrong place. Suppose a test of the shipping rule
needs a Spring context. Then the rule depends on a bean, a
setting or a repository. That dependency belongs in the application service, which passes plain
values to the domain. So the test becomes the first warning, before any review. The
architecture rule domainIsPlainJava gives the same warning in the build. See the
section The architecture rules in the platform's test package.
The careful version of the other view deserves a fair answer. Some teams test their rules
through the whole application, with @SpringBootTest. They say that such a test
also proves the wiring, the settings and the mapping. That is true. But it starts a context for
each set of settings, it needs a database, and a failure can be in any layer. The order service
proves the wiring once, in the wiring test, and the rules hundreds of times, in milliseconds.
Use a full test for a rule only where the rule needs the database, such as a unique index.
A light-style entity is also the stored document. So the order service adds a test of how
Spring Data stores it, still without a database. NoteMappingTest builds the same
converter that MongoTemplate uses, and turns a note into a document and back:
@Test
void theRaisedEventsAreNotStored() {
Note note = Note.create(UUID.randomUUID().toString(), "Call back", "DELIVERY", true, "agent-1", CLOCK); // has an event
Document stored = new Document();
converter.write(note, stored);
assertThat(stored).containsKeys("_id", "orderId", "text", "category", "actionRequired", "createdAt", "createdBy")
.doesNotContainKey("events");
}converter.write fills a Document from the BSON library, so the
test sees exactly what would be stored. The check proves that the list of raised events is not
stored. For how the converter maps a document, see the chapter Storing
aggregates in DocumentDB.
Shared test data with a fixed clocktwist#
public static class TestData
{
public static readonly DateTimeOffset Now = new(2026, 1, 15, 9, 0, 0, TimeSpan.Zero);
public static readonly TimeProvider Clock = new FixedTimeProvider(Now);
}
public sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}/** Shared values for tests, with a fixed clock so every time is predictable. */
public final class TestData {
public static final Instant NOW = Instant.parse("2026-01-15T09:00:00Z");
public static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
public static final CustomerContact CUSTOMER = new CustomerContact("Jo Bloggs", "jo@example.com");
public static final DeliveryMethod STANDARD = new DeliveryMethod("STANDARD");
public static final List<OrderItem> TWO_MUGS = List.of(new OrderItem(new ProductCode("MUG-001"), 2));
private TestData() {
}
/** A new order with no raised events, so each test starts clean. */
public static Order newOrder() {
Order order = Order.place(OrderId.newId(), CUSTOMER, STANDARD, TWO_MUGS, StockAvailability.allAvailable(), CLOCK);
order.pullEvents();
return order;
}public static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);Clockcomes fromjava.time. A fixed clock always returns the same instant. The domain receives the clock as a parameter, such asorder.ship(CLOCK), so every time that a test sees isNOW.TimeProviderhas the same purpose in .NET 8 and later.private TestData() {The private constructor stops anyone from creating an object of this class. The class is a holder for static members, like a
static classin C#.order.pullEvents();Placing an order raises
OrderPlaced. The helper pulls that event, so a test of shipping sees only the events that shipping raises.
The C# pane shows the same idea with a TimeProvider of its own. The two
differ in one way. Java has a fixed clock in the Java Development Kit (JDK), so the test needs no class of its own.
The helper readyToShip() builds on newOrder(). It records a payment
and picks every line, then pulls the events. One helper per state keeps the tests short, and
each helper goes through the real methods of the aggregate. A test never builds an order in a
state that the rules would not allow.
Application service tests with Mockitotwist#
An application service decides the order of the steps. It loads, lets the domain decide,
saves, publishes and records the audit. Its test runs those steps with the real domain and with
a Mockito mock of each port. No Spring context starts.
The test creates the service with new:
private final OrderRepository orders = mock(OrderRepository.class);
private final NoteService notes = mock(NoteService.class);
private final ReferenceDataService referenceData = mock(ReferenceDataService.class);
private final StockChecker stock = mock(StockChecker.class);
private final DomainEventPublisher events = mock(DomainEventPublisher.class);
private final AuditLog audit = mock(AuditLog.class);
private final OrderService service = new OrderService(orders, notes, referenceData, stock, new ShippingPolicy(),
events, audit, new TransactionTemplate(new NoOpTransactionManager()), CLOCK);mock(OrderRepository.class)This call creates a mock of the repository port. An unstubbed method of a mock does nothing, and returns null, an empty value or zero. For the basics, see the section Mocking.
new ShippingPolicy()The domain service is real, not a mock. The test then checks the steps together with the real rule.
new TransactionTemplate(new NoOpTransactionManager())A real transaction template runs the transactional part, with a transaction manager that does nothing. See the section A transaction manager that does nothing.
The constructor of OrderService takes every dependency as a parameter, so a test
can create the service with new. This works because the service uses constructor injection. A service that
read its dependencies from fields filled by Spring could not be built this way.
The most useful application test checks what does not happen. When the stock service refuses, nothing may be saved, published or audited:
[Fact]
public void SavesNothingWhenTheStockIsNotAvailable()
{
stock.Setup(s => s.Reserve(It.IsAny<OrderId>(), It.IsAny<IReadOnlyList<OrderItem>>()))
.Returns(StockAvailability.Unavailable(["MUG-001"]));
var service = new OrderService(orders.Object, stock.Object, notes.Object, events.Object);
Assert.Throws<ProductsUnavailableException>(() => service.Place(TwoMugs));
orders.Verify(o => o.Save(It.IsAny<Order>()), Times.Never());
notes.VerifyNoOtherCalls();
events.VerifyNoOtherCalls();
}@Test
void savesNothingWhenTheStockIsNotAvailable() {
when(stock.reserve(any(), any())).thenReturn(StockAvailability.unavailable(List.of(new ProductCode("MUG-001"))));
assertThatThrownBy(() -> service.place(CUSTOMER, STANDARD, TWO_MUGS, firstNotes))
.isInstanceOf(ProductsUnavailableException.class);
verify(orders, never()).save(any());
verifyNoInteractions(notes, events, audit);
}when(stock.reserve(any(), any())).thenReturn(when(...).thenReturn(...)stubs a method, likeSetup(...).Returns(...)in Moq.any()matches every argument, likeIt.IsAny<T>(). Mockito needs no lambda around the call, because the mock records the call itself.verify(orders, never()).save(any());This check fails if
savewas called at all. It does the same asTimes.Never()in Moq.verifyNoInteractions(notes, events, audit);This check fails if any method of these three mocks was called. The closest match in Moq is
VerifyNoOtherCalls(), which fails on any call that the test did not verify.
The C# pane compiled, and its test passed, with .NET 10, Moq 4.20.72, xUnit 2.9.3 and small
stand-in types. Mockito has no .Object property, because the mock is the object.
A mock can also throw. doThrow(...).when(mock).method(...) stubs a method that
returns nothing. The test checksTheDeliveryMethodBeforeCallingTheStockService uses
it to make the lookup check fail. Then it expects verifyNoInteractions(stock). So
the service never calls another service for an order that it would refuse anyway.
A mocked repository has no database behind it. But save must return the saved
copy with its next version, because the use case continues with that copy. So the test gives
the mock an answer that behaves like the real adapter:
/** Makes the mocked repository behave like the real one: save returns a stored copy with the next version. */
private void saveReturnsTheStoredCopy() {
when(orders.save(any())).thenAnswer(call -> {
Order order = call.getArgument(0);
long next = order.version() == null ? 0 : order.version() + 1;
return Order.rehydrate(order.id(), next, order.customer(), order.deliveryMethod(), order.placedAt(),
order.lines(), order.status(), order.lastPayment(), order.shippedAt(), order.cancelledAt());
});
}thenAnswer runs the lambda for each call, like Returns with a
function in Moq. call.getArgument(0) is the order that the service passed. For why
save returns a new copy, see the chapter Storing aggregates in
DocumentDB.
The light-style NoteService uses a Spring Data repository directly. Its test
mocks that interface in the same way, because a Spring Data repository is an ordinary Java
interface. One test of the note service also shows a small Java detail:
@Test
void listingTheNotesOfAnUnknownOrderIsNotFound() {
// NotFoundException is non-sealed with a protected constructor, so a test can subclass it.
doThrow(new NotFoundException("ORDER_NOT_FOUND", "Order not found") {
}).when(orders).requireExists(ORDER_ID);The constructor of the platform's NotFoundException is protected, and the test
is in another package. So the test cannot call the constructor directly, but a subclass can. The
empty braces after the constructor call create an anonymous subclass. An anonymous class is a
class with no name, which the expression declares on the spot. For another use of it, see the
section Getting type information back.
Another test of the note service records a decision, not a rule.
aNoteNeedingActionCanStillBeAddedAfterTheOrderShipped proves that the shipping rule
holds only at the moment of shipping. Its comment says that the team accepted this gap, and
names the README section that explains it. A test like this keeps a known gap visible. If
someone closes the gap later, the test fails, and the decision is reviewed.
Checking the order of the stepstwist#
Placing an order has six steps, and their order matters. The lookup check comes first, the
stock service second, and the notes last. The test checks the order with an
InOrder object, and reads the order ID with an ArgumentCaptor:
PlacedOrder placed = service.place(CUSTOMER, STANDARD, TWO_MUGS, firstNotes);
ArgumentCaptor<OrderId> reservedFor = ArgumentCaptor.forClass(OrderId.class);
ArgumentCaptor<Order> saved = ArgumentCaptor.forClass(Order.class);
InOrder order = inOrder(referenceData, stock, orders, events, audit, notes);
order.verify(referenceData).requireActive("DELIVERY_METHOD", "STANDARD", "deliveryMethod");
order.verify(stock).reserve(reservedFor.capture(), eq(TWO_MUGS)); // the other service first
order.verify(orders).save(saved.capture()); // then the order
order.verify(events).publish(List.of(new OrderPlaced(reservedFor.getValue(), STANDARD, TWO_MUGS, NOW)));
order.verify(audit).record("ORDER_PLACED", "order", reservedFor.getValue().value());
order.verify(notes).addNotes(reservedFor.getValue().value(), firstNotes); // then its notes
assertThat(saved.getValue().id()).isEqualTo(reservedFor.getValue()); // the reserved ID is the order's IDInOrder order = inOrder(referenceData, stock, orders, events, audit, notes);An
InOrderobject checks calls across these mocks in the order of theverifylines. A call that happened in another order fails the test.order.verify(stock).reserve(reservedFor.capture(), eq(TWO_MUGS));capture()records the argument, which here is the order ID that the service chose.eq(...)matches an argument that equalsTWO_MUGS.assertThat(saved.getValue().id()).isEqualTo(reservedFor.getValue());The service chose the ID before it called the stock service, because the ID is the idempotency key of the reservation. This line proves that the saved order has the same ID.
The order is the application layer's job, so this is the right place to test it. The domain test cannot see the stock service, and the journey test sees only the result. For why the stock call comes before the transaction, see the chapter Use cases: the application service.
Moq checks an order with a MockSequence, and the mocks must be strict for that.
A strict mock throws on every call that has no setup. Mockito checks the order after the calls,
with ordinary mocks. For captors and strict mocks in general, see the section
Capturing an argument, and strict mocks.
A transaction manager that does nothingnew#
OrderService.place runs part of its work in a transaction. It calls the stock
service first, and only then starts the transaction, with a TransactionTemplate. A
unit test has no database. But the lambda inside the template holds the steps that the test
must check. The platform's test package solves this with a transaction manager that does
nothing:
public class NoOpTransactionManager extends AbstractPlatformTransactionManager {
@Override
protected Object doGetTransaction() {
return new Object();
}
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
// Nothing to begin.
}
@Override
protected void doCommit(DefaultTransactionStatus status) {
// Nothing to commit.
}
@Override
protected void doRollback(DefaultTransactionStatus status) {
// Nothing to roll back.
}
}extends AbstractPlatformTransactionManager {Spring's base class does the bookkeeping of every transaction manager. It tracks whether a transaction is active, joins an existing one, and calls the four methods below at the right moments. A subclass only says how to begin, commit and roll back.
return new Object();The base class needs an object that stands for the transaction. Any object works, because nothing reads it.
The test passes new TransactionTemplate(new NoOpTransactionManager()) to the
service. The template runs the lambda, and the steps inside run in order. The class comment
adds that Spring then reports an active transaction. So code that refuses to run outside a
transaction, such as the platform's outbox publisher, runs in the test too.
The other way is to mock the TransactionTemplate. The careful version stubs
execute with an answer that calls the callback, and that works. It costs a stub in
every test class, and the stub must copy what the template does. The do-nothing manager keeps
the real template, so the test runs the code that production runs, minus the database.
The methods with @Transactional, such as ship, need nothing at all.
The test creates the service with new, so no
proxy wraps it, and the annotation is ignored. For why a
proxy is needed, see the chapter How Spring actually
works. That is fine for a test of the steps. Whether the transaction really rolls back is
tested in the journey test, against a real database.
API slices through the platform's own annotationtwist#
A controller test should run the real security, the real validation and the real error
responses. It should not start the database or the stock client. Spring Boot's
@WebMvcTest starts only Spring model-view-controller (MVC), which is the web layer of Spring. The platform adds its own web beans to that
slice with a composed annotation:
/**
* A web slice test with the platform's security, error handling and MDC interceptor, the same
* as in production. Only the controllers and Spring MVC start, so the test runs in about a
* second.
*
* <pre>{@code
* @PlatformWebMvcTest(OrderController.class)
* class OrderControllerTest { ... }
* }</pre>
*
* <p>{@code @WebMvcTest} loads no auto-configuration from libraries, so this annotation imports
* the platform's web-related ones by name.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@WebMvcTest
@ImportAutoConfiguration({
PlatformCoreAutoConfiguration.class,
PlatformWebAutoConfiguration.class,
PlatformSecurityAutoConfiguration.class,
PlatformMongoWebAutoConfiguration.class})
@Import(TestJwtDecoderConfiguration.class)
public @interface PlatformWebMvcTest {
/** The controllers to test. */
@AliasFor(annotation = WebMvcTest.class, attribute = "controllers")
Class<?>[] value() default {};
}@WebMvcTestThe new annotation carries
@WebMvcTest. So a class with@PlatformWebMvcTestis a web slice, as if it carried@WebMvcTestitself. An annotation on an annotation is called a meta-annotation.@ImportAutoConfiguration({This annotation adds the listed auto-configurations to the slice. A slice starts only a fixed set of auto-configurations, so the platform's security chain, error handler and log interceptor must be named here.
PlatformMongoWebAutoConfiguration.class})This one holds the web part of the platform's MongoDB support. It answers a stale version with 409. It needs no database.
@Import(TestJwtDecoderConfiguration.class)This adds a token decoder that refuses every real token. For why the slice needs it, see the section Testing security in a controller slice.
@AliasFor(annotation = WebMvcTest.class, attribute = "controllers")@AliasForpasses the value of this attribute to@WebMvcTest. So@PlatformWebMvcTest(OrderController.class)means@WebMvcTest(controllers = OrderController.class). Without the alias, Spring would not know thatvaluebelongs to the inner annotation.Class<?>[] value() default {};An attribute named
valueis special in Java. It is the one attribute that can be written without its name, as in@PlatformWebMvcTest(OrderController.class).
A run for this book built the same kind of annotation and checked it. The slice loaded only
the named controller. A path of another controller answered 404. A
@MockitoBean replaced the service, and the beans of the imported
auto-configuration were present. For @Target, @Retention and
@Documented, see the section Permission annotations:
each scope string written once.
A controller test uses the annotation with the controller, its mapper and a mock of the use case:
@PlatformWebMvcTest(OrderController.class)
@Import(OrderApiMapper.class)
class OrderControllerTest {
// ...
@Autowired
MockMvc mvc;
@MockitoBean
OrderService orders;@Import(OrderApiMapper.class)The slice creates only controllers and web beans. The mapper is a plain
@Component, so the test adds it by name. The real mapper runs, so the test also checks the translation between the request classes and the use case.@MockitoBeanThis puts a Mockito mock of
OrderServiceinto the context, in place of a real bean. It comes from Spring Framework, and it replaces Boot's old@MockBean. See the section Boot 4: @MockBean becomes @MockitoBean.
A controller written as a static nested class slips past the slice's
filter. In a run for this book, two controllers were nested classes of the
@SpringBootApplication class. @WebMvcTest(OrderController.class) then
loaded the other controller too. Spring registers the nested classes of a configuration class
directly, so the slice's filter never sees them. Keep every controller in a top-level class.
In ASP.NET Core, the closest match is a subclass of WebApplicationFactory that
replaces services, and that every test class shares:
public class ShopApiFactory : WebApplicationFactory<Program>
{
public Mock<IOrderService> Orders { get; } = new();
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.ConfigureTestServices(services => services.AddSingleton(Orders.Object));
}This factory compiled with .NET 10 and Microsoft.AspNetCore.Mvc.Testing
10.0.12. ConfigureTestServices replaces one service, and the factory starts the
whole host, with every service except the replaced one. The Spring
slice starts less. It creates no use case, no repository and no client. So a forgotten mock
fails at startup with a missing bean, not with a call to a real database.
Why a plain @WebMvcTest is not enoughtrap#
A developer new to the platform writes @WebMvcTest(OrderController.class), as a
Spring tutorial shows. The test compiles, and the context starts. A run for this book tried it
in a copy of the order service. The test asked for an order that the mocked service reported
as missing:
| What the run looked at | With a plain @WebMvcTest |
|---|---|
| the security chains in the context | Boot's oauth2SecurityFilterChain, not the platform's chain |
| the controller advices in the context | none |
| GET /orders/{id} for a missing order | perform threw a ServletException, caused by OrderNotFoundException |
Without the platform's beans, an exception never becomes a problem
response. The platform's exception handler turns OrderNotFoundException
into a 404 with the code ORDER_NOT_FOUND. A plain slice has no such handler. So
MockMvc passes the exception to the test, and the test fails with an error, not with a wrong
status. The security in the test is also the chain of Spring Boot, not the chain that runs
in production. Use @PlatformWebMvcTest for every controller test.
With the platform's annotation, the same request gets the answer that production gives:
@Test
void aMissingOrderIsA404WithItsCode() throws Exception {
OrderId id = OrderId.of(ORDER_ID);
when(orders.get(id)).thenThrow(new OrderNotFoundException(id));
mvc.perform(get("/orders/" + ORDER_ID).with(AGENT))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("ORDER_NOT_FOUND"));
}The mock throws OrderNotFoundException, and the platform's handler answers 404
with the code ORDER_NOT_FOUND. This is the reason the platform ships the annotation. A slice tests the HTTP contract, and
the error responses are part of that contract. The 404, 409, 422 and 503 answers are all written
by the platform. A slice without them tests a contract that does not exist.
A second run measured the damage. It removed only @ImportAutoConfiguration from the
platform's annotation, and ran OrderControllerTest. At that time the class had 22
tests, and 18 of them failed. Eleven were in the nested class Security, and seven in
TraceId. With the import back, all 22 passed.
MockMvc and JSONPath: checking the HTTP contracttwist#
A MockMvc test sends a request through Spring MVC without a server, and checks the response. The controller test checks each kind of error. Here an invalid body must list every field that failed:
@Test
void invalidFieldsAreAllListed() throws Exception {
mvc.perform(post("/orders").with(AGENT).contentType(MediaType.APPLICATION_JSON).content("""
{ "customer": { "name": "", "email": "not-an-email" }, "deliveryMethod": "STANDARD", "lines": [] }
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.title").value("Invalid request"))
.andExpect(jsonPath("$.errors.length()").value(3))
.andExpect(jsonPath("$.errors[?(@.field == 'customer.name')]").exists())
.andExpect(jsonPath("$.errors[?(@.field == 'customer.email')]").exists())
.andExpect(jsonPath("$.errors[?(@.field == 'lines')]").exists());
}mvc.perform(post("/orders").with(AGENT).contentType(MediaType.APPLICATION_JSON).content("""postbuilds the request.with(AGENT)adds a caller with both scopes. The body is a text block, which starts and ends with three double quotes. A text block keeps a JSON body readable, with no escaped quotes. For text blocks, see the section Text blocks..andExpect(status().isBadRequest())Each
andExpectadds one check. The checks come from the classMockMvcResultMatchers, with static imports..andExpect(jsonPath("$.errors.length()").value(3))JSONPath is a small language for finding values in JSON.
$is the whole document, andlength()counts the elements of the list..andExpect(jsonPath("$.errors[?(@.field == 'customer.name')]").exists())The part in square brackets is a filter. It keeps the elements whose
fieldiscustomer.name, and@is the element being tested. So the check does not depend on the order of the errors.
The test class has one test for each answer that the platform writes. Together they fix the error contract of the API:
| Test | The answer it expects |
|---|---|
| invalidFieldsAreAllListed | 400, with every invalid field in errors |
| anInvalidIdInThePathIsA400ThatNamesTheField | 400, with the field orderId |
| aMissingOrderIsA404WithItsCode | 404, with the code ORDER_NOT_FOUND |
| aBrokenBusinessRuleIsA422WithItsCode | 422, with the code ORDER_NOT_PAID |
| aStaleVersionIsA409 | 409, with the code CONCURRENT_UPDATE |
| aWriteConflictInsideATransactionIsAlsoA409 | 409, with the same code, for MongoDB's write conflict |
| anUnavailableDependencyIsA503 | 503, when the stock client reports the service as unavailable |
| thePageSizeIsLimited | 400, with the field size, for a page of 1,000 |
| aRequestWithoutATokenIsA401ProblemBeforeAnyController | 401, with the WWW-Authenticate header |
| aCallerWithoutTheWriteScopeIsA403AndNothingRuns | 403, and the use case is never called |
| anUnexpectedErrorIsA500WithoutItsMessage | 500, with a fixed text, and never the message of the exception |
The last test in the table guards against a leak. Any exception that the platform does not know is a bug, and its message may hold a secret. So the test throws an exception with a secret in its message, and checks that the body does not contain it:
/** Any other exception is a bug. The answer is a fixed text, and never the exception's message. */
@Test
void anUnexpectedErrorIsA500WithoutItsMessage() throws Exception {
doThrow(new IllegalStateException("secret: the database password is hunter2")).when(orders).ship(any());
mvc.perform(post("/orders/" + ORDER_ID + "/ship").with(AGENT))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.title").value("Internal error"))
.andExpect(jsonPath("$.detail").value("Something went wrong. Quote the traceId if you contact support."))
.andExpect(content().string(not(containsString("hunter2"))));
}content().string(...) checks the whole body as text. not and
containsString are matchers from the Hamcrest library, with static imports from
org.hamcrest.Matchers. Together they pass only if the secret is absent. The detail
asks the caller for the traceId, so support can find the full error in the logs.
A nested class named TraceId checks one more promise. Every problem carries the
traceId, even the problems that Spring MVC creates itself, such as a 405. In a
slice, no tracing runs. So the test puts a trace ID into the log context before each test, with
MDC.put("traceId", "trace-1"), and removes it after. For the log context, see the
chapter Logging, tracing and health.
The journey test needs a value from one response for the next request. It reads the value
with Jayway's JsonPath.read:
String orderId = JsonPath.read(placed, "$.orderId");
String noteId = JsonPath.read(placed, "$.noteIds[0]");placed is the body of the response, as a string. JsonPath.read
returns the value at the path, with the type of the variable. In C#, the closest match is
JsonDocument.Parse(body).RootElement.GetProperty("orderId").GetString().
Security in tests, in shorttwist#
Every controller test sends its requests as a caller with a token. It does so with
jwt() from spring-security-test:
/** A customer-service agent's token: a subject and both scopes. */
private static final JwtRequestPostProcessor AGENT = jwt().jwt(token -> token.subject("agent-1"))
.authorities(new SimpleGrantedAuthority("SCOPE_orders.read"), new SimpleGrantedAuthority("SCOPE_orders.write"));jwt() puts an authentication straight into the security context of the request.
It never calls the token decoder, so the test needs no token issuer. The chapter on security
owns this topic. The section Testing security in a controller
slice covers the defaults of jwt() and the decoder for tests. It also covers
the 401 and 403 tests, and the trap of @WithMockUser. For the rule that
every endpoint names a permission, see the section A rule that every
endpoint names its permission.
One habit from that chapter applies to every feature. Each permission test has a partner. One caller without the scope gets 403, and one caller with the scope succeeds. A rule that only refuses could refuse everyone.
The HTTP client against a real server from the JDKtwist#
The stock client turns a reservation into JSON, adds the service's token and an idempotency
key, retries a 503, and translates the answer. A mock of the client would test none of that.
So StockAdapterTest starts a real HTTP server inside the test. The server is the
one in the JDK, in the package com.sun.net.httpserver:
private HttpServer server;
private final AtomicInteger reservationCalls = new AtomicInteger();
private final AtomicInteger tokenCalls = new AtomicInteger();
private volatile int status = 200;
private volatile String body = """
{"result":"RESERVED","shortSkus":[],"reservationId":"r-1"}""";
private volatile String authorization;
private volatile String idempotencyKey;
private StockAdapter adapter;
@BeforeEach
void start() throws IOException {
server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.createContext("/token", exchange -> {
tokenCalls.incrementAndGet();
respond(exchange, 200, """
{"access_token":"token-1","token_type":"Bearer","expires_in":3600}""");
});
server.createContext("/stock/reservations", exchange -> {
reservationCalls.incrementAndGet();
authorization = exchange.getRequestHeaders().getFirst("Authorization");
idempotencyKey = exchange.getRequestHeaders().getFirst("Idempotency-Key");
respond(exchange, status, body);
});
server.start();
String baseUrl = "http://localhost:" + server.getAddress().getPort();
HttpClientProperties properties = new HttpClientProperties();
properties.setBaseUrl(URI.create(baseUrl));
properties.setTokenRegistrationId("stock-service");
ResilienceProperties resilience = new ResilienceProperties();
resilience.getRetry().setInitialInterval(Duration.ofMillis(1));
HttpClientFactory factory = new HttpClientFactory(RestClient::builder, () -> tokenManager(baseUrl + "/token"));
adapter = new StockAdapter(factory.create(StockHttpClient.class, properties), new ResilientCalls(resilience));
}server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);Port 0 lets the system choose a free port when the server binds. So two test runs on one machine never fight over a port.
getAddress().getPort()later reads the port that was chosen.server.createContext("/token", exchange -> {Each context answers one path. The first one plays the token issuer, so the client-credentials flow runs for real. The second one plays the stock service and records the headers it received.
private volatile int status = 200;server.start()starts the server in a new background thread, and the server handles every request on that thread. The test setsstatuson its own thread, and the server reads it on the other.volatilemakes each write visible to the other thread. For more, see the section volatile means more in Java.private final AtomicInteger reservationCalls = new AtomicInteger();The server thread counts the calls. An atomic variable adds one safely from any thread. For more, see the section Atomics.
resilience.getRetry().setInitialInterval(Duration.ofMillis(1));The platform's default waits 200 milliseconds before the first retry, and about twice as long before the next one. The test sets 1 millisecond, so a test of retries does not wait.
new HttpClientFactory(RestClient::builder, () -> tokenManager(baseUrl + "/token"));The test builds the client with the platform's real factory, as Spring would. So the timeouts, the token interceptor and the error handling are the production ones.
The tests then change the answer and check the result. This one proves the retries:
@Test
void retriesServerErrorsThenReportsTheServiceAsUnavailable() {
status = 503;
body = "{}";
assertThatThrownBy(() -> adapter.reserve(OrderId.newId(), TWO_MUGS)).isInstanceOf(DownstreamServiceException.class);
assertThat(reservationCalls).hasValue(3);
}hasValue(3) proves that three calls reached the server, which is one call and two
retries. Then the adapter reported
the service as unavailable, and the platform answers that with 503. The other tests of the class
check the translation of each answer, the token and the idempotency key. They also check an
unknown answer, and one service token shared by two users. The client chapter explains each behaviour. See the
chapter Calling other services.
The platform's own ResilientCallsTest uses the same trick for fast retries:
private static ResilienceProperties fastRetries() {
ResilienceProperties properties = new ResilienceProperties();
properties.getRetry().setInitialInterval(Duration.ofMillis(1));
return properties;
}The method sets setInitialInterval(Duration.ofMillis(1)), the same one line as the
client test.
Why not WireMock? WireMock is the better tool when a test needs delays, faults or many stubs, and the local environment uses it. For a test of one client, the JDK server needs no library and starts in milliseconds. The test reads the received headers from plain fields. For WireMock in a test, see the section WireMock: faking an HTTP dependency. A .NET developer would use WireMock.Net here. Both choices are sound.
Messaging unit teststwist#
Two classes of the order feature touch messages. The mapper decides which domain events leave the service. The listener turns a payment event into a call of the use case. Both are tested without a queue.
The mapper test checks the published events and the events that stay inside:
@Test
void keepsInternalEventsInternal() {
assertThat(mapper.toIntegrationEvent(new LinePicked(OrderId.newId(), LineId.newId(), NOW))).isEmpty();
}toIntegrationEvent returns an Optional, and an empty one means
"not published". A picked line is an internal fact, so no other service hears about it. The
other two tests check that a placed order and a shipped order become versioned events with
plain types.
The listener test is harder. The listener calls the platform's inbox, and the inbox calls the listener's handler back. The inbox is platform code with tests of its own. So this test replaces it with a mock that only passes the event through:
@Test
@SuppressWarnings("unchecked")
void translatesTheEventAndCallsTheUseCase() {
doAnswer(call -> {
((Consumer<PaymentReceivedV1>) call.getArgument(2)).accept(call.getArgument(1));
return null;
}).when(inbox).process(eq("order-service.payment-received"), any(PaymentReceivedV1.class), any());
OrderId orderId = OrderId.newId();
listener.on(new PaymentReceivedV1("event-1", NOW, orderId.value(), "PAY-1", new BigDecimal("12.5"), "GBP", NOW));
ArgumentCaptor<Payment> payment = ArgumentCaptor.forClass(Payment.class);
verify(orders).recordPayment(eq(orderId), payment.capture());
assertThat(payment.getValue().amount()).isEqualTo(Money.of("12.50", "GBP"));
}doAnswer(call -> {doAnswerstubs a method that returns nothing with code that runs on each call. Here the code plays the inbox. It takes the handler and the event from the call, and runs the handler.((Consumer<PaymentReceivedV1>) call.getArgument(2)).accept(call.getArgument(1));The third argument of
processis the handler, aConsumer. Mockito returns it with no type argument, because generics are erased at run time. So the test casts it. For erasure, see the section What erasure takes away.@SuppressWarnings("unchecked")The compiler cannot check that cast, and warns about it. This annotation tells the compiler that the test accepts the risk. Put it on the smallest scope that needs it, here one method.
assertThat(payment.getValue().amount()).isEqualTo(Money.of("12.50", "GBP"));The event carried
12.5, and the captured payment holds12.50in pounds. So the test proves the translation into the domain's money.
A third test, PaymentReceivedV1Test, reads JSON into the consumed event with a
real Jackson mapper. It proves that unknown fields are ignored. It also proves that an envelope of Amazon
Simple Notification Service (SNS) is refused, instead of being read as an event full of nulls. For that trap, see the section
The SNS envelope, and the null trap.
MongoDB in Docker: the replica settrap#
The persistence tests need a real MongoDB. The platform starts one in Docker, with Testcontainers, in a test configuration that every service imports:
@TestConfiguration(proxyBeanMethods = false)
public class MongoTestContainerConfiguration {
public static final String IMAGE = "mongo:8.0.34";
@Bean
@ServiceConnection
MongoDBContainer mongoDbContainer() {
return new MongoDBContainer(IMAGE).withReplicaSet();
}
}@TestConfiguration(proxyBeanMethods = false)This is a configuration class for tests only. A test adds it with
@Import.@BeanThe container is a bean. Spring starts it when the context starts, and stops it when the context closes. The context is cached and shared by test classes with the same configuration, so they share one container too. For the context cache, see the section Test configuration.
@ServiceConnectionSpring Boot reads the address of the running container, and sets the MongoDB connection from it. The test needs no
spring.mongodb.uri.public static final String IMAGE = "mongo:8.0.34";The image names an exact version, and the local environment uses the same version. Use the MongoDB version that your DocumentDB cluster is compatible with.
@Bean
@ServiceConnection
MongoDBContainer mongoDbContainer() {
return new MongoDBContainer(IMAGE).withReplicaSet();
}Show the answer
UncategorizedMongoDbException: This MongoDB deployment
does not support retryable writes. Please add retryWrites=false to your connection
string.The Testcontainers 2 class starts a stand-alone MongoDB unless you ask for a replica set.
MongoDB runs a transaction only on a replica set. A stand-alone server refuses it.
The old class, org.testcontainers.containers.MongoDBContainer, always started a
replica set. The new class, org.testcontainers.mongodb.MongoDBContainer, starts one
only after withReplicaSet().
So an upgrade that changes only the import turns transactions off in every test. Keep
withReplicaSet(). DocumentDB always runs as a replica set.
Testcontainers 2 has non-generic container classes in new packages. The old generic class still ships, deprecated. Boot 4.1 can still connect to it, but its connection support is marked for removal in Boot 4.2. So move to the new class, and keep the replica set.
Many guides start the container in a static field instead. The test class then carries
@Testcontainers, and the field carries @Container and
@ServiceConnection. The section Testing without a cluster
shows that style. It works, but each test class declares the container again. The bean in a
shared configuration declares it once, for every test class and every service.
Testcontainers for .NET does the same job in C#. The two libraries have different code, but the idea is the same. A real database runs in Docker for the length of the tests.
Persistence tests with the platform's data slicetwist#
A persistence test checks that an aggregate goes into MongoDB and comes back whole. It needs the database, the platform's MongoDB setup and the adapter, and nothing else. The platform has a slice annotation for that:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@DataMongoTest(properties = "platform.mongo.migrations.enabled=false")
@ImportAutoConfiguration(PlatformMongoAutoConfiguration.class)
@Import(MongoTestContainerConfiguration.class)
public @interface PlatformDataMongoTest {
}@DataMongoTest(properties = "platform.mongo.migrations.enabled=false")@DataMongoTestis the slice of Spring Boot for document data. It starts Spring Data MongoDB and the repositories. The property turns the platform's migrations off, so each test sees only the data it writes.@ImportAutoConfiguration(PlatformMongoAutoConfiguration.class)This adds the platform's MongoDB setup, such as the transaction manager and the DocumentDB settings. So a test runs with the settings of production.
@Import(MongoTestContainerConfiguration.class)This adds the container from the section above.
The test of the full-style adapter imports the adapter and its mapper:
/** The full-style adapter, its mapper and optimistic locking against a real MongoDB replica set. */
@PlatformDataMongoTest
@Import({OrderPersistenceAdapter.class, OrderDocumentMapper.class})
class OrderPersistenceAdapterIT {The class name ends in IT, so only mvn verify -Pit runs it. Its
tests check the round trip of a whole aggregate, and the stored names and types. They also check
the version after a save, a page of orders, and the conflict between two writers. One test reads the stored
document with MongoTemplate, to see the shape that another tool would see:
Document stored = mongo.findById(order.id().value(), Document.class, "orders");
assertThat(stored.getList("lines", Document.class).getFirst()).containsKey("lineId").doesNotContainKey("_id");
assertThat(stored.get("lastPayment", Document.class).get("amount")).isInstanceOf(Decimal128.class);The check proves two decisions of the storage chapter. The ID of a line is stored as
lineId, and money is stored as Decimal128, not as a string. For both
decisions, see the chapter Storing aggregates in DocumentDB.
@DataMongoTest rolls nothing back after a test, unlike the slice for Jakarta
Persistence (JPA). So each
test must work beside the data that other tests left behind. No test counts all the documents
in a collection. Each test
creates orders with new IDs, and looks only at those orders. The page test, for example, checks
that the result contains its cancelled order, not that the page holds exactly one.
The light-style note has its own test, NoteRepositoryIT. It uses the same
annotation and imports nothing, because Spring Data creates the repository itself.
One optimistic-lock test for each aggregatenew#
Two agents open the same order. One cancels it, and the other picks a line. The second save
must fail, or the cancel is lost. The @Version field makes it fail. A test proves
it once for each aggregate, because each feature stores its aggregate in its own way:
@Test
void rejectsAStaleCopy() {
Order saved = orders.save(newOrder());
Order firstCopy = orders.get(saved.id());
Order secondCopy = orders.get(saved.id());
firstCopy.cancel(CLOCK);
orders.save(firstCopy);
secondCopy.pickLine(secondCopy.lines().getFirst().id(), CLOCK);
assertThatThrownBy(() -> orders.save(secondCopy)).isInstanceOf(OptimisticLockingFailureException.class);
}Both copies are loaded with version 0. The first save writes version 1. The second save
must throw OptimisticLockingFailureException. It still says
version 0, so Spring Data finds no matching document. The platform turns
that exception into a 409, which the controller test checks with a mock. This test proves the
cause, and the controller test proves the answer.
The note feature has the same test, and it shows a difference of the light style:
assertThatThrownBy(() -> notes.save(secondCopy)).isInstanceOf(OptimisticLockingFailureException.class);
// Spring Data wrote the next version into the object before the update failed, so the
// failed copy must not be saved again: load the note afresh instead.
assertThat(secondCopy.version()).isEqualTo(1L);The failed save changed the version of the object anyway, so secondCopy.version()
is 1. So a retry with the same object
would overwrite the other agent's change. The test records that fact, and the rule that
follows from it. Load the note again, and never retry with the failed copy.
A third test, aConcurrentUpdateInsideTransactionsIsAWriteConflict, runs two
writers inside two open transactions. MongoDB then refuses the second writer earlier, with a
write conflict. For the two kinds of conflict, see the chapter Transactions in
depth.
The wiring test: the whole service, connected to nothingnew#
A developer renames a setting in application.yml, and every unit test passes.
The service then fails to start in the test environment, because a bean needs the old key. No
fast test started the whole application. So the order service has one test that starts every
bean with every setting, while nothing connects to anything:
/**
* Starts the whole service with every setting, while nothing connects to anything. It catches a
* missing bean, a cycle, a bad property or a wrong auto-configuration order in seconds, without
* Docker. The settings are in {@code application-wiring.yml}.
*/
@SpringBootTest
@org.springframework.test.context.ActiveProfiles("wiring")
class ApplicationWiringTest {
@Autowired
ApplicationContext context;
@Test
void theContextStartsWithThePlatformsBeans() {
assertThat(context.getBean(PlatformTransactionManager.class)).isInstanceOf(MongoTransactionManager.class);
assertThat(context.getBeansOfType(SecurityFilterChain.class)).containsOnlyKeys("platformSecurityFilterChain");
}
/** Spring Boot 4 reads spring.mongodb.uri; the old spring.data.mongodb.uri would be ignored silently. */
@Test
void usesTheConfiguredMongoUri() {
ConnectionString used = context.getBean(MongoConnectionDetails.class).getConnectionString();
assertThat(used.getHosts()).containsExactly("localhost:1");
assertThat(used.getDatabase()).isEqualTo("wiring-test");
}
}@SpringBootTestThis starts the whole application context, as the service does in production. If any bean cannot be created, the test fails, and the message names the bean.
@org.springframework.test.context.ActiveProfiles("wiring")The profile
wiringloadsapplication-wiring.ymlfrom the test resources. That file gives every setting a value that connects to nothing..containsOnlyKeys("platformSecurityFilterChain");Exactly one security chain exists, and it is the platform's. This catches a wrong order of auto-configurations. See the section Why the platform's chain is created before Boot's.
assertThat(used.getHosts()).containsExactly("localhost:1");The service uses the URI from the setting. Spring Boot 4 ignores the old key
spring.data.mongodb.uriwithout a warning, and would connect tolocalhost:27017instead. This line catches that.
The profile makes every connection lazy or impossible:
spring:
mongodb:
# Nothing listens on port 1. The driver connects lazily, and gives up after 100 ms.
uri: mongodb://localhost:1/wiring-test?serverSelectionTimeoutMS=100
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ""
jwk-set-uri: http://localhost:1/jwks
# ...
cloud:
aws:
sqs:
enabled: false
credentials:
access-key: test
secret-key: test
platform:
# ...
mongo:
migrations:
enabled: false
messaging:
outbox:
resend-enabled: false
clients:
stock:
base-url: http://localhost:1Each value stops one kind of connection. For example,
serverSelectionTimeoutMS=100 makes a query give up after 100 milliseconds:
| Setting | Why it lets the service start without a network |
|---|---|
| spring.mongodb.uri on port 1, with serverSelectionTimeoutMS=100 | the driver connects only when a query runs, and a query would give up after 100 ms |
| migrations enabled: false | the migrations would write to MongoDB at startup |
| issuer-uri empty, jwk-set-uri on port 1 | Spring fetches keys only for a real token, and no test sends one |
| sqs enabled: false | the SQS listeners would start polling a queue |
| credentials access-key and secret-key | the AWS client needs some credentials to be created |
| resend-enabled: false | the outbox resender would read MongoDB on a schedule |
| clients.stock.base-url on port 1 | the client is created with a URL, and is never called |
The test took about 4 seconds in the companion's run, with no Docker. It catches the
mistakes that only a whole context shows. These are a missing bean, a cycle between beans, and a
setting that does not bind. The test also checks results that depend on names in strings. The
platform's beforeName lists name Spring Boot's classes as strings, which the
compiler cannot check. The test does not read the strings. It checks their effect, one security
chain, the platform's. If an upgrade renamed one of those classes and the order went wrong, that
check would fail. So run the test after every upgrade of Spring Boot or the platform.
The OpenAPI contract test uses the same profile. Both tests start the same context, so the second one reuses the cached context and starts faster.
In ASP.NET Core, WebApplicationFactory<Program> starts the host in the same
way. A .NET team gets a similar test by starting the factory with settings that point nowhere,
and resolving a few services. The Spring test also catches a wrong order of auto-configurations. That
problem comes from Spring Boot's auto-configuration, so a .NET test has nothing like it to
catch.
The journey test: real MongoDB and a real SNS and SQS emulatortwist#
A journey test runs one business story through the whole service. An agent places an order with a note. The order cannot ship while the note needs action. It cannot ship before the payment arrives, or before every line is picked. Then it ships. The test uses a real MongoDB, and Floci, a free emulator of SNS and Amazon Simple Queue Service (SQS), both in Docker. Only the stock service is mocked, at its port:
@SpringBootTest(properties = {
"spring.security.oauth2.resourceserver.jwt.issuer-uri=",
"spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:1/jwks",
"spring.security.oauth2.client.registration.stock-service.client-secret=test",
"spring.security.oauth2.client.provider.shop-identity.token-uri=http://localhost:1/token",
"clients.stock.base-url=http://localhost:1",
"messaging.queues.payment-received=" + JourneyAwsResources.PAYMENT_QUEUE,
"platform.messaging.outbox.resend-enabled=false"})
@AutoConfigureMockMvc
@Import({MongoTestContainerConfiguration.class, AwsTestContainerConfiguration.class, OrderJourneyIT.Resources.class})
class OrderJourneyIT {@SpringBootTest(properties = {The properties are set for this test class only, on top of
application.yml. They replace the environment variables that production sets."platform.messaging.outbox.resend-enabled=false"})The scheduled resender is off. So every event in the test is sent by the normal path, right after the commit. The test does not depend on the timing of a schedule.
@AutoConfigureMockMvc@SpringBootTestgives no MockMvc without this annotation. See the section MockMvc and TestRestTemplate under @SpringBootTest.@Import({MongoTestContainerConfiguration.class, AwsTestContainerConfiguration.class, OrderJourneyIT.Resources.class})The test imports the two containers from the platform.
Resourcesis a small configuration inside the test. It creates the topic, the queues and the subscription in Floci, as the local environment's init script does.
In the class, @MockitoBean StockChecker stock replaces the stock port. The test
drives the story through MockMvc, and sends the payment through the real queue, twice:
// The payment service's event arrives through SQS, twice, as at-least-once delivery allows.
String payment = paymentEvent(UUID.randomUUID().toString(), orderId);
aws.sqs.sendMessage(message -> message.queueUrl(aws.paymentQueueUrl).messageBody(payment));
aws.sqs.sendMessage(message -> message.queueUrl(aws.paymentQueueUrl).messageBody(payment));
await().atMost(Duration.ofSeconds(20)).untilAsserted(() ->
mvc.perform(get("/orders/" + orderId).with(agent)).andExpect(jsonPath("$.status").value("PAID")));aws.sqs.sendMessage(message -> message.queueUrl(aws.paymentQueueUrl).messageBody(payment));The test sends the same event twice, as SQS may deliver it. The inbox must record the payment once.
await().atMost(Duration.ofSeconds(20)).untilAsserted(() ->The listener handles the message on its own thread, a moment later. Awaitility runs the check again and again until it passes, or until 20 seconds have passed. A fixed sleep would be either too short or too slow.
After the order ships, the test checks the records that no response shows. It reads them
with MongoTemplate, and with a queue that listens to the service's topic:
// The audit log: every change once, by the agent, except the payment, which the system recorded.
List<Document> orderAudit = mongo.find(query(where("resourceId").is(orderId)).with(Sort.by("at")),
Document.class, "audit_log");
assertThat(orderAudit).extracting(entry -> entry.getString("action"))
.containsExactly("ORDER_PLACED", "PAYMENT_RECORDED", "LINE_PICKED", "ORDER_SHIPPED");
assertThat(orderAudit).extracting(entry -> entry.getString("actor"))
.containsExactly("agent-1", "system", "agent-1", "agent-1");mongo.find(query(where("resourceId").is(orderId)).with(Sort.by("at")),queryandwhereare static imports from two classes of Spring Data. The line reads as a sentence. It finds the audit entries of this order, sorted by time..containsExactly("agent-1", "system", "agent-1", "agent-1");The agent did three things, and the payment listener did one, as the system actor. The payment appears once, although the event arrived twice.
The same test then waits until both published events reach the subscribed queue, and until the outbox collection is empty. So one journey proves the rules, the audit, the inbox, the outbox and the delivery to SNS together.
The other journeys are short. Each checks one thing:
- An order and its notes are stored together, or not at all.
- An inactive delivery method is refused before the stock service is called.
- A caller without a token gets 401, and a caller without the write scope gets 403.
- The lookup values are seeded and cached.
- A message that is not the event ends in the dead-letter queue.
Each one checks something that only the whole service with real infrastructure can show.
The strict rule. An integration test of a service runs against real copies of everything the service calls.
What we do. The journey mocks the StockChecker port with
@MockitoBean. The real stock client is tested against a real HTTP server in
StockAdapterTest.
Why. A real stock service would bring its own database and its own dependencies into the test. The port is the boundary that the order service owns. The client test already proves everything on the other side of it.
When to stop. When a bug appears that only the real pair of services shows, such as a disagreement about the JSON. Then add a contract test between the two teams, or a QA journey against the local environment.
An older version of such a test can depend on a failure. If SNS is set to an address where nothing listens, the outbox sends nothing, and the events stay in the outbox. A test can then check the outbox and pass. But that test relies on a network failure and on timing, and it proves nothing about delivery. The order service sends to a real emulator, turns the resender off, and waits for the events to arrive. That proves the path that production uses.
In ASP.NET Core, the same test would use WebApplicationFactory<Program>
with containers from Testcontainers for .NET. The design is the same. Only the stock port is
replaced, and everything else is real.
The OpenAPI drift testtwist#
The committed file contracts/openapi.yaml is the contract that other teams read.
A hand-written file drifts from the code, and nothing warns anyone. So a test generates the
document from the controllers, and compares it with the committed file:
@Test
void theCommittedContractMatchesTheCode() throws Exception {
String generated = mvc.perform(get("/v3/api-docs.yaml").with(jwt()))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
write(GENERATED, generated);
if (Boolean.getBoolean("openapi.update")) {
write(COMMITTED, generated);
}
assertThat(COMMITTED).as("contracts/openapi.yaml is missing; run mvn verify -Dopenapi.update=true").exists();
assertThat(Files.readString(COMMITTED))
.as("contracts/openapi.yaml differs from the code. Review target/openapi.yaml, "
+ "then run mvn verify -Dopenapi.update=true and commit the file.")
.isEqualTo(generated);
}mvc.perform(get("/v3/api-docs.yaml").with(jwt()))springdoc serves the document at this path. The test asks for it through MockMvc, so it needs no server and no port. The document needs a token, like every path.
if (Boolean.getBoolean("openapi.update")) {Boolean.getBooleanreads a system property, not a string.mvn verify -Dopenapi.update=truesets it, and the test then writes the committed file..as("contracts/openapi.yaml differs from the code. Review target/openapi.yaml, "as(...)gives the check a message for its failure. The message says what to do, so a developer who never saw the test can fix the build.
The test runs in the wiring profile, so it needs no Docker. Every API change
then shows as a diff of contracts/openapi.yaml in the pull request. The contracts
chapter owns this topic. For the drift check, see the section Exporting
the contract, and the drift check. For code first against contract first, and for event
schemas, see the chapter API and event contracts.
The events have a contract test too. PublishedEventSchemaTest turns each published
domain event into its integration event with the real mapper. It writes the event with the
service's JSON mapper, and validates the JSON against the committed schema in
contracts/events. A renamed or removed field then fails the build before a consumer
breaks. The test is a @JsonTest slice, so it starts only Jackson. For the schemas and
the validator, see the section Event contracts: a JSON schema for each
event.
The architecture rules in the platform's test packagetwist#
The order service has rules about its own structure. The domain uses no framework, a controller uses no repository, and features meet only through their services. A rule in a document is forgotten. A rule that fails the build is kept. ArchUnit reads the compiled classes and checks such rules. For the basics, see the section ArchUnit: architecture rules as tests.
The rules belong to the platform, so every service checks the same ones. They are in the library's test package, and a service calls them from a plain JUnit test:
private static final String BASE = "com.acme.orderservice";
/** The features in the full style, whose application layer must use ports only. */
private static final String ORDER = BASE + ".order";
private static final JavaClasses CLASSES = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages(BASE);
@Test
void domainIsPlainJava() {
ArchitectureRules.domainIsPlainJava().check(CLASSES);
}.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)The importer skips the test classes. A test may break the rules on purpose, as the next sections show. A run for this book imported no class named
*Testwith this option, against 15 without it..importPackages(BASE);The importer reads every compiled class of the service. The field is
static, so the classes are read once for all the tests of the class.ArchitectureRules.domainIsPlainJava().check(CLASSES);checkthrows anAssertionErrorthat lists every violation. So the test fails like any other test.
The first rule is the most important one:
/** A {@code domain} package uses no framework, no adapter and no other layer. */
public static ArchRule domainIsPlainJava() {
return noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage(
"org.springframework..", "com.mongodb..", "org.bson..", "jakarta..",
"tools.jackson..", "com.fasterxml..", "io.awspring..",
"..application..", "..api..", "..persistence..", "..client..", "..messaging..",
"com.acme.platform.core..", "com.acme.platform.mongo..")
.as("A domain package is plain Java");
}noClasses().that().resideInAPackage("..domain..")The rule is about every class in a package whose name contains
domain. Two dots match any part of a package name."tools.jackson..", "com.fasterxml..", "io.awspring..",The classes of Jackson 3 are in
tools.jackson, but its annotations are still incom.fasterxml. So the rule names both."com.acme.platform.core..", "com.acme.platform.mongo..")The domain may use the platform's
domainpackage, with its base classes, but not the platform's web or storage code..as("A domain package is plain Java");This text replaces the generated sentence in the failure message.
The other rules of the platform follow the same pattern. Each is a method that returns an
ArchRule:
| Rule | What it stops |
|---|---|
| domainIsPlainJava | a framework, an adapter or another layer in the domain |
| applicationDoesNotUseAdapters(features) | HTTP, MongoDB or an adapter in the application layer of a full-style feature |
| apiDoesNotUsePersistenceOrClients | a controller that reaches past the application layer |
| controllersDoNotUseRepositories | a controller that skips the use case and its rules |
| everyEndpointHasAPermission | an endpoint that anyone with a token can call |
| documentsOnlyInPersistenceOrModel | a stored document outside a persistence or model package |
| httpClientsOnlyInClient | a declarative HTTP client outside a client package |
| entitiesHaveNoPublicSetters | an entity that can be changed without its rules |
| featuresUseEachOtherOnlyThroughApplication(base) | a feature that reaches into another feature's domain or storage |
| servicesDoNotUseOtherFeaturesRepositoriesOrEntities(base) | a service that uses another feature's repository or entity |
The rules know the three levels of DDD. A rule about the domain package affects
only full-style features, because the other features have none. The rule about the application
layer takes the full-style features as a parameter. So the light and simple features are not
held to rules that they do not need.
Many guides write ArchUnit tests in another style. A class carries
@AnalyzeClasses, and each rule is a field with @ArchTest. That style
needs ArchUnit's own JUnit engine. The platform's style needs only plain JUnit tests that call
methods. The methods can take parameters, such as the base package, and a library can ship them
like any other code.
ArchUnitNET is the C# fork of ArchUnit, and its rules read the same way:
IArchRule rule = Types().That().ResideInNamespace("Shop.Domain")
.Should().NotDependOnAny(Types().That().ResideInNamespace("Microsoft.AspNetCore"));
Assert.True(rule.HasNoViolations(Shop));This rule compiled, and its test passed, with .NET 10 and ArchUnitNET 0.13.4. Shop is the
loaded assembly of the stand-in project.
A custom ArchConditionnew#
Some rules cannot be said with the fluent sentences. "A feature may use another feature only through that feature's application package" is one of them. The rule depends on which feature a class is in, and which feature its dependency is in. So the platform writes the check itself:
public static ArchRule featuresUseEachOtherOnlyThroughApplication(String basePackage) {
return classes().that().resideInAPackage(basePackage + "..")
.should(new ArchCondition<>("use other features only through their application package") {
@Override
public void check(JavaClass origin, ConditionEvents events) {
String feature = featureOf(basePackage, origin.getPackageName());
if (feature == null) {
return;
}
for (Dependency dependency : origin.getDirectDependenciesFromSelf()) {
String target = dependency.getTargetClass().getBaseComponentType().getPackageName();
String targetFeature = featureOf(basePackage, target);
if (targetFeature == null || targetFeature.equals(feature) || targetFeature.equals("shared")) {
continue;
}
String inFeature = target.substring((basePackage + "." + targetFeature).length());
boolean allowed = inFeature.startsWith(".application") && !inFeature.startsWith(".application.port");
if (!allowed) {
events.add(SimpleConditionEvent.violated(dependency, dependency.getDescription()));
}
}
}
})
.as("Features use each other only through their application package");
}.should(new ArchCondition<>("use other features only through their application package") {An
ArchConditionis a check that ArchUnit runs on each selected class. The text is the description in the failure message. The class is anonymous and declared on the spot, because only this rule uses it.public void check(JavaClass origin, ConditionEvents events) {ArchUnit calls this method once for each class.
JavaClassdescribes the class from its bytecode.eventscollects the violations.for (Dependency dependency : origin.getDirectDependenciesFromSelf()) {Each dependency is one use of another class, such as a field type, a parameter, a method call or a constructor call.
dependency.getTargetClass().getBaseComponentType().getPackageName();For an array such as
Order[], the base component type isOrder. So an array cannot hide a forbidden class.boolean allowed = inFeature.startsWith(".application") && !inFeature.startsWith(".application.port");Another feature may be used only through its application package, and not through its ports. A port is that feature's own boundary to storage or another service.
events.add(SimpleConditionEvent.violated(dependency, dependency.getDescription()));This records one violation. The description names the two classes, so the failure says where to look.
A run for this book wrote a condition of this kind, and it reported both of the classes that
broke it, each with its message. The helper featureOf takes the part of the package
name after the base package. For com.acme.orderservice.note.model, it returns
note.
The shared package is open to every feature. It holds the permission
annotations and the audit names. A shared package is a common place for code that grows without
an owner. Keep it small, and move anything with rules into a feature.
The guardrail rulesnew#
Most rules keep the layers apart. Five rules guard against the mistakes that a pull request review misses most often. Each one is small, and each one has a reason that the rest of the part explains.
No public setter on an entity. An entity changes only through methods that apply its rules. A public setter lets any code skip the rules:
public static ArchRule entitiesHaveNoPublicSetters() {
DescribedPredicate<JavaClass> entities = assignableTo(Entity.class)
.or(DescribedPredicate.describe("light-style entities",
(JavaClass type) -> type.isAnnotatedWith(Document.class)
&& !type.getPackageName().contains(".persistence")));
return methods().that().areDeclaredInClassesThat(entities)
.and().haveNameMatching("set[A-Z].*")
.should().notBePublic()
.allowEmptyShould(true)
.as("Entities have no public setter");
}DescribedPredicate<JavaClass> entities = assignableTo(Entity.class)A predicate selects classes. This one selects every subclass of the platform's
Entity, which covers the full-style domain.(JavaClass type) -> type.isAnnotatedWith(Document.class)The light-style note is a stored document outside a persistence package. So the predicate selects such documents too. A persistence document only holds data, and may have setters.
.allowEmptyShould(true)This lets the rule pass when it selects no method at all. A service whose entities have no setters, or that has no entities, then passes.
Features only through services. A service never takes another feature's
repository or entity. It asks that feature's service, which checks that feature's rules. The
rule servicesDoNotUseOtherFeaturesRepositoriesOrEntities states the most common case
of the wider rule above. Its failure message is clearer.
No repository in a controller. A controller that calls a repository skips the use case, its rules, its events and its audit:
/** A controller calls a service, never a repository, so every rule and check runs. */
public static ArchRule controllersDoNotUseRepositories() {
return noClasses().that().areAnnotatedWith(RestController.class)
.should().dependOnClassesThat(are(assignableTo(org.springframework.data.repository.Repository.class))
.or(assignableTo(com.acme.platform.domain.Repository.class)))
.as("Controllers use no repository");
}The rule names both kinds of Repository. One is the Spring Data interface, which the light
and simple features use. The other is the platform's port, which the full-style feature uses.
A permission on every endpoint. The permission is checked on the controllers, so a new endpoint without an annotation is open to any caller with a token. For the rule and its test, see the section A rule that every endpoint names its permission.
HTTP clients only in client packages. A declarative client is an interface
with @HttpExchange. If one appears in a domain or application package, the
anti-corruption layer is gone:
/** Declarative HTTP clients live in a client package, behind a port. */
public static ArchRule httpClientsOnlyInClient() {
return classes().that().areAnnotatedWith(HttpExchange.class)
.should().resideInAPackage("..client..")
.as("@HttpExchange interfaces live only in client packages");
}resideInAPackage("..client..") accepts any package whose name contains
client.
A rule that never fails proves nothing. So the service has a test that feeds each guardrail a class that breaks it:
/** A light-style entity, a document outside persistence, that someone gave a setter. */
@Document("parcels")
static class ParcelWithASetter {
private String status;
public void setStatus(String status) {
this.status = status;
}
}
@Test
void anEntityWithAPublicSetterFailsTheBuild() {
JavaClasses classes = new ClassFileImporter().importClasses(ParcelWithASetter.class);
assertThatThrownBy(() -> ArchitectureRules.entitiesHaveNoPublicSetters().check(classes))
.isInstanceOf(AssertionError.class)
.hasMessageContaining("setStatus");
}The broken class is a static nested class of the test, so it exists only in the test code.
importClasses imports just that one class. The check must fail, and its message
must name the setter. The same test class does this for the endpoint rule, with a controller
that forgot its permission, and with one that did not.
In .NET, ArchUnitNET can state the same rules. The rules are not the hard part in either language. The hard part is agreeing on them as a team, and keeping them in one shared place.
Adding the rules to an existing servicetwist#
A service that is five years old breaks the rules in forty places. If the rules fail the build today, nobody adds them. ArchUnit has an answer, which the platform's class comment names:
* <p>To add the rules to an existing service with old violations, wrap a rule in
* {@code FreezingArchRule.freeze(rule)}. ArchUnit then records today's violations, and only new
* ones fail the build.So FreezingArchRule.freeze(rule) takes any rule. In the test, the call becomes
FreezingArchRule.freeze(ArchitectureRules.domainIsPlainJava()).check(CLASSES). A run
for this book with ArchUnit 1.5.1 showed the whole cycle:
| Step | What happened |
|---|---|
| the plain rule, on a domain class with @Component | the rule failed |
| the frozen rule, first run | it passed, and wrote a store of today's violations |
| the frozen rule, same code | it passed again |
| the frozen rule, after a new violation was added | it failed, and named only the new violation |
The store is a folder of text files. Its default name is archunit_store. The
first run creates it only if a file named archunit.properties allows it, with
freeze.store.default.allowStoreCreation=true. That setting is false by default.
The run also set freeze.store.default.path to choose the folder.
The order service keeps a small example of a frozen rule. Two small controllers model an older service. The old one was written before the rule existed, and its violation is in the store:
class FrozenRuleTest {
@RestController
static class OldReportController {
@GetMapping("/old-report")
String oldReport() {
return "written before every endpoint needed a permission";
}
}
@RestController
static class NewReportController {
@GetMapping("/new-report")
String newReport() {
return "written after the rule, and still without a permission";
}
}
@Test
void theOldViolationIsRecordedSoTheRulePasses() {
JavaClasses classes = new ClassFileImporter().importClasses(OldReportController.class);
FreezingArchRule.freeze(ArchitectureRules.everyEndpointHasAPermission()).check(classes);
}
@Test
void aNewViolationFailsTheBuild() {
JavaClasses classes = new ClassFileImporter().importClasses(OldReportController.class,
NewReportController.class);
assertThatThrownBy(() -> FreezingArchRule.freeze(ArchitectureRules.everyEndpointHasAPermission()).check(classes))
.isInstanceOf(AssertionError.class)
.hasMessageContaining("newReport")
.hasMessageNotContaining("oldReport");
}
}FreezingArchRule.freeze(ArchitectureRules.everyEndpointHasAPermission()).check(classes);The platform's rule, frozen. ArchUnit compares today's violations with the store. A violation in the store passes. A violation that is not in the store fails the check.
.hasMessageContaining("newReport")The second test adds a new controller with the same mistake. The check fails, and its message names only the new method.
.hasMessageNotContaining("oldReport");The old violation is still known, so the message leaves it out. A reviewer sees only what the change added.
The settings are in archunit.properties, at the root of the test classpath:
# ArchUnit's settings, read from the test classpath.
#
# A frozen rule (FrozenRuleTest) keeps the violations it accepts in a store: a folder of text
# files, committed with the code. Review every change to it in the pull request, because a
# store that grows means that someone froze a new violation.
freeze.store.default.path=src/test/resources/archunit_store
# Never create a store by accident, for example in CI, where it would accept every violation
# without a review. To create the store, set this to true once, run the test, commit the
# folder, and set it back to false.
freeze.store.default.allowStoreCreation=falsefreeze.store.default.path=src/test/resources/archunit_storeThe folder of the store, relative to the module, because Maven runs the tests there. It is under
src, so it is committed with the code.freeze.store.default.allowStoreCreation=falseThe default, stated with its reason. With
false, a missing store fails the test.
The first run created the store with the setting at true. The store has a file
that names each frozen rule, and one file of violations for each rule:
#
#Sun Oct 25 22:12:10 GMT 2026
Every\ endpoint\ has\ a\ permission\ annotation=788271d0-89f0-404a-8ca9-76d9759202a5Every\ endpoint\ has\ a\ permission\ annotation=788271d0-89f0-404a-8ca9-76d9759202a5The key is the rule's description. The value names the file that holds the rule's violations.
The file of violations has one line for each violation. Its line here says that the method
OldReportController.oldReport() is not meta-annotated with
@PreAuthorize, with the source file and line. A pull request that changes the
store shows each accepted violation by name.
Commit the store with the code. Review a change to the store in the pull request, because a store that grows means someone froze a new violation.
Optional or required: which rules to adoptnew#
Some teams make the architecture tests optional, so a service may skip them. That choice deserves a fair hearing. Rules cost something. A rule that is too strict blocks a sound pragmatic shortcut, and someone must maintain it. A small team that reviews every change may catch the same mistakes by reading.
If a team keeps only one rule, the book recommends "a domain package is plain Java". That rule protects what DDD is for. Business rules stay testable in milliseconds, and they never depend on a framework. Every other rule can be checked by a careful review. This one is the easiest to break by accident, with one import.
The rules are worth the most in three situations:
- Many engineers work on the service, and no one reads every change.
- Code is generated by an AI assistant, which copies whatever pattern it saw last.
- The service will live for years, and its first authors will leave.
The book recommends the rules as required for a new service, from the first commit. They take about a second of build time, and a new service breaks none of them. For an existing service, freeze them. Then they need no fixes today, and they stop the problem from growing. Make a rule optional only where a feature has a documented reason to break it. Say so in the rule's parameters, as the service does for the full-style features:
@Test
void applicationOfFullStyleFeaturesUsesNoAdapter() {
ArchitectureRules.applicationDoesNotUseAdapters(ORDER).check(CLASSES);
}Only the ORDER feature is passed to the rule. The light-style note service uses
a Spring Data repository on purpose, so the rule does not apply to it. For the team's guardrails as a whole, see the chapter
DDD for a team: the playbook.
Testing the platform librarytwist#
The platform library is code that every service runs. So it has tests of its own, in its own repository. A test of a base class needs a class that uses it. The test declares one inside itself:
static final class ThingId extends Identifier {
ThingId() {
super(newValue(), "thingId");
}
}
record ThingHappened(Instant occurredAt) implements DomainEvent {
}
static final class Thing extends AggregateRoot<ThingId> {
Thing() {
super(new ThingId(), null);
}
void doSomething() {
raise(new ThingHappened(Instant.EPOCH));
}
}
@Test
void pullEventsReturnsTheRaisedEventsAndClearsThem() {
Thing thing = new Thing();
thing.doSomething();
assertThat(thing.pullEvents()).containsExactly(new ThingHappened(Instant.EPOCH));
assertThat(thing.pullEvents()).isEmpty();
}ThingId, ThingHappened and Thing exist only for this
test. A record or class declared inside another class is nested, and a nested record is always
static. So the test needs no domain from a real service, and the library stays free of any
service's names. IdentifierTest uses the same trick, with a ParcelId
and an InvoiceId, to prove that two IDs of different types are never equal.
The library tests the code that every service relies on most:
| Platform code | Its tests |
|---|---|
| the inbox | InboxTest, and InboxIT with a duplicate, a rollback and two copies of one event at once |
| the outbox publisher | OutboxDomainEventPublisherTest, including the refusal outside a transaction |
| the outbox resender and its lock | OutboxResenderTest, and OutboxResenderLockIT with two instances |
| the migration runner | MongoMigrationRunnerTest for order and duplicate IDs, MongoMigrationRunnerIT for the lock, and CreateCollectionsIT for creating the collections before the first transaction |
| the log context and the path variables in the MDC | LogContextTest and PathVariableMdcInterceptorTest |
| the DocumentDB settings | DocumentDbClientSettingsCustomizerTest, including a missing certificate bundle |
| retries and the circuit breaker | ResilientCallsTest |
| the base classes of the domain | AggregateRootTest, IdentifierTest and GuardTest |
| the current actor on another thread | ActorOnAnotherThreadTest, which shows that a new thread loses the caller unless the executor passes it on |
This list matters. Code that every service shares is where a missing test costs most. An
inbox without a test of two copies at once can lose or repeat payments in every service at
once. Each chapter that teaches one of these pieces shows its test. A library also needs one
test class that you would not expect. Boot's slices look for a
@SpringBootApplication class above the test's package, and a library has none. So
the platform declares a small PlatformTestApplication in its test code. For the test
support that the library ships to services, see the section Test support,
shipped in the library.
A shared NuGet package in .NET would have the same tests. Nothing here is special to Java, except that a nested record or class makes the test types short.
What to test first in an older servicenew#
An older service has few tests, and every change feels risky. Writing tests for all of it first would take months. So start with the tests that cost least and protect the most, in this order:
- The wiring test. It is one small class and one file of settings, and it needs no Docker. From then on, it catches a broken bean or setting in seconds, which makes upgrades safer.
- The architecture rules, frozen. They need no fixes today, and they stop new code from making the structure worse.
- API slice tests for the endpoints you are about to change. Record what each endpoint answers today, including its errors, before you touch it. Such a test is called a characterisation test. It describes the current behaviour, right or wrong, so a change cannot alter it by accident.
- Domain tests, as rules move into the domain. Each rule that moves from a service into an aggregate gets its plain JUnit test in the same pull request.
- One optimistic-lock test for each aggregate. A lost update is silent, so a missing version check is found only by a test.
- The inbox and the outbox against a real database, early. They are where duplicates and lost events hide. Test them against DocumentDB itself in a test environment, because MongoDB in Docker is not DocumentDB.
Do not start with a large journey test. It is slow to write, slow to run, and when it fails it does not say which layer broke. Add journeys after the fast tests exist, one for each story that matters to the business.
Move feature by feature, and only where you are changing code anyway. For the checklist of changing an existing service, see the chapter Changing the service.
1An expert asksA domain test of the shipping rule needs a Spring context to pass. Why does
the book call that a sign that the code is in the wrong place, and not a reason to start
Spring?
domainIsPlainJava catches the same mistake
in the build.2Why does the order service test its controllers with @PlatformWebMvcTest and
not with a plain @WebMvcTest?
@PlatformWebMvcTest and
not with a plain @WebMvcTest?3An expert asksThe unit test of OrderService passes a real
TransactionTemplate with a NoOpTransactionManager. What does that give
the test that a mock of the template would not?
OrderService passes a real
TransactionTemplate with a NoOpTransactionManager. What does that give
the test that a mock of the template would not?4TranslateWrite the two checks at the end of this Moq test in Mockito. The test
expects that the order was never saved and that the notes, events and audit mocks were never
called:
orders.Verify(o => o.Save(It.IsAny<Order>()), Times.Never());
notes.VerifyNoOtherCalls();
events.VerifyNoOtherCalls();
audit.VerifyNoOtherCalls();
orders.Verify(o => o.Save(It.IsAny<Order>()), Times.Never());
notes.VerifyNoOtherCalls();
events.VerifyNoOtherCalls();
audit.VerifyNoOtherCalls();verify(orders, never()).save(any());
verifyNoInteractions(notes, events, audit);never() does the same as Times.Never(). verifyNoInteractions
fails on any call at all, and takes several mocks at once.
Quick reference#
The test classes of the order service, by kind:
| Kind | Classes | Command |
|---|---|---|
| domain unit | OrderTest, ValueObjectTest, ShippingPolicyTest, NoteTest | mvn verify |
| application unit | OrderServiceTest, NoteServiceTest | mvn verify |
| API slice | OrderControllerTest, NoteControllerTest, ReferenceDataControllerTest | mvn verify |
| HTTP client | StockAdapterTest | mvn verify |
| messaging unit | OrderIntegrationEventMapperTest, PaymentReceivedListenerTest, PaymentReceivedV1Test | mvn verify |
| mapping | NoteMappingTest | mvn verify |
| wiring | ApplicationWiringTest | mvn verify |
| contract | OpenApiContractTest, PublishedEventSchemaTest | mvn verify |
| architecture | ArchitectureTest, ArchitectureRulesCatchViolationsTest, FrozenRuleTest | mvn verify |
| persistence | OrderPersistenceAdapterIT, NoteRepositoryIT, NoteMigrationsIT | mvn verify -Pit |
| journey | OrderJourneyIT | mvn verify -Pit |
The platform's test package, and what each class is for:
| Class | What it gives a service |
|---|---|
| PlatformWebMvcTest | a web slice with the platform's security, error handling and log interceptor |
| TestJwtDecoderConfiguration | a token decoder that refuses every real token, for slices |
| PlatformDataMongoTest | a persistence slice with the platform's MongoDB setup and a container |
| MongoTestContainerConfiguration | MongoDB 8.0.34 in Docker, as a replica set, with @ServiceConnection |
| AwsTestContainerConfiguration | Floci 2.3.0 in Docker, with Spring Cloud AWS pointed at it |
| NoOpTransactionManager | a transaction manager that does nothing, for unit tests |
| ArchitectureRules | the shop's design rules as ArchUnit rules |
Running locally and shipping#
A developer runs the order service on their own machine, against local copies of everything it needs. A separate repository, the local environment, holds those copies. They are MongoDB, HTTP stubs, an emulator of AWS messaging and a token issuer. Two commands start everything. The chapter then builds the image that ships, with layers, a user that is not root, and memory settings for a container.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | a docker-compose.yml in each solution | a shared local-environment repository | one set of local dependencies for every service and for QA, started once Why |
| new | no equivalent | mongo --replSet rs0, and rs.initiate in the healthcheck | MongoDB runs a transaction only on a replica set, so even one local node is started as one Why |
| twist | WireMock.Net | WireMock 3.13.0 in Docker, with stubs chosen by priority and body | a stub with a lower priority number wins, so a special product overrides the default answer Why |
| new | LocalStack | Floci 2.3.0 | LocalStack needs an account since March 2026, so the shop uses a free emulator with the same API Why |
| new | no equivalent | an aws-init container that runs init.sh | the topics, queues, dead-letter queues and subscriptions exist before any service starts Why |
| twist | a local token issuer of your choice | mock-oauth2-server 6.1.3, with one client ID for each kind of caller | a client ID stands for a kind of user, and its token carries that user's scopes Why |
| new | docker compose up for the dependencies, then dotnet run | docker compose up -d, then mvn package && docker compose up --build | two commands start everything, with no manual step Why |
| trap | ASPNETCORE_ENVIRONMENT=Development in launchSettings.json | the local profile from the IDE, with localhost in place of the container names | a MongoDB URI with replicaSet=rs0 fails from the host, because the member is named mongo:27017 Why |
| twist | curl with a bearer token | the same curl, with a token from the local issuer | the first request of a new developer, copied from the README Why |
| new | no equivalent | the rules of the local environment | a stub, a topic or a queue arrives in the same change as the code that needs it Why |
| twist | a Dockerfile that copies the publish output | a layered Spring Boot jar, extracted with --launcher and started with JarLauncher | the rarely changing layers come first, so a code change rebuilds only the last layer Why |
| twist | a USER line in the Dockerfile | useradd --system --uid 10001, then USER 10001 | the service runs as user 10001, never as root, so a break-in gets no root rights in the container Why |
| trap | "System.GC.HeapHardLimitPercent": 75 | -XX:MaxRAMPercentage=50 in JAVA_TOOL_OPTIONS | the JVM's default heap is 25 percent of the container's memory, but the .NET default is 75 percent Why |
| new | no equivalent | -XX:+ExitOnOutOfMemoryError | the JVM exits with code 3 at the first OutOfMemoryError, so the orchestrator restarts it Why |
| new | a HEALTHCHECK with curl, which the aspnet:10.0 image does not contain | a healthcheck that opens /dev/tcp with bash, on the readiness probe | the Java and the .NET runtime images have no curl, and ready means that the database is reachable Why |
| new | no equivalent | the DocumentDB certificate bundle, mounted when the container runs | the image is the same in every environment, and holds no certificate Why |
| new | no equivalent | an exact version for every image | everyone runs the same thing, and an upgrade is a visible change Why |
The local environment: one repository for every servicenew#
The order service needs MongoDB, the stock service, Amazon Simple Notification Service
(SNS), Amazon Simple Queue Service (SQS) and a token issuer. The payment
service needs most of the same. If each service started its own copies, a developer would run
three MongoDB servers to work on two services. The QA team would need yet another setup for its
Playwright tests. So the shop keeps the shared dependencies in one repository of their own,
called local-environment.
Its compose file says how to use it:
# The shared local dependencies of every shop service. Start them first, then the service:
#
# docker compose up -d (here)
# docker compose up --build (in the service's repository)
#
# Everything joins the "local-dev" network, so services reach these containers by name.
# The QA team's Playwright tests run against this same setup. Every image version is pinned,
# so everyone runs the same thing.
name: local-environmentname: local-environmentThis is the name of the Compose project. Docker Compose puts it in front of every container name, so the MongoDB container is called
local-environment-mongo-1.
The file ends with the network that every container joins:
networks:
default:
name: local-devA Compose file creates a default network for its containers. These three lines give that
network a fixed name, local-dev. A service from another repository can then join
the same network, and reach each container by its service name, such as
mongo.
The repository's README lists the containers:
| Container | Image | Purpose | Host port |
|---|---|---|---|
| mongo | mongo:8.0.34 | stands in for DocumentDB, as a single-node replica set | 27017 |
| wiremock | wiremock/wiremock:3.13.0 | stubs of the HTTP services the shop calls | 8089 |
| floci | floci/floci:2.3.0 | SNS and SQS, with the API of LocalStack | 4566 |
| aws-init | amazon/aws-cli:2.27.0 | creates the topics, queues and subscriptions, then stops | none |
| mock-oauth2 | ghcr.io/navikt/mock-oauth2-server:6.1.3 | issues tokens | 8090 |
In .NET, a solution often has its own docker-compose.yml for its dependencies.
That works well for one service. The shared repository costs one more checkout, but every
service and the QA team then run the same stubs and the same queues. A change to a stub is made
once, and every consumer gets it.
MongoDB as a replica set of onenew#
The order service saves an order and its notes in one transaction. MongoDB runs a transaction only on a replica set. So the local MongoDB runs as a replica set with one member:
# Stands in for DocumentDB. Use the MongoDB version that your DocumentDB cluster is compatible with.
# It runs as a single-node replica set, because transactions need one.
mongo:
image: mongo:8.0.34
command: ["--replSet", "rs0", "--bind_ip_all"]
ports: ["27017:27017"]
healthcheck:
# Also initialises the replica set the first time it runs
test: ["CMD", "mongosh", "--quiet", "--eval",
"try { rs.status().ok } catch (e) { rs.initiate({_id: 'rs0', members: [{_id: 0, host: 'mongo:27017'}]}).ok }"]
interval: 5s
retries: 30command: ["--replSet", "rs0", "--bind_ip_all"]--replSet rs0starts the server as a member of a replica set namedrs0.--bind_ip_allmakes it listen on every network address, so other containers can reach it."try { rs.status().ok } catch (e) { rs.initiate({_id: 'rs0', members: [{_id: 0, host: 'mongo:27017'}]}).ok }"]A new replica set must be initiated once.
rs.status()throws while it is not. Then thecatchcallsrs.initiate, with one member namedmongo:27017. On every later run,rs.status()succeeds, and the check only reports health.interval: 5sDocker runs the check every 5 seconds. After 30 failed checks in a row, Docker marks the container as unhealthy.
The healthcheck does two jobs. It initiates the replica set on the first start, and it
reports health. That saves a separate container for the set-up step. In a run for this book on
25 October 2026, the container was healthy within about 20 seconds. The replica set then
listed one member, mongo:27017. That member name matters for the IDE, as the
section Running from the IDE shows.
The platform's tests start the same image, mongo:8.0.34, with Testcontainers. So
the local environment and the integration tests run one version of MongoDB. Two places with two
versions would test against two databases. For the container in tests, see the section
MongoDB in Docker: the replica set.
Choose the version by your DocumentDB cluster. DocumentDB emulates the MongoDB 3.6, 4.0, 5.0
and 8.0 APIs. A guide that pins mongo:5.0 "to match DocumentDB" is right for a
5.0 cluster, and wrong for an 8.0 one. Check the engine version of your cluster, then set the
same major version in both places.
Local MongoDB is not DocumentDB. MongoDB in Docker accepts features that DocumentDB does not support. A test that passes locally can fail against the cluster. Check an unusual feature, such as a new aggregation stage, against a real DocumentDB cluster in a development environment. For the differences, see the section What Amazon DocumentDB does not do.
WireMock: stubs of the other servicestwist#
The order service calls the stock service. Locally, WireMock answers in its place:
# Imitates the HTTP services that shop services call. The stubs are in wiremock/mappings/<service>/.
wiremock:
image: wiremock/wiremock:3.13.0
command: ["--global-response-templating", "--verbose"]
volumes: ["./wiremock:/home/wiremock"]
ports: ["8089:8080"]command: ["--global-response-templating", "--verbose"]Response templating lets a stub compute part of its answer. The default stub uses it to return a new reservation ID each time. Without
--verbose, WireMock logs only the requests that match no stub. With it, WireMock logs every request and the stub that answered it.volumes: ["./wiremock:/home/wiremock"]WireMock reads its stubs from
mappingsand large bodies from__filesunder this folder. Each service that is stubbed has a folder of its own, such asmappings/stock-service/.
The stubs for the stock service give one answer by default, and other answers for special products. The default stub has a low priority:
{
"name": "stock-service: every product is available (the default answer)",
"priority": 10,
"request": {
"method": "POST",
"urlPath": "/stock/reservations",
"headers": {
"Authorization": { "matches": "Bearer .+" },
"Idempotency-Key": { "matches": ".+" }
}
},With "priority": 10, this stub loses to any stub with a smaller number. A special
product gets such a stub, which matches on the body:
{
"name": "stock-service: the product OUT-OF-STOCK is never available (for testing the 422 path)",
"priority": 1,
"request": {
"method": "POST",
"urlPath": "/stock/reservations",
"headers": { "Authorization": { "matches": "Bearer .+" } },
"bodyPatterns": [ { "matchesJsonPath": "$.lines[?(@.sku == 'OUT-OF-STOCK')]" } ]
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"jsonBody": { "result": "INSUFFICIENT_STOCK", "shortSkus": ["OUT-OF-STOCK"] }
}
}"priority": 1,When several stubs match a request, WireMock uses the one with the lowest priority number. 1 is the highest priority. A stub without a priority gets 5.
"bodyPatterns": [ { "matchesJsonPath": "$.lines[?(@.sku == 'OUT-OF-STOCK')]" } ]The stub matches only a body with a line whose
skuisOUT-OF-STOCK. The filter in square brackets is JSONPath, as in the controller tests."headers": { "Authorization": { "matches": "Bearer .+" } },The stub needs a bearer token, as the real stock service does. So a missing token fails locally too.
A run for this book sent four requests to these stubs. WireMock named the stub that matched
in a response header, Matched-Stub-Name:
| Request | Answer | The stub that matched |
|---|---|---|
| a line with MUG-001, a token and an Idempotency-Key | 200 RESERVED, with a new reservation ID | the default answer, priority 10 |
| a line with OUT-OF-STOCK | 200 INSUFFICIENT_STOCK | the product OUT-OF-STOCK, priority 1 |
| a line with SERVICE-DOWN | 503 | the product SERVICE-DOWN, priority 1 |
| no token | 401, with the header WWW-Authenticate set to Bearer | a call without a token, priority 20 |
The stub for a call without a token has priority 20, the lowest of the four. It matches every reservation, with or without a token. So it must lose to every other stub, and it wins only when no other stub matches.
A stub without a priority beats a stub with priority 10. A run of WireMock
3.13.2 showed it. The default priority is 5, and a lower number wins. So a new stub that leaves
out priority takes over from the default answer. Give every stub an explicit
priority.
QA's Playwright tests use these special products by name. A tester places an order for
OUT-OF-STOCK to see the 422 page, and for SERVICE-DOWN to see the 503
page. So the stubs are shared test data, and the README lists their behaviour. For WireMock
inside a Java test, see the section WireMock: faking an HTTP
dependency.
The image is wiremock/wiremock:3.13.0. The latest release of 3.13 was 3.13.2,
as of October 2026. An upgrade is a change to this line, made and reviewed on purpose.
Floci: SNS and SQS on your machinenew#
The service publishes to SNS and reads from SQS. Locally, Floci answers in place of AWS:
# Local SNS and SQS: Floci, a free emulator with LocalStack's API, on port 4566.
floci:
image: floci/floci:2.3.0
ports: ["4566:4566"]Floci is a free AWS emulator under the MIT licence. Its repository was created in February 2026. It
emulates SQS, with standard and FIFO queues, dead-letter queues and the visibility timeout, and
SNS, with delivery to SQS. Every service it emulates is on port 4566. The image is
floci/floci on Docker Hub. Version 2.3.0 was the latest, released on 15 October
2026.
Many guides use LocalStack for this job, and LocalStack was the usual choice for years.
Since March 2026, LocalStack is delivered as one version, and users need an account. Its image
localstack/localstack:latest asks for an auth token. Older tags still run, but get
no security updates, and the GitHub repository is archived. So, as of October 2026, a team that
wants a free emulator with no account uses another one. Floci speaks the same API. A service sets only spring.cloud.aws.endpoint to reach it.
LocalStack is still a sound choice for a team that already pays for it, or that needs an AWS service that Floci does not emulate. Then change one image line and keep the rest.
The integration tests start the same Floci image, from the platform's
AwsTestContainerConfiguration:
public static final String IMAGE = "floci/floci:2.3.0";
public static final int PORT = 4566;The constant IMAGE holds the same tag as the compose file.
A guide may run Floci with the tag latest, and plan to pin it later. Then each
developer gets whatever was latest on the day of the pull, and two machines run two emulators.
The shop pins the tag from the start, in both places.
aws-init: creating the queues and topicsnew#
An emulator starts empty. The order service expects its queue to exist, and fails at startup if it does not. So a container that runs once creates every topic, queue and subscription:
# Creates the topics, queues and subscriptions that every service expects (aws-init/init.sh).
aws-init:
image: amazon/aws-cli:2.27.0
depends_on: [floci]
entrypoint: ["/bin/sh", "/init/init.sh"]
volumes: ["./aws-init:/init"]
environment:
AWS_ACCESS_KEY_ID: local
AWS_SECRET_ACCESS_KEY: local
AWS_DEFAULT_REGION: eu-west-2
AWS_ENDPOINT_URL: http://floci:4566entrypoint: ["/bin/sh", "/init/init.sh"]The container runs the script and stops. The image is the official AWS command-line tool.
AWS_ACCESS_KEY_ID: localThe tool signs each request with credentials, and it refuses to send a request without them. In a run for this book, the same image with no credentials printed "Unable to locate credentials". Floci accepted these dummy values.
AWS_ENDPOINT_URL: http://floci:4566This environment variable sends every command of the tool to Floci instead of AWS.
The script waits for Floci, then defines two shell functions:
echo "Waiting for Floci..."
until aws sns list-topics >/dev/null 2>&1; do sleep 1; done
# queue <name>: creates the queue and its dead-letter queue, and prints the queue URL.
#
# A message moves to the dead-letter queue after 5 failed receives, so a poison message stops
# being retried and someone can look at it.
#
# The visibility timeout must be longer than the slowest handler plus about 1 second: Spring
# Cloud AWS acknowledges (deletes) messages in batches, every second or every 10 messages, and
# does not extend the timeout while a handler runs. A shorter timeout delivers each message twice.
queue() {
dlq_url=$(aws sqs create-queue --queue-name "$1-dlq" --query QueueUrl --output text)
dlq_arn=$(aws sqs get-queue-attributes --queue-url "$dlq_url" --attribute-names QueueArn --query Attributes.QueueArn --output text)
aws sqs create-queue --queue-name "$1" \
--attributes "{\"VisibilityTimeout\":\"30\",\"RedrivePolicy\":\"{\\\"deadLetterTargetArn\\\":\\\"$dlq_arn\\\",\\\"maxReceiveCount\\\":\\\"5\\\"}\"}" \
--query QueueUrl --output text
}until aws sns list-topics >/dev/null 2>&1; do sleep 1; doneThe loop tries a harmless command once a second until Floci answers. The short form
depends_on: [floci]only waits until the Floci container has started, not until Floci is ready. Compose waits for a healthy container only withcondition: service_healthy, and the Floci container has no healthcheck.queue() {This defines a shell function named
queue. Inside it,$1is its first argument, the queue name.dlq_url=$(aws sqs create-queue --queue-name "$1-dlq" --query QueueUrl --output text)First the dead-letter queue.
$( )captures the output of a command.--query QueueUrl --output textprints only the URL, not the whole JSON answer."{\"VisibilityTimeout\":\"30\",\"RedrivePolicy\":\"{\\\"deadLetterTargetArn\\\":\\\"$dlq_arn\\\",\\\"maxReceiveCount\\\":\\\"5\\\"}\"}"The attributes are JSON, and the redrive policy is JSON inside a JSON string, so the quotes are escaped twice. The visibility timeout is 30 seconds. After 5 failed receives, SQS moves a message to the dead-letter queue.
The comment above the function explains the two numbers. A message that fails every time is
a poison message. Without a dead-letter queue, SQS would deliver it again for up to four
days, its default retention. With maxReceiveCount 5, it moves to the dead-letter
queue after five tries, where someone can see it.
The visibility timeout is how long a received message stays hidden from other consumers. Spring Cloud AWS deletes handled messages in batches, and does not extend the timeout while a handler runs. So the timeout must be longer than the slowest handler plus about a second. For the full reasoning, see the section The visibility timeout.
The second function subscribes a queue to a topic, and the script then creates the resources of the order service:
subscribe() {
queue_arn=$(aws sqs get-queue-attributes --queue-url "$2" --attribute-names QueueArn --query Attributes.QueueArn --output text)
aws sns subscribe --topic-arn "$1" --protocol sqs --notification-endpoint "$queue_arn" \
--attributes RawMessageDelivery=true >/dev/null
}
# --- order-service publishes
aws sns create-topic --name order-events >/dev/null
# --- order-service consumes: payment-events from the payment service
payment_events=$(aws sns create-topic --name payment-events --query TopicArn --output text)
payment_received=$(queue order-service-payment-received)
subscribe "$payment_events" "$payment_received"--attributes RawMessageDelivery=true >/dev/nullRaw delivery puts the event itself into the queue. Without it, SNS wraps the event in a JSON envelope, and the listener would read the envelope as the event.
payment_received=$(queue order-service-payment-received)The function prints the queue URL, and
$( )keeps it in a variable for the next line.
The run for this book checked the result. The aws-init container exited with code 0 after
printing "AWS resources ready". Floci then listed the queue and its dead-letter queue. The queue
had VisibilityTimeout 30 and a RedrivePolicy with
maxReceiveCount 5. One subscription linked payment-events to the
queue.
Each service adds its own block to this one script. The block names the service in a comment, as the order service does. For the publishing side, see the chapter Publishing events: the outbox.
mock-oauth2-server: tokens for each kind of callertwist#
Every request to the order service needs a token. Locally,
mock-oauth2-server issues them. Its configuration maps each client ID to a subject
and its scopes:
| client_id | Subject | Scopes |
|---|---|---|
| agent | local-agent | orders.read orders.write |
| read-only | local-read-only | orders.read |
| order-service | order-service | stock.reserve, for the order service's own calls to the stock service |
The third mapping gives the order service its own token, for its calls to the stock service:
{
"requestParam": "client_id",
"match": "order-service",
"claims": {
"sub": "order-service",
"aud": ["stock-service"],
"scope": "stock.reserve"
}
}A request with client_id order-service gets a token for the
audience stock-service. A client ID stands for a kind of user, which is enough to
test the scopes. The security
chapter explains the configuration, the image ghcr.io/navikt/mock-oauth2-server:6.1.3,
and why the service checks one issuer URL and fetches keys from another. See the section
A local token issuer, and the issuer-versus-keys trick.
Two commands start everythingnew#
A new developer should not follow a page of manual steps. The shop has a rule that two commands start any service with all its dependencies. The service's compose file states them:
# Runs ONLY this service. Its dependencies (MongoDB, WireMock, Floci, the mock OAuth2 server) come
# from the local-environment repository, which must be started first:
#
# 1. in local-environment: docker compose up -d
# 2. here: mvn package && docker compose up --build
services:
order-service:
build: .
environment:
SPRING_PROFILES_ACTIVE: local
ports:
- "8080:8080"
# ...
networks:
default:
name: local-dev
external: truedocker compose up -d-druns the containers in the background, so the terminal is free.mvn package && docker compose up --buildmvn packagebuilds the jar, and runs the unit tests. The Dockerfile copies that jar, so it must exist first.--buildbuilds the image again from the new jar.SPRING_PROFILES_ACTIVE: localThis environment variable switches on the profile
local, so Spring Boot also readsapplication-local.yml. It is likeASPNETCORE_ENVIRONMENTin ASP.NET Core.external: trueThis network is not created by this file. It must exist already, created by the local environment. So the service joins the same network as its dependencies, and reaches
mongoandwiremockby name.
The profile local holds the container names:
spring:
mongodb:
uri: mongodb://mongo:27017/orders?replicaSet=rs0The URI names the container mongo and the replica set rs0. Inside
the network, both names are right. The rest of the profile points the token keys at
mock-oauth2, AWS at floci, and the stock client at
wiremock.
In the run for this book, the second command built the image and started the container in about 8 seconds. The container was healthy after about 24 seconds. A request without a token got 401, as it should.
The rule has a cost. Every change that needs a new dependency must also change the local environment, so the two commands keep working. The section The rules of the local environment says when.
Running from the IDEtrap#
A developer wants breakpoints, so they run OrderServiceApplication from the IDE
on the host. The dependencies still run in Docker. From the host, the container names mean
nothing, so the README overrides them with localhost:
--spring.mongodb.uri=mongodb://localhost:27017/orders?directConnection=true
--spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:8090/default/jwks
--spring.security.oauth2.client.provider.shop-identity.token-uri=http://localhost:8090/default/token
--spring.cloud.aws.endpoint=http://localhost:4566
--clients.stock.base-url=http://localhost:8089Each line is a program argument that sets one property. A program argument wins over every
file, so these values replace the container names of the local profile. The IDE run still uses
the profile local for everything else.
--spring.mongodb.uri=mongodb://localhost:27017/orders?replicaSet=rs0Show the answer
UnknownHostException:
mongo, and the first write waits for a server.A replica set tells the driver the names of its members.
The driver connects to localhost:27017 and asks for the replica set. The member
answers that its name is mongo:27017.
With replicaSet=rs0, the driver then uses that name. The host cannot resolve
mongo.
The migrations write before the web server starts, so the port never opens.
Use directConnection=true, which talks only to the host you named.
The run for this book tried three URIs from the host:
| URI from the host | Result |
|---|---|
| mongodb://localhost:27017/orders?replicaSet=rs0 | no HTTP port after 45 seconds, and an UnknownHostException for mongo in the log |
| mongodb://localhost:27017/orders | readiness UP after 13 seconds |
| mongodb://localhost:27017/orders?directConnection=true | readiness UP after 13 seconds |
With one host and no replicaSet, the Java driver also connected directly. But
directConnection=true says so in the URI, so a reader does not have to know the
driver's default. Keep it in the README.
A .NET service that runs on the host meets the same replica set and the same member name.
This book did not run the MongoDB driver for .NET against the local replica set. So check what
it does with a URI that has no directConnection=true before you rely on it.
The first request with curltwist#
The README of the service ends its section on running locally with three requests. They get a token, read a lookup list and place an order:
TOKEN=$(curl -s -X POST localhost:8090/default/token -d grant_type=client_credentials \
-d client_id=agent -d client_secret=any | jq -r .access_token)
curl -s localhost:8080/reference-data/DELIVERY_METHOD -H "Authorization: Bearer $TOKEN"
curl -s -X POST localhost:8080/orders -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{
"customer": { "name": "Jo Bloggs", "email": "jo@example.com" }, "deliveryMethod": "STANDARD",
"lines": [ { "productCode": "MUG-001", "quantity": 2 } ],
"notes": [ { "text": "Leave with a neighbour", "category": "DELIVERY", "actionRequired": true } ] }'TOKEN=$(curl -s -X POST localhost:8090/default/token -d grant_type=client_credentials \The first command asks the local issuer for a token as the client
agent, and keeps it in the shell variableTOKEN.jq -r .access_tokentakes the token out of the JSON answer.-H "Authorization: Bearer $TOKEN"Each later request sends the token as a bearer token. The agent has both scopes, so it can read and write.
To test a missing scope, ask for a token with client_id=read-only. The place
request then gets 403. With "productCode": "OUT-OF-STOCK", it gets 422, because the
WireMock stub reports that product as unavailable.
A run against the full local environment, on 25 October 2026, gave these answers:
| Request | Answer |
|---|---|
| the token, as the client agent | a token with iss http://localhost:8090/default, sub local-agent, aud order-service and the scope orders.read orders.write |
| GET /reference-data/DELIVERY_METHOD | 200, with the four delivery methods in code order, SECOND_CLASS_POST among them with active false |
| POST /orders | 201, with {"orderId": "...", "noteIds": ["..."]} |
| POST /orders with a read-only token | 403, with the title Forbidden and a traceId |
| POST /orders with OUT-OF-STOCK | 422, with the code PRODUCTS_UNAVAILABLE |
| POST /orders/{orderId}/notes/{noteId}/resolve, by the same agent | 422, with the code NOTE_RESOLVED_BY_ITS_AUTHOR |
The last row is a business rule. The agent who wrote a note may not resolve its action.
Locally, every token of the client agent has the same subject, so resolving a note
from curl needs a second client in the issuer's configuration.
The run could not pull the image ghcr.io/navikt/mock-oauth2-server:6.1.3. The
registry answered 403 Forbidden, also with no stored credentials. So the same version of the
issuer ran from its jar on Maven Central, in a Java container named mock-oauth2 on
port 8090. Everything else ran from the compose files as they are. The compose file still
names the image on ghcr.io, with its exact version, and a developer normally runs that image. The migrations ran on the
new database before the service took traffic, and the readiness probe answered UP.
The local environment's README also shows how to send a payment, as the payment service would. It runs the AWS command-line tool in a container on the same network:
docker run --rm --network local-dev -e AWS_ACCESS_KEY_ID=local -e AWS_SECRET_ACCESS_KEY=local \
-e AWS_DEFAULT_REGION=eu-west-2 -e AWS_ENDPOINT_URL=http://floci:4566 amazon/aws-cli:2.27.0 \
sns publish --topic-arn arn:aws:sns:eu-west-2:000000000000:payment-events --message \
'{"eventId":"e-1","occurredAt":"2026-10-25T09:00:00Z","orderId":"<order id>","paymentReference":"PAY-1","amount":12.50,"currency":"GBP","paidAt":"2026-10-25T09:00:00Z"}'--network local-dev puts the tool on the shared network, so it reaches
floci by name. --rm removes the container when the command ends.
Replace <order id> with the ID from the place request. The order then
becomes paid, through SNS, SQS and the inbox. In a browser, Swagger UI is at
http://localhost:8080/swagger-ui.html, in the local profile only.
The rules of the local environmentnew#
The local environment is only useful while it matches what the services need. Its README states four rules:
## Rules
- Add a stub, a topic or a queue in the same piece of work that makes a service call or use it.
- Keep the stubs realistic: QA's tests depend on them.
- Pin every image version.
- Local MongoDB is not DocumentDB. Check unusual features against a real cluster.Each rule in the list, from Add a stub to Local MongoDB is not
DocumentDB, has a reason:
- The same piece of work. A service that calls a new service, publishes a new event or reads a new queue changes the local environment in the same change. Otherwise the two commands stop working for everyone else, the next morning. For the files each change touches, see the chapter Changing the service.
- Realistic stubs. QA's Playwright tests run against these stubs. A stub that answers what the real service never answers makes those tests prove the wrong thing. Copy a real response when you write a stub, and keep its field names.
- Exact image versions. See the section An exact version for every image.
- Not DocumentDB. See the section MongoDB as a replica set of one.
The strict rule. Each service owns its whole build and run setup, so it never depends on another repository.
What we do. Every service depends on the shared
local-environment repository for its dependencies and stubs.
Why. One copy of each stub, queue and token client serves every service and QA. A change is made once, and nobody runs three databases for two services.
When to stop. When teams change the shared files so often that they block each other, or one team's stub breaks another team's tests. Then give each team its own folder of stubs and its own review, or move a team's stubs into its own repository.
The Dockerfile: a layered jartwist#
The service ships as a container image. The Dockerfile builds it from the jar that
mvn package made. For the layered jar and the other ways to ship, see the chapter
Packaging and native images. This is the service's
file:
# Build the jar first: mvn package (from the companion root: mvn -pl order-service -am package)
#
# The image has layers: dependencies change rarely, so they sit in their own layers, and Docker
# reuses them from its cache when only the application code changed.
FROM eclipse-temurin:25.0.4.1_1-jre AS extract
WORKDIR /build
COPY target/order-service-*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted
FROM eclipse-temurin:25.0.4.1_1-jre
# Never run as root
RUN useradd --system --uid 10001 --no-create-home app
USER 10001
WORKDIR /app
# Rarely changing layers first, so a code change rebuilds only the last layer
COPY --from=extract /build/extracted/dependencies/ ./
COPY --from=extract /build/extracted/spring-boot-loader/ ./
COPY --from=extract /build/extracted/snapshot-dependencies/ ./
COPY --from=extract /build/extracted/application/ ./
# Size the heap from the container's memory limit. Stop on OutOfMemoryError, so the orchestrator restarts the container.
# Half, not three quarters: the service needs about 220 MB outside the heap, so with 75 percent a
# 512 MB container killed the process before the heap was full (measured, see the memory chapter).
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=50 -XX:+ExitOnOutOfMemoryError"
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]FROM eclipse-temurin:25.0.4.1_1-jre AS extractThe first stage only unpacks the jar. A Java Runtime Environment (JRE) is enough for that, because nothing is compiled here.
AS extractnames the stage, so the second stage can copy from it.COPY target/order-service-*.jar app.jarThe image is built from the jar, not from the source code. So
mvn packageruns first, on the developer's machine or on the build server.RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extractedSpring Boot's tool splits the jar into four folders under
extracted.--launcherkeeps Spring Boot's launcher classes, so the result starts with a class name instead of a jar.COPY --from=extract /build/extracted/dependencies/ ./Each
COPYmakes one image layer. The released libraries change least, so they come first. The service's own classes come last.ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]The launcher class finds the service's classes in
BOOT-INF/classes, and the libraries next to them. Then it calls the service'smainmethod.
A run for this book listed the layers of a Boot 4.1.2 jar. They are the
dependencies, the Spring Boot loader, the snapshot dependencies and the application, in that
order. With the folders copied into one, java
org.springframework.boot.loader.launch.JarLauncher started the application. The jar's
manifest names the same class as its Main-Class. In the run of the order service
image, the folder /app held the folders BOOT-INF, META-INF and org.
The chapter on packaging shows the other form. It extracts without --launcher,
and starts the small application.jar that the tool writes. Both forms are correct.
The service uses the launcher class, so the start command names no file.
The order of the layers is what makes a build fast. A code change alters only the last layer. Docker reuses the three layers above it from its cache, and a server that pulls the new image downloads only that last layer.
In .NET, the usual Dockerfile copies the output of dotnet publish into an
ASP.NET Core runtime image. The layering idea is the same. Spring Boot splits the libraries
from the code for you.
A user that is not roottwist#
Two lines of the Dockerfile create a user and switch to it:
# Never run as root
RUN useradd --system --uid 10001 --no-create-home app
USER 10001RUN useradd --system --uid 10001 --no-create-home appuseraddcreates a system user namedapp, with the fixed user ID 10001 and no home folder.USER 10001Every later line, and the running service, uses this user. The line names the number, not the name. So anyone who reads the image's settings sees a user ID that is not 0, the ID of root.
In the run for this book, id inside the running container printed
uid=10001(app) gid=999(app). If an attacker breaks into the service, they get the
rights of that user, not root. The service needs no more, because it only reads its own files
and listens on port 8080.
The JVM in a container: heap and OutOfMemoryErrortrap#
One line of the Dockerfile sets how much memory the service may use, and what happens at an OutOfMemoryError. The .NET runtime configuration has a setting for the first job:
{
"configProperties": {
"System.GC.HeapHardLimitPercent": 75
}
}# Size the heap from the container's memory limit. Stop on OutOfMemoryError, so the orchestrator restarts the container.
# Half, not three quarters: the service needs about 220 MB outside the heap, so with 75 percent a
# 512 MB container killed the process before the heap was full (measured, see the memory chapter).
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=50 -XX:+ExitOnOutOfMemoryError"ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=50 -XX:+ExitOnOutOfMemoryError"The Java Virtual Machine (JVM) reads the environment variable
JAVA_TOOL_OPTIONSwhen it starts, and adds its options. A run printedPicked up JAVA_TOOL_OPTIONSand showed both flags set.-XX:MaxRAMPercentage=50The maximum heap is half of the container's memory limit. With a limit of 512 MB, a run showed a maximum heap of 256 MB.
-XX:+ExitOnOutOfMemoryErrorAt the first
OutOfMemoryError, the JVM stops the process.
Without a setting, the JVM uses a quarter of the container's memory for its
heap. The default of MaxRAMPercentage is 25. A service in a 1 GB container
then gets a 256 MB heap. It can run out of memory while three quarters of the container is
unused. The default in .NET is different. In a container with a memory limit, the .NET heap
hard limit is 75 percent of that limit, unless you set another value. For the memory model, see
the section Containers.
The second flag matters as much. A program that catches OutOfMemoryError can
keep running in a broken state. In a run without the flag, such a program printed "still alive
after the error" and exited normally. With the flag, the JVM printed a line that starts "Terminating due to
java.lang.OutOfMemoryError", and exited with code 3 at the first error. The
orchestrator sees a dead container and starts a new one.
The rest of the container's memory is not wasted. Metaspace, thread stacks and buffers live outside the heap. The chapter on the JVM suggests 70 percent and a limit on metaspace as well. The service used 75 percent until the chapter on memory measured it. The service needs about 220 MB outside the heap, so in a 512 MB container the heap of 384 MB left too little, and the container killed the process. The service now uses 50 percent and no metaspace limit. For the measurement, see the chapter The JVM under the service: memory.
The setting is in the image, as an environment variable. A deployment can still replace
JAVA_TOOL_OPTIONS without a new build, for example to add a heap dump on error.
System.GC.HeapHardLimitPercent in .NET has the same purpose as
-XX:MaxRAMPercentage. A .NET service seldom sets it, because its default in a
container is already 75 percent.
The healthcheck: ready means the database is reachablenew#
Docker Compose, and later the orchestrator, must know when the service can take traffic. The service's compose file asks the readiness probe:
healthcheck:
# The image has no curl, so bash opens the TCP connection itself. "Ready" means the database is reachable.
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/localhost/8080 && printf 'GET /actuator/health/readiness HTTP/1.0\\r\\n\\r\\n' >&3 && grep -q UP <&3"]
interval: 10s
retries: 12
# Migrations run before the web server starts, and an instance may wait for another's
# migration lock. Allow for that before failed checks count.
start_period: 60sexec 3<>/dev/tcp/localhost/8080In bash, a redirection to the path
/dev/tcp/host/portopens a TCP connection. It is not a real file, because bash handles the name itself. This line opens a connection to the service, as file number 3, for reading and writing.printf 'GET /actuator/health/readiness HTTP/1.0\\r\\n\\r\\n' >&3The command writes a minimal HTTP request to the connection.
grep -q UP <&3"]grepreads the answer, and succeeds only if it containsUP. So the check passes only when the service says that it is ready.start_period: 60sFor the first 60 seconds, a failed check does not count towards the 12 retries. Startup can be slow, because migrations run first. If a check passes during this time, the container counts as started, and every later failure counts.
The base image has no curl. The run for this book confirmed it, and the check passed with bash alone. Installing curl only for a healthcheck would add a tool that an attacker could use inside the container.
The readiness probe includes the database. The platform's defaults add
mongo to the readiness group, and leave the liveness group alone. So a database
outage makes the service unready, and the orchestrator stops sending it traffic. It does not
restart every instance. In the run, /actuator/health/readiness answered 200 with
{"status":"UP"}. For the probes and their groups, see the section
The health probes: liveness without the database.
An orchestrator such as Kubernetes uses its own probes, not this healthcheck. The comment about the migration lock applies there too. An instance that waits for another's migration lock opens no HTTP port. So its startup probe must allow for the lock's whole wait. For the lock and the numbers, see the section Waiting at startup, and the probes.
The DocumentDB certificate bundle, mounted at run timenew#
DocumentDB uses Transport Layer Security (TLS) by default, and the service must trust the certificate of the cluster. AWS publishes that certificate in a file, the global bundle. The service does not copy the file into its image. The deployment mounts it when the container starts:
# DocumentDB TLS: the deployment sets PLATFORM_MONGO_DOCUMENTDB_CA_BUNDLE to the mounted AWS
# certificate bundle, for example /certs/global-bundle.pem. It is not set locally.The environment variable PLATFORM_MONGO_DOCUMENTDB_CA_BUNDLE sets the property
platform.mongo.documentdb.ca-bundle, by relaxed binding. The platform then trusts
the certificates in that file, as its test
DocumentDbClientSettingsCustomizerTest.trustsTheCaBundleWhenOneIsConfigured
checks. Locally, the variable is not set, and MongoDB in Docker runs
without TLS.
The file stays outside the image for two reasons. The image is then the same in every
environment, so the image that was tested is the image that ships. And the bundle can change
without a new build of the service. The test
failsAtStartupWhenTheCaBundleIsMissing in the same class checks the other side. A setting that names a missing file stops the service at startup, not at
the first query.
For how the platform loads the bundle, and the ways that AWS documents for Java, see the section Connecting to Amazon DocumentDB. For the DocumentDB settings of the platform, see the chapter Storing aggregates in DocumentDB.
An exact version for every imagenew#
A developer pulls wiremock:latest in January, and a colleague pulls it in March.
They now run two versions, and a stub that works for one may fail for the other. A tag such as
latest moves. So every image in the shop names an exact version:
| Image | Version | Where |
|---|---|---|
| mongo | 8.0.34 | the local environment, and MongoTestContainerConfiguration |
| wiremock/wiremock | 3.13.0 | the local environment |
| floci/floci | 2.3.0 | the local environment, and AwsTestContainerConfiguration |
| amazon/aws-cli | 2.27.0 | the local environment, and the README's payment command |
| ghcr.io/navikt/mock-oauth2-server | 6.1.3 | the local environment |
| eclipse-temurin | 25.0.4.1_1-jre | the service's Dockerfile, both stages |
The same image in two places, such as MongoDB in the local environment and in the tests,
has the same version in both. An upgrade is then one visible change in a pull request, which
someone reviews and tests. With latest, an upgrade happens on the day someone
pulls, and nobody decides it.
The Java base image names its patch release, 25.0.4.1_1, not just
25. So a rebuild of the same commit gets the same Java release. Moving to a new patch is
then a deliberate change. Do it often, because patch releases carry security fixes.
1An expert asksWhy does the order service keep its dependencies in a shared
local-environment repository instead of in its own compose file?
local-environment repository instead of in its own compose file?2The local MongoDB runs with --replSet rs0. Why, when there is only one
node?
--replSet rs0. Why, when there is only one
node?3An expert asksWhy does the image set -XX:+ExitOnOutOfMemoryError?
-XX:+ExitOnOutOfMemoryError?OutOfMemoryError, a JVM can keep running in a broken state, for example
when code catches the error. With the flag, the JVM exits at the first error, with code 3. The
orchestrator sees a dead container and starts a new one, so the service recovers on its
own.4TranslateThis .NET runtime configuration limits the heap to 75 percent of the
container's memory. Write a Dockerfile line that sets the same limit for the JVM, and also
stops the JVM at the first out-of-memory error:
{
"configProperties": {
"System.GC.HeapHardLimitPercent": 75
}
}
{
"configProperties": {
"System.GC.HeapHardLimitPercent": 75
}
}ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=50 -XX:+ExitOnOutOfMemoryError"The JVM reads JAVA_TOOL_OPTIONS when it starts. MaxRAMPercentage sets
the heap as a share of the container's limit.
Quick reference#
The commands of local work:
| Task | Command |
|---|---|
| start the shared dependencies | docker compose up -d, in local-environment |
| start the order service in Docker | mvn package && docker compose up --build, in order-service |
| run the service from the IDE | the README's program arguments, with localhost and directConnection=true |
| get a token as an agent | curl -s -X POST localhost:8090/default/token -d grant_type=client_credentials -d client_id=agent -d client_secret=any |
| open Swagger UI | http://localhost:8080/swagger-ui.html, in the local profile only |
| stop everything | docker compose down, in each repository |
The host ports of the local environment:
| Port | Container | Inside the network |
|---|---|---|
| 27017 | mongo | mongo:27017 |
| 8089 | wiremock | wiremock:8080 |
| 4566 | floci | floci:4566 |
| 8090 | mock-oauth2 | mock-oauth2:8080 |
| 8080 | order-service | order-service:8080 |
The JVM under the service: memory#
The order service passes every test and runs for days on a laptop. In a cluster, a 512 MB container is killed at 03:00. There is no OutOfMemoryError, and the heap of the Java Virtual Machine (JVM) never filled. This chapter follows one object from its allocation to the collector, then follows the kill to its cause. Every number comes from the service in a container with a limit.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | a memory limit on the container, counted on the whole process | the same limit, which the JVM cannot see coming | the kernel counts resident pages, sends SIGKILL at the limit, and no Java code runs afterwards Why |
| trap | the .NET heap takes 75 percent of a container by default, and that works | -XX:MaxRAMPercentage=75 looked like the same thing | the limit counts the whole process, the JVM keeps about 220 MB outside the heap, and 384 plus 220 is more than 512 Why |
| twist | the GC heap, and the rest of the process | the Java heap, and the regions outside it | metaspace, the code cache, thread stacks and direct buffers have their own sizes and their own limits Why |
| twist | pointer-bump allocation into generation 0 | pointer-bump allocation into a thread-local buffer in eden | both runtimes allocate by moving a pointer, and both keep young objects apart from old ones Why |
| twist | workstation GC, or server GC with one heap per CPU | Serial, G1 or ZGC, chosen by the JVM from the container's CPUs and memory | a container with one CPU gets Serial, and the service's pauses are then its business Why |
| twist | OutOfMemoryException, and a dump with dotnet-dump | OutOfMemoryError, and a heap dump with jcmd GC.heap_dump | the JVM reaches its own heap limit first only if the container's limit leaves room for it Why |
| new | no equivalent | exit code 137, OOMKilled true, and nothing in the log | the kernel killed the process at the container's limit, and no Java code ran afterwards Why |
| new | no equivalent | MaxDirectMemorySize, which defaults to the maximum heap | direct buffers are capped by the JVM at another heap's worth, outside the heap Why |
| twist | docker stats, and the kernel's cgroup files | Native Memory Tracking, read with jcmd | the JVM's own account of every region, as reserved and committed bytes Why |
Scene 1: what memory is to a processtwist#
For the first look at the heap, the collectors and the container limit, see the chapter
The JVM at runtime: memory, GC and containers. For the
Dockerfile of the service, see the section
The JVM in a container: heap and OutOfMemoryError. This chapter starts
with the operating system, because the kill at 03:00 is the operating system's doing. The numbers come from runs on 26 October 2026, in Docker 27.5.1 on a laptop with an Intel
i9-8950HK. The service's own image ran inside an image of the
Java Development Kit (JDK) 25.0.4.1. The run script is
companion/runs/memory/run.sh.
Think of a process as a house. The house has rooms. The Java heap is one room, the biggest. The container's memory limit is not the size of that room. It is the floor area of the whole house, with every room counted. When the house grows past the floor area, the landlord does not knock on the door of the biggest room. The landlord removes the house.
The words behind the picture are these. A process owns virtual memory, which is a set of
addresses it may use. The addresses are divided into pages of 4 KB. A page uses no physical memory until the process touches it. Then the kernel gives it a page
of physical memory. The pages that
are in physical memory right now are called the resident set, and its size is the RSS. A container's memory limit is a limit on the resident memory of every process in the
container. The kernel's own data and the file cache that those processes caused count too. The kernel keeps the
count in a cgroup, and the file memory.max of the cgroup is the limit. When the count reaches the limit and cannot be reduced, the kernel's out-of-memory killer
acts. It picks a process in the cgroup and sends it the signal SIGKILL. That signal cannot be caught. The
process is gone, and a shell reports its exit status as 137, which is 128 plus the signal's
number 9.
Three facts follow, and the whole chapter rests on them. The limit counts the whole process, not the Java heap. The Java Virtual Machine (JVM) cannot see the kill coming, because it is not asked. And no Java code runs after it, so no exception, no log line and no heap dump exist.
The service at rest shows the gap between the heap and the house. The run started the service in a container with -m 512m and one CPU. It sent the
service 537 rounds of three requests in a minute, and read docker stats before and
after:
279.9MiB / 512MiB 54.67% idle, just after the start
347.3MiB / 512MiB 67.84% after a minute of loadEach line is one sample, and 512MiB is the limit on both. The first number is the
container's resident memory, and the second is the limit. After a minute the service used 347 MB of its 512. Its heap, as the next scene shows, had
117 MB committed and about 65 MB in use. More than half of the house was not the heap.
The C# reader knows the first half of this. The .NET runtime also keeps memory outside its GC heap. In a container, .NET limits the heap of its garbage collector (GC) to 75 percent of the memory limit by default. The rest is for the runtime, the code and the threads. The Java default is 25 percent, and the service's Dockerfile raised it to 75 to match. That number is the trap of this chapter, and scene 6 pays for it.
What you can see here#
docker stats, whose memory column is the container's resident memory against
its limit. The same number in Kubernetes comes from kubectl top pod. Inside the
container, /sys/fs/cgroup/memory.current is the count the kernel uses, and
memory.events has a line oom_kill that counts the kills. After a kill, docker inspect shows the exit code 137 and the flag
OOMKilled as true. In Kubernetes, the pod's description shows the reason
OOMKilled.
What you can tune here#
The limit itself, and whether the container may use swap. Scene 6 shows why the second setting matters most on a laptop.
Scene 2: the rooms of the housetwist#
The JVM keeps an account of every room, and the run asked for it after the minute of load.
The feature is called Native Memory Tracking (NMT). It is off by default, costs five to ten
percent when on, and the run switched it on with -XX:NativeMemoryTracking=summary.
The summary names each region with two numbers. Reserved is address space the JVM has set
aside. Committed is memory the JVM has asked the kernel for, which is what the container
counts once it is touched. The rooms that matter in the service, in committed megabytes:
| Region, as NMT names it | Committed | What is in it |
|---|---|---|
| Java Heap | 117 MB, of 384 reserved | every object, from a request's DTO to the cached reference data |
| Metaspace | 90 MB | the metadata of every loaded class, more than 20,000 of them in the service |
| Code | 58 MB | the machine code that the compilers made, and the interpreter's own |
| Symbol | 19 MB | the names of classes, methods and fields, shared by the whole JVM |
| Class | 16 MB | the compressed class space, which holds the class pointers that objects carry |
| Shared class space | 14 MB | the JDK's own classes, loaded from the class data sharing archive |
| Native Memory Tracking | 8 MB | the account itself |
| Thread | 7 MB, of 56 reserved | the stacks of the platform threads, 1 MB reserved each and only the touched pages committed |
| everything else | about 6 MB | GC data, the compiler's working memory, arena chunks, locks, logging |
The table is in mebibytes, as NMT and docker stats print them. The total committed was 334 MB, and the container showed 347 MB resident. The two agree,
which is the point of the account. Add the rooms outside the heap, and the service needs about
217 MB that is not heap, whatever the heap does. At idle, before the load, it was about 160 MB, so the rooms grow with use. Now the arithmetic of the Dockerfile. With
-XX:MaxRAMPercentage=75 in a 512 MB container, the heap may grow to 384 MB. The
run's VM.flags confirmed it, in the flags the JVM chose for itself:
-XX:InitialHeapSize=8388608 -XX:MaxHeapSize=402653184 -XX:MaxRAM=536870912 -XX:MaxRAMPercentage=75.000000 -XX:ReservedCodeCacheSize=251662336 -XX:+UseCompressedOops -XX:+UseSerialGCMaxHeapSize is 402,653,184 bytes, which is 384 MB. MaxRAM is the
container's 512 MB. 384 plus 217 is 601, and 601 is far more than 512. The heap can be within its limit while the house is over
the floor area.
Each room has its own limit, and the table says which. The heap stops at
MaxHeapSize. Direct buffers, which scene 7 meets, stop at
MaxDirectMemorySize, which defaults to the maximum heap. Metaspace has no limit by default, and MaxMetaspaceSize gives it one. The code cache is reserved at 240 MB
with tiered compilation. A thread stack is 1 MB on Linux x64, set by -Xss. A service with a thousand
platform threads reserves a gigabyte for stacks. The container caps the
sum, and nothing else does.
Two settings of the run shaped the rooms. The JVM saw one CPU and 512 MB, and on such a machine it chooses the Serial collector. The
Java 25 tuning guide reserves G1 for a server-class machine,
which has at least two processors and 1,792 MB. The GC log's first line said
so, Using Serial. And compressed references were on, as they are for any heap below 32 GB. So a reference in
an object is 4 bytes and not 8. The .NET runtime makes the same
kind of choice. Workstation GC is the default, and server GC gives one heap and one thread per
logical CPU, but never on a machine with one.
What you can see here#
jcmd 1 VM.native_memory summary, inside the container, where the service is
process 1. jcmd 1 VM.native_memory baseline and then summary.diff
show what grew since the baseline, which is how the run watched the leaks of the later scenes.
jcmd 1 VM.flags prints the flags the JVM chose, such as the collector and the
maximum heap. The Actuator has three memory gauges, for used, committed and maximum bytes. Each one carries
the tag area, as heap or nonheap, so they show the heap
and the JVM's non-heap pools over time. No Actuator
metric shows the resident memory of the process. Read it from the cgroup, or from the
platform's dashboard.
What you can tune here#
-XX:MaxRAMPercentage, from the arithmetic above and never from a habit.
-XX:MaxMetaspaceSize, when a service loads classes without end, which a class loader leak does. -Xss, when a service has many platform threads and shallow stacks. And
-XX:MaxDirectMemorySize, for the libraries that read and write with direct buffers.
Every one of these moves a wall between rooms inside the same house.
Scene 3: an allocationtwist#
Now one object, an Order made for one request. Its allocation is a pointer
bump. Each thread owns a small piece of the young generation. The piece is called a thread-local
allocation buffer, or TLAB. Allocating an object means moving the thread's pointer forward by
the object's size. No lock, no search. The .NET runtime allocates the same way, by adding to a
pointer, and its documentation says that this is almost as fast as the stack. The difference is
how many objects a request makes. Java has no structs. An order with three lines, a customer
and money values is a dozen objects, each with a header of 12 bytes, and each allocated. The C2
compiler removes some of them with escape analysis, when an object never leaves the method that
made it. The rest go to the heap.
When the thread's buffer is full, it takes another from eden. When eden is full, the collector runs. The run's GC log, from the minute of load, shows what that looks like under the Serial collector:
[92.049s][info][gc] GC(113) Pause Young (Allocation Failure) 78M->47M(112M) 4.909msRead the line from the left. The 92.049s is the time since the JVM started, the GC number, the kind of
pause, and the cause. "Allocation Failure" is the ordinary cause. It means that an allocation
did not fit in eden, not that anything failed. Then the heap before and after the pause, with
the committed size in brackets, and the pause's length. Over the whole run the log held 110
young pauses, with a median of 4.8 milliseconds. Each one stopped every thread of the service.
A request that arrived during one waited.
The run's heap at that moment, from jcmd 1 GC.heap_info, had the shape the
tuning guide describes. The young generation was 36 MB, as eden of 31 MB and two survivor
spaces of 4 MB. The old generation, which the Serial collector calls tenured, was 80 MB with 48
MB in use. A young pause copies the objects that are still reachable from eden into a survivor
space. An object that survives enough pauses, fifteen by default, is copied into the old
generation. Most orders never get there. They are made, sent, and dead before the next pause.
That is the generational hypothesis, and the whole design rests on it.
The log also held five full pauses, and four of them came in the first forty seconds, with the cause "Metadata GC Threshold". Those are not about the heap. They are about metaspace. The JVM triggers a collection when the metaspace grows past a threshold, which starts at about 21 MB and is raised after each collection. A service that loads more than 20,000 classes at startup crosses that threshold four times, and the longest of the four pauses took 231 milliseconds. The chapter on starting the service shows where those seconds go.
What you can see here#
The GC log, which the run wrote with -Xlog:gc*:file=/tmp/gc.log. The tag
gc alone gives one line per pause. gc* gives the phases, the regions
and the metaspace line of each pause too. The Actuator timer jvm.gc.pause gives the same pauses as a metric. Its tags name
the collector, the action and the cause. And jcmd 1 GC.heap_info gives the generations at one moment.
What you can tune here#
Nothing in the service's code, for the pointer bump. -XX:MetaspaceSize, the threshold of the full pauses at startup, is the one flag
of this scene worth a thought. Think about it only after a measurement like the one above.
Scene 4: the collectors, and a pause you caused yourselftwist#
The service got the Serial collector because of the container's shape, and under that collector every pause stops the world. Three collectors ship in Java 25, and the JVM chooses between the first two by itself:
| Collector | When the JVM picks it | How it pauses | The price |
|---|---|---|---|
| Serial | one CPU, or less than 1,792 MB | one thread does all the work, and every collection stops every thread | pauses grow with the live data |
| G1 | two or more CPUs and at least 1,792 MB | regions of equal size, young collections in short stop-the-world pauses, old regions marked concurrently and reclaimed in mixed collections, with a soft goal of 200 ms per pause | some memory for its bookkeeping, and CPU for the concurrent work |
| ZGC | only with -XX:+UseZGC | all the expensive work concurrently, with pauses under a millisecond that do not grow with the heap, generational since Java 24 | throughput, and heap headroom for the concurrent work |
The run met a fourth kind of pause, and it caused it. To take the class histogram of the
next scene, the run called jcmd 1 GC.class_histogram. The histogram counts live
objects, so the JVM first ran a full collection, and the GC log recorded it:
[119.798s][info][gc] GC(114) Pause Full (Heap Inspection Initiated GC) 62M->47M(113M) 344.056msThe cause in brackets, Heap Inspection Initiated GC, names the diagnostic. That is
a third of a second in which the service answered nothing.
A heap dump does the same, and a bigger heap pays more. The tools of this chapter are safe to
use in production, and they are not free. The reference pages say so, and the log proves it.
In .NET the matching choice is workstation GC against server GC. Since .NET 9 the dynamic adaptation called DATAS resizes the heap on its own. The Java choice is made once, by the JVM or by a flag. The pause goal of G1 is the one setting most services ever touch.
What you can see here#
The Using line at the top of the GC log, which names the collector. The cause
in brackets on each pause line, which says who asked. And the Actuator timer
jvm.gc.pause, whose tag cause carries the same words.
What you can tune here#
-XX:+UseG1GC or -XX:+UseZGC, with a measurement of the pauses before
and after, on the container shape that production uses. A laptop with eight CPUs picks G1 and
tells you nothing about a one-CPU container. -XX:MaxGCPauseMillis for G1, which is
a goal and not a promise. And the second CPU, which changes the collector by itself and may be
the cheaper fix.
Scene 5: the leak the JVM catchestwist#
A leak in Java is an object that is still reachable and should not be. Nothing frees it, because something still points to it. The run made one on purpose, with a class that the chapter on running the service never ships. The experiments jar holds it, and the diagnostics image adds that jar to the classpath only for these runs:
private static byte[] heap(int kb) {
byte[] block = new byte[kb * 1024];
block[0] = 1; // touch it, so the page is really used
return block;
}The method makes a byte[] block of the asked size and touches its first byte. A
worker thread stores one such block every 100 milliseconds in a list that the bean holds.
The bean exists as long as the context. Nothing ever removes a block. The run started the
service with the Dockerfile's old setting of 75 percent, and with half, which is the setting
the Dockerfile has now. Scene 6 tells what happened at 75. At half, the heap may grow to 256
MB, and the GC log of the last second shows the collector losing:
[44.745s][info][gc] GC(104) Pause Full (Allocation Failure) 238M->237M(247M) 210.731ms
[44.947s][info][gc] GC(105) Pause Full (Allocation Failure) 238M->238M(247M) 200.443ms
[45.099s][info][gc] GC(106) Pause Full (Allocation Failure) 238M->238M(247M) 149.471ms
[45.252s][info][gc] GC(107) Pause Full (Allocation Failure) 238M->238M(247M) 153.449msEach line is a Pause Full, and the numbers before and after the arrow barely
move. Four full collections in half a second, each one freeing a megabyte or nothing, each one
stopping the service for a fifth of a second. Eighteen full pauses ran in all, and fifteen of them had the cause Allocation Failure. This is what
a service looks like just before a heap leak ends it. The CPU is at 112 percent of its one core, the requests crawl, and the log is quiet. Then the JVM gave up:
Terminating due to java.lang.OutOfMemoryError: Java heap spaceThe Terminating due to line is the last thing the JVM wrote. The service's image
sets -XX:+ExitOnOutOfMemoryError, so the JVM exited with
code 3 at the first error, and the container stopped with that code. The orchestrator would
start a new one. This is the good ending of a leak, because it leaves evidence. The log has the
error. And with one more flag, the JVM writes the heap to a file before it dies.
The run took the dump itself instead, in a separate run with a 1 GB container. It took it
while the leak held about 190 MB, with jcmd 1 GC.heap_dump /tmp/heap.hprof. The JVM wrote
291 MB in one second, and the file is in the HPROF format. A quicker look is the class
histogram, which the run took at the same moment:
num #instances #bytes class name (module)
-------------------------------------------------------
1: 156920 226611000 [B (java.base@25.0.4.1)
2: 148921 3574104 java.lang.String (java.base@25.0.4.1)
3: 23538 2970104 java.lang.Class (java.base@25.0.4.1)The first row is [B, which is the JVM's name for byte[]. In the
service at rest, that row held 15 MB. Here it holds 227 MB, and about 200 MB of that is the leak. The histogram
counts only live objects, because it runs a full collection first. A histogram names the class of what leaked. It does not name who holds it.
For that, open the dump in Eclipse Memory Analyzer and read its dominator tree. An object dominates another when every path from the roots to the second goes through the
first. The tree sorts objects by what they keep alive. The LeakExperiment bean is at the top
of it, with its list and every block under it.
In .NET, the same investigation is dotnet-dump collect and
dotnet-dump analyze, and the exception is OutOfMemoryException. The
shape is the same. The difference is in the next scene. The JVM catches a heap leak only if the
container's limit leaves room for the heap to fill.
What you can see here#
The full pauses with "Allocation Failure" as their cause, back to back, in the GC log or in
jvm.gc.pause. The Terminating due to line. The histogram from
jcmd 1 GC.class_histogram, which costs a full pause. The dump from
jcmd 1 GC.heap_dump, which costs more. And the old-object sampling of Flight Recorder, which samples objects that stay alive and
saves where each one was allocated. With path-to-gc-roots=true it also keeps the chain that holds
the object, at the cost of a full collection when the recording ends.
What you can tune here#
-XX:+HeapDumpOnOutOfMemoryError with -XX:HeapDumpPath pointing at a
mounted volume, in the image or in the deployment. The Java 25 reference says the dump is
written only for a heap exhaustion, before the error is reported. The service's Dockerfile does not set it. A dump of a 384 MB heap in a container with no
volume for it has nowhere to go. Add the volume first, then the flag.
Scene 6: the kill that comes firsttrap#
Now the mystery. The run started the same leak with the Dockerfile's setting of the time, 75 percent, in the same 512 MB container. The container had no swap. The service's resident memory went from 393 MB to 486 MB in five seconds, and then there was no container:
392.7MiB / 512MiB 76.70% just after the start, with the leak already running
485.9MiB / 512MiB 94.91% five seconds later
0B / 0B 0.00% ten seconds later
exit code: 137
OOMKilled by the container: trueThe three samples are five seconds apart. The third, 0B / 0B, has no container
to measure. The
log ended with an ordinary line. The leak had logged "holds 100 MB" and nothing after
it. No OutOfMemoryError, no "Terminating", no dump. The GC log showed young pauses at 203 MB and 215 MB of heap, nowhere near the 384 MB limit.
Then it showed one full pause for the metaspace threshold. The heap was fine. The JVM was fine. The house had grown past the
floor area, and the kernel removed it.
The arithmetic of scene 2 says why, and it says that the leak was not even needed. After a minute of load the service used 347 MB, and 217 MB of it was outside the heap. The heap may grow to 384 MB. So a busy day, with the heap honestly at 250 MB and no leak at all, puts the house at 467 MB. A burst of 50 MB ends it. At 03:00 the nightly job that rebuilds the reference data is such a burst. That is the whole mystery. The Dockerfile had the wrong number, and the number looked right because it is the right number for .NET.
The run did the direct-buffer leak of the next scene the same way. The result was the same, a kill at 496 MB with the heap flat. The two kinds of leak are indistinguishable from outside. And then the run changed one thing, the Dockerfile's percentage, to half. With a heap limit of 256 MB, the house reached 465 MB at most, 47 MB under the limit. The heap leak then ended as scene 5 showed, with the JVM's own error and an exit code of 3. That is the fix, and the companion's Dockerfile now has it.
The strict rule. Size the heap from a measurement of the service's non-heap memory. Set the percentage so that heap plus non-heap stays under the limit with a margin.
What we do. The Dockerfile sets -XX:MaxRAMPercentage=50, for
every container size.
Why. The non-heap memory of the service is about 220 MB under load, and it does not grow with the container. At 512 MB, half leaves about 40 MB of margin, and the leak run peaked at 465 MB. At 1 GB, half leaves about 290 MB, which is more margin than the service needs. A 512 MB heap is still several times what the service uses. One number for every size is what a team can remember.
When to stop. When a service runs in 2 GB or more, half wastes memory. Then measure the non-heap once, and set the percentage for that size.
One more twist, and it is the reason the first captures of this chapter had to be thrown
away. On a laptop, Docker Desktop runs containers in a virtual machine, and that machine has a
swap file of 1 GB by default. A container with -m 512m and no
--memory-swap may use as much swap again as its limit. The first run with a raised cap on direct memory
used that default, and it was not killed. The kernel pushed the leaked
pages to swap, the resident memory stayed at 479 MB, and the service kept answering, slowly. A
Kubernetes node is the opposite case. The kubelet refuses to start on a Linux node with swap enabled. Its default swap behaviour is
NoSwap, under which pods cannot use swap at all. So the laptop said "fine" and the cluster said "killed", for the same image and the
same limit. The runs now pass --memory-swap 512m, which gives the container no
swap, and the kill appeared.
Kubernetes states the kill plainly. A container that uses more than its memory limit may be terminated by the kernel. The
termination happens after the fact, when the kernel detects pressure. The container's reason
then shows as OOMKilled. The reference adds that
if the killed process is the container's process 1, and the container is restartable,
Kubernetes restarts it. A service that is killed and restarted every night looks healthy on
every dashboard that measures uptime by the hour.
What you can see here#
The exit code 137 and OOMKilled: true, from docker inspect or
kubectl describe pod. A restart count that grows. A resident memory graph that
climbs to the limit and drops to zero. And nothing in the application's log, which is the
sign that separates this from scene 5.
What you can tune here#
-XX:MaxRAMPercentage, from the arithmetic. The container limit, if the
arithmetic cannot fit. And on a laptop, --memory-swap equal to --memory
for any test of a memory limit, or the test lies.
Scene 7: the leak outside the heap, and the JVM's second capnew#
Not every leak is in the heap, and a heap-only mind misses the other kind. A library that reads and writes network bytes often uses direct buffers. A direct buffer is a block of memory outside the heap, which the kernel can fill without a copy. The experiments jar can leak those too:
private static ByteBuffer direct(int kb) {
ByteBuffer block = ByteBuffer.allocateDirect(kb * 1024);
for (int i = 0; i < block.capacity(); i += 4096) {
block.put(i, (byte) 1); // touch every page
}
return block;
}The method calls allocateDirect for a buffer of the asked size. Then it touches
one byte in every page of the buffer, so that every page becomes resident. With swap off, the result was scene 6 again, a kill at 496 MB with a flat
heap. With swap on, as Docker Desktop allows by default, the run saw something the heap leak never
shows. The run script calls this mode direct-swap. The leak stopped by itself, at
about 371 MB, and the service kept running:
Exception in thread "leak-experiment" java.lang.OutOfMemoryError: Cannot reserve 1048576 bytes of direct buffer memory (allocated: 389021704, limit: 389283840)
at java.base/java.nio.Bits.reserveMemory(Bits.java:178)
at java.base/java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:108)
at java.base/java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:367)The limit in the message is 389,283,840 bytes, which is about 371 MB. The JVM
caps direct buffers at MaxDirectMemorySize. When that flag is not set, the
JDK's own code sets the cap to Runtime.getRuntime().maxMemory(). Under Serial that
is the maximum heap minus one survivor space, so a little under 384 MB. A service with a 384 MB
heap may therefore also hold about 371 MB of direct buffers. The sum is far over a
512 MB container. The exception was thrown in
the leak's own thread, which died. The service's requests ran on other threads and kept
working. The run's load loop got 6,165 rounds of 200 and 201 answers in ten minutes. The -XX:+ExitOnOutOfMemoryError flag did not fire. That flag exits on an error
thrown by the JVM itself, and this one was thrown by Java code in java.nio.
NMT showed where the memory went. The diff against the baseline, after ten minutes, had one line that moved:
- Other (reserved=380970KB +272736KB, committed=380970KB +272736KB)Direct buffers appear under Other, not under the heap, and a diff of the
summary is the way to see a room grow. The Actuator gauge
jvm.buffer.memory.used, with the tag id as direct, shows
the same number over time.
Then the run raised the cap, with -XX:MaxDirectMemorySize=512m, which is what a
team does when a native library asks for more. With swap, nothing was killed, and the service
ran slowly on a swap file. Without swap, the kill came at 507 MB. The lesson is not the flag.
The JVM caps the heap and the direct buffers. It does not cap metaspace, threads or a native
library's own allocations. The container caps the sum, and the sum is what a team must know.
What you can see here#
The gauge jvm.buffer.memory.used. The lines Other and
Internal of the NMT diff. An OutOfMemoryError whose message
names direct buffer memory, in the log of a thread nobody watches. And a resident memory that grows while jvm.memory.used for the heap stays flat.
That is the one graph that tells the two kinds of leak apart.
What you can tune here#
-XX:MaxDirectMemorySize, downwards as often as upwards. A service that never uses direct buffers on purpose can set it low. A library's leak then
throws early instead of growing to a heap's worth.
Scene 8: the toolkit, and the image that lacks ittwist#
Every number in this chapter came from one of these tools, and each is one command:
| Tool | Command | What it costs |
|---|---|---|
| the container's view | docker stats, or memory.current in the cgroup | nothing |
| the JVM's account | jcmd 1 VM.native_memory summary, after -XX:NativeMemoryTracking=summary | 5 to 10 percent of CPU while tracking is on |
| the growth since a moment | jcmd 1 VM.native_memory baseline, then summary.diff | the same |
| the heap's shape | jcmd 1 GC.heap_info | nothing |
| the GC log | -Xlog:gc*:file=/tmp/gc.log | a file that grows, rotated by default at 20 MB |
| the class histogram | jcmd 1 GC.class_histogram | one full collection |
| a heap dump | jcmd 1 GC.heap_dump /tmp/heap.hprof | one full collection, and a file the size of the heap |
| the long-lived objects | a Flight Recorder recording with path-to-gc-roots=true | a full collection when the recording ends |
The service's own image has none of the jcmd commands. It is built on
eclipse-temurin:25-jre, a Java Runtime Environment (JRE) image, and the run checked
what that image holds. Its
bin folder has six tools, among them java and jfr. It has no jcmd, because the module jdk.jcmd is not in a JRE. The other
diagnostic tools of that module are missing with it. The runs of this chapter therefore used a second image, with the service's own layers copied
into the JDK image. In that image jcmd exists inside the container. A team has three ways out. Build the production image on the JDK image, which is
bigger. Build a runtime with jlink and add the module jdk.jcmd, which
the run tried and got a 58 MB runtime. Or keep the JRE and use Flight Recorder, whose
jfr tool the JRE does have, and the next chapter does.
One more lesson came from a run that went wrong. The run first took the dump inside the 512 MB container, and the container was killed while the file was written. A file written inside the container goes through the kernel's page cache, and the cgroup counts that cache. The dump was the last straw, and the container was killed. Write dumps to a mounted volume, and know that the write itself needs room.
Whichever way, the time to capture is before the restart. A heap dump, an NMT summary and the last GC log of a dying container are worth more than a day of graphs. All three are gone with the container.
What you can see here#
The bin folder of the image, listed with ls. And
java --list-modules, which says whether the two diagnostic modules are there before
you need them.
What you can tune here#
The base image, between the JRE and the JDK, or a jlink runtime with the two
modules. And a volume for the dumps, named in -XX:HeapDumpPath, so that a dump
written by a dying process survives it.
1A service in a 512 MB container with -XX:MaxRAMPercentage=75 is killed every
night with exit code 137 and nothing in its log. The heap graph never reached its maximum.
What happened, and what is the first thing to change?
-XX:MaxRAMPercentage=75 is killed every
night with exit code 137 and nothing in its log. The heap graph never reached its maximum.
What happened, and what is the first thing to change?SIGKILL. No Java code runs after it. Lower the percentage so that heap
plus non-heap fits with a margin, which for this service is half.2An expert asksThe same leak ran twice in the same container. One run ended with OutOfMemoryError
and exit code 3. The other ended with a kill and exit code 137. Why?
OutOfMemoryError
and exit code 3. The other ended with a kill and exit code 137. Why?OutOfMemoryError when the heap reaches its
own maximum. The kernel kills the process when the whole container reaches its limit. With a
heap maximum of 256 MB the heap filled first, and the exit flag gave code 3. With 384 MB the
container filled first, before the heap was full. Swap can hide the kill on a laptop, because
the kernel pushes pages out instead.3A service's heap is flat, but its resident memory grows until the container kills it.
Which two JVM accounts show where the memory went?
jcmd 1 VM.native_memory summary.diff against
a baseline, which shows the region that grew, such as Other for direct buffers or
Metaspace for classes. And the Actuator gauges for the non-heap pools and the
buffer pools, such as jvm.buffer.memory.used, over time.4TranslateTranslate this .NET container setting into the JVM flag for the order service's Dockerfile.
The setting limits the GC heap to 60 percent of the container's memory.
{
"configProperties": {
"System.GC.HeapHardLimitPercent": 60
}
}
{
"configProperties": {
"System.GC.HeapHardLimitPercent": 60
}
}JAVA_TOOL_OPTIONS.
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=60 -XX:+ExitOnOutOfMemoryError"The JVM under the service: code, threads and the compiler#
The order service answers its first request in 0.31 seconds and its second in 0.04. For
minutes after every deploy it is slow, and then it is fast. In a quiet minute, one request in a
thousand takes ten times the median. This chapter follows one method,
OrderService.place, from its class file to
machine code and back. The numbers come from one run of the service under load.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | CIL and metadata in a PE assembly, not verified for type safety at run time | bytecode and a constant pool in a class file, verified when the class is linked | both runtimes load a class the first time it is needed, and both keep the source's names in the binary Why |
| twist | a struct lives inline, and a class carries a 16-byte header on x64 | every object carries a 12-byte header, and a record is a class | compressed class pointers make the header 12 bytes and not 16, and compact headers make it 8 when switched on Why |
| twist | two tiers, quick JIT and then the optimising JIT, with tiered PGO | five tiers, the interpreter, three forms of C1 and then C2 | the interpreter runs the first 200 calls, C1 the next thousands, and C2 compiles the method after 5,000 Why |
| trap | ReadyToRun code ships in the app, so the first minute is fine | nothing ships but bytecode, so the first minutes belong to the compilers | the run made 17,848 compilations in its first minute on one CPU, and the ahead-of-time cache is the answer Why |
| twist | guarded devirtualisation, from the profile of tier 0 | inlining behind a class check, and deoptimisation when the check fails | the run had 516 deoptimisations, and none of them was in the service's own code Why |
| new | no log of the stops | -Xlog:safepoint, one line per stop with the time to reach it | every thread stops at a poll, for the collector and for a thread dump alike, and the slow request waited for one Why |
| twist | a thread is an operating-system thread, and async/await frees it while it waits | a platform thread is the same, and a virtual thread is an object that the JVM mounts on a carrier | the code stays blocking, and the thread under it is taken away while it waits Why |
| trap | await inside lock is a compile error | synchronized inside a virtual thread pinned its carrier until Java 24 | Java 25 no longer pins on synchronized, and the recording of the service on virtual threads showed no pinned thread Why |
| twist | dotnet-trace and dotnet-counters | Flight Recorder, started with one flag and read with the jfr tool | the recording costs about one percent, the JRE image has the tool, and the reading happens on your machine Why |
Scene 1: the class filetwist#
The chapter before this one cleared the heap. The service
did not die of memory, and when it was killed, the kernel did it. This chapter is about the
other half of the Java Virtual Machine (JVM), the half that
runs code. It has two mysteries. After every deploy, the service is slow for minutes, and the
graphs show it. And in a quiet minute with a median of 19 ms, one request took 241 ms. The
protagonist is one method, place in OrderService. Scene 1 reads its
bytes. Scene 7 reads a recording of it. For the first look at how Java runs a program, see the
handbook chapter How Java runs your code.
The run. On 26 October 2026 the service ran from its image inside an image of the
Java Development Kit (JDK) 25.0.4.1. The host was Docker
27.5.1 on a laptop with an Intel i9-8950HK. The container had one CPU and 512 MB. The JVM ran
with three diagnostic flags: -XX:+PrintCompilation, -Xlog:safepoint
and a Flight Recorder recording. A script sent requests for two
minutes, then timed each request for one more minute. A second, shorter run put Tomcat on
virtual threads. The numbers below come from those
two runs, unless the text names another chapter. The script is companion/runs/jit/run.sh.
Here is the protagonist as the author wrote it. It checks the delivery method, reserves stock outside the transaction, and then places the order and its first notes in one transaction:
public PlacedOrder place(CustomerContact customer, DeliveryMethod deliveryMethod, List<OrderItem> items,
List<NewNote> firstNotes) {
referenceData.requireActive(ReferenceTypes.DELIVERY_METHOD, deliveryMethod.code(), "deliveryMethod");
OrderId orderId = OrderId.newId();
StockAvailability availability = stock.reserve(orderId, items); // outside the transaction
return transaction.execute(status -> {
Order order = Order.place(orderId, customer, deliveryMethod, items, availability, clock);
Order saved = saveAndPublish(order, AuditActions.ORDER_PLACED); // aggregate 1
List<String> noteIds = notes.addNotes(saved.id().value(), firstNotes); // aggregate 2, same transaction
return new PlacedOrder(saved.id().value(), noteIds);
});
}Two calls in it matter for this chapter. stock.reserve goes through an
interface, the port to the stock service. transaction.execute receives a
lambda.
The compiler javac turned the ten lines into 60 bytes of
bytecode, and the run printed them with
javap. Each line is one instruction. The number at the left is its offset in the
method, and the comment at the right is the constant pool entry it names:
public PlacedOrder place(CustomerContact, DeliveryMethod, List<OrderItem>, List<NewNote>);
Code:
0: aload_0
1: getfield #17 // Field referenceData:ReferenceDataService
4: ldc #47 // String DELIVERY_METHOD
6: aload_2
7: invokevirtual #49 // Method DeliveryMethod.code:()String
10: ldc #55 // String deliveryMethod
12: invokevirtual #57 // Method ReferenceDataService.requireActive
15: invokestatic #63 // Method OrderId.newId:()OrderId
18: astore 5
20: aload_0
21: getfield #21 // Field stock:StockChecker
24: aload 5
26: aload_3
27: invokeinterface #69, 3 // InterfaceMethod StockChecker.reserve
32: astore 6
34: aload_0
35: getfield #37 // Field transaction:TransactionTemplate
38: aload_0
...
48: invokedynamic #75, 0 // InvokeDynamic #0:doInTransaction
53: invokevirtual #79 // Method TransactionTemplate.execute
56: checkcast #85 // class PlacedOrder
59: areturnThe output is shortened to one line per idea, and the
package names are cut. The JVM is a stack machine, like
the Common Language Runtime (CLR). aload_0 pushes this, and
getfield replaces it with the field. A call takes its arguments from the stack.
Four kinds of call appear in sixty bytes. The fifth kind, invokespecial, calls
constructors and private methods.
invokevirtual calls a method on a class, and the JVM picks the override.
invokestatic calls a static method, and the target is known at link time.
invokeinterface calls through an interface, and here the interface is the port
StockChecker. invokedynamic is the lambda. It asks a bootstrap method for the call site at
first use, and the lambda class exists only from then on. The checkcast at the end
is type erasure at work. execute returns
a type parameter, so the compiler inserted the cast to PlacedOrder.
The bytes are stored in a class file. Its layout is fixed by the JVM specification, and it
opens with the magic number CAFEBABE. Then come two version numbers, and a Java 25
compiler writes major version 69. A Java 25 JVM accepts every major version from 45 to 69, so
old class files run and newer ones are refused. Then the constant pool, which holds every name,
every type and every string the class uses. The #17 in the listing is an index into
it. Then the fields and the methods. The bytecode of a method is in its Code
attribute, behind two numbers. One is the depth of operand stack the method needs, and one is
the number of local variable slots. The astore 5 above writes slot five. The names
are all there. A class file is not stripped, which is why javap and every stack
trace can name the method.
The C# reader has seen this before. A C# compiler emits Common Intermediate Language (CIL) plus metadata into a portable executable (PE) file. The metadata is the constant pool by another name. Both runtimes are stack machines. The difference is where the checking happens. Modern .NET does not verify CIL for type safety at run time. Its just-in-time (JIT) compiler rejects only invalid CIL. The JVM verifies every class when it links it, before any method in it runs.
Loading has three steps, and the JVM does them lazily. Loading finds the bytes and makes a
class from them. Linking verifies the class, prepares its static fields, and resolves the
symbolic names in the constant pool. Initialisation runs the
static initialiser. HotSpot waits until a class
is first needed before it loads and links it. It resolves each constant pool entry when an
instruction first uses it. So the #69 above is a name until the first order is
placed. Then the JVM looks up StockChecker.reserve, finds it, and replaces the
lookup with the answer. The first request pays for every lookup on its path. The verifier
reads the bytecode once, because a class file since version 50 carries a
StackMapTable with the types at each branch target. A class that fails
verification throws VerifyError and never links. The histogram in the next scene
counts 23,858 java.lang.Class objects in the running service. The recording counts
22,463 loaded classes, and most of them belong to Spring and the JDK. The other
Class objects stand for arrays and primitive types.
What you can see here#
javap -c -p on a class in the jar, which prints the bytecode of every method.
-v adds the constant pool and the version. The run ran it inside the container
against /app/BOOT-INF/classes, where the
layered jar keeps the service's own classes.
-Xlog:class+load prints one line per loaded class, with its source. The source
says whether the class came from a jar or from the
class data sharing (CDS) archive.
What you can tune here#
AOT is short for ahead-of-time, and the
AOT cache is the first answer to the slow minutes. Java 24 can
save the loaded and linked classes of a training run, and Java 25 saves their method profiles
too. Spring Boot documents it in three commands. Extract the jar with
java -Djarmode=tools -jar order-service.jar extract. Run the extracted application
once with -XX:AOTCacheOutput=app.aot and -Dspring.context.exit=onRefresh.
Then start it with -XX:AOTCache=app.aot. The training run must use the extracted
form, or the cache has no effect. The handbook chapter
Packaging and native images places it against the other
packaging options. The AOT cache does not cache compiled code. Scene 3 says what it does for the
compilers instead.
Scene 2: the objecttwist#
place makes objects. An OrderId, an Order, a
PlacedOrder, the lambda, and many that the libraries make for it. Scene 3 of the
memory chapter followed one of them into a TLAB. This scene
looks at its shape. The run asked the JVM for a class histogram after three minutes of load,
and the top of it is this:
num #instances #bytes class name (module)
-------------------------------------------------------
1: 159218 15178528 [B (java.base@25.0.4.1)
2: 151241 3629784 java.lang.String (java.base@25.0.4.1)
3: 23858 3010528 java.lang.Class (java.base@25.0.4.1)
4: 78990 2527680 java.util.concurrent.ConcurrentHashMap$Node (java.base@25.0.4.1)
5: 27348 2406624 java.lang.reflect.Method (java.base@25.0.4.1)The first line is byte arrays, written [B, and most of them are the text of
strings. The second line is the strings themselves. Divide the bytes by the instances, and a
String is 24 bytes and a ConcurrentHashMap$Node is 32. Those sizes
come from three rules. Every object starts with a header. Every reference inside it is 4 bytes
in this service. And every object is padded to a multiple of 8.
The header is two words. The mark word is 8 bytes. It holds the lock state, the identity
hash code and the age of the object for the collector. The class word points to the class, and
it is 4 bytes because compressed class pointers are on by default. So the header is 12 bytes.
A String has a reference to its byte array, an int hash and two
small fields. That is about 22 bytes, and the padding makes it 24. The C# reader knows the
same arithmetic with different numbers. A .NET object on x64 carries a 16-byte header, the
object header word and the method table pointer, and a reference is 8 bytes.
Java references are 4 bytes because of compressed ordinary object pointers. The flags of the
run showed them as -XX:+UseCompressedOops. The JVM stores a reference as a 32-bit
offset from the heap base, in units of 8 bytes, which reaches 32 GB. Any heap below 32 GB gets
them by default. Above that, a reference is 8 bytes and every object with references grows.
Compressed class pointers are the same idea for the class word. Their flag is deprecated in
Java 25, and they are on by default.
Java 25 can make the header 8 bytes. Compact object headers fold a 22-bit class pointer
into the mark word, with the hash code and the collector's bits beside it. They were
experimental in Java 24 and are a product feature in 25. The flag is
-XX:+UseCompactObjectHeaders, and it is still off by default. The reference says
the flag saves 4 bytes per object on average and often improves performance. A future release
is expected to turn it on. The histogram above has 159,218 arrays and 151,241 strings. Those
two lines alone would lose about 1.2 MB of headers.
Now the twist for the C# reader. Java has no struct. OrderId is a
record, and a record is a class that cannot be extended,
with java.lang.Record as its superclass. Its instance carries the full header, and
a field of that type is a 4-byte reference to it. An Order with an
OrderId, a customer and a list of lines is a small graph of objects, not one
block. Value classes, which would let the JVM flatten some of them, are a
preview feature targeted at Java 28. What Java
has instead is the compiler's escape analysis, which scene 4 reaches. When C2 can prove that an
object never leaves the compiled method, it allocates nothing and keeps the fields in
registers. The status lambda above escapes, because execute receives
it. A small record that a hot method makes and reads and drops often does not.
What you can see here#
jcmd 1 GC.class_histogram, inside the container. Its impact is a full
collection, and the memory chapter met the pause it causes. For one class, the Java Object
Layout tool prints the real layout with the header, as
java -jar jol-cli.jar internals com.acme.orderservice.order.domain.model.Order.
The flags that jcmd 1 VM.flags prints include UseCompressedOops. When
you set it, they include UseCompactObjectHeaders too.
What you can tune here#
-XX:+UseCompactObjectHeaders, after a test run, because it changes every
object. Nothing else in the layout is worth a flag. Keep the heap below 32 GB, so references
stay at 4 bytes, and the memory chapter gave the service 256 MB.
Scene 3: the interpreter and the two compilerstwist#
Now the first mystery. The service starts, and for minutes it is slow. The run saw it. The
first request of the startup chapter took 0.31 seconds and the second 0.04. In this run, with
one CPU, Spring reported Started after 31 seconds, where the laptop needs sixteen.
The reason is in the first lines the JVM printed:
242 1 3 java.lang.Object::<init> (1 bytes)
275 2 3 java.lang.Byte::toUnsignedInt (6 bytes)
277 3 3 java.lang.String::hashCode (60 bytes)
285 4 n 0 jdk.internal.misc.Unsafe::getReferenceVolatile (native)
328 27 4 java.lang.String::charAt (25 bytes)
330 7 3 java.lang.String::charAt (25 bytes) made not entrant: not used
371 67 ! 3 java.util.concurrent.ConcurrentHashMap::putVal (432 bytes)
593 196 % 3 java.lang.StringCoding::countPositives @ 7 (33 bytes)These lines come from -XX:+PrintCompilation, and no reference document explains
their columns, so here they are from the HotSpot source. The first number is the time in
milliseconds since the JVM started. The second is the compile id. Then up to five marks.
% is on-stack replacement, and s a
synchronized method. ! marks a method
with exception handlers, b a blocking compile and n a native wrapper.
Then the tier, then the method and its bytecode size, then a message. So
String::hashCode was compiled at tier 3 after 277 ms. String::charAt
reached tier 4 after 328 ms, and two milliseconds later its tier 3 code was dropped as
not used. And countPositives was compiled while a loop in it was
running, with @ 7 naming the bytecode offset where the compiled code took over.
The JVM starts every method in the interpreter. HotSpot's interpreter is generated at
startup from a template for each bytecode, and it runs the method one instruction at a time.
While it runs, it counts. Each method has an invocation counter and a back-edge counter, and
the back-edge counter counts loop iterations. The counts decide when the method moves to a
compiler. There are two compilers, and together they are the
just-in-time (JIT) compiler of the JVM. C1 compiles fast and
optimises lightly. C2 compiles slowly and optimises hard, with the profile that C1's code
collected. Together they make five tiers. Tier 0 is the interpreter. Tier 1 is C1 without
profiling, for methods whose profile would not help. Tier 2 is C1 with the two counters only.
Tier 3 is C1 with full profiling, which records which branches are taken and which classes
appear at each call. Tier 4 is C2. The normal path is 0, then 3, then 4. Tier 2 appears when
the C2 queue is long, because tier 2 code runs faster than tier 3 code. Tier 1 is the end for
trivial methods. The run compiled the accessors of the request record at tier 1 and left them
there. PlaceOrderRequest::customer, with its 5 bytes, is one of them.
The thresholds are in the HotSpot source, not in any manual. A method goes to tier 3 after 200 invocations. It also goes after 100 invocations when invocations plus loop iterations pass 2,000. It goes to tier 4 after 5,000 invocations, or after 600 when the sum passes 15,000. A loop alone sends a method to on-stack replacement after 60,000 iterations at tier 3. Each threshold is scaled up when the compile queue is long. A busy JVM therefore waits longer before it compiles. The CLR has two tiers in place of five. Tier 0 is the quick JIT and tier 1 the optimising JIT, with dynamic PGO adding the profile from tier 0. ReadyToRun code counts as tier 0 and is replaced when a method gets hot. Java has nothing that counts as compiled code at start. The jar carries bytecode, and every method begins in the interpreter after every deploy.
Here is what that cost the service. The run lasted 217 seconds and the compilation log has
24,992 compilations in it. 8,420 of them happened before Started. By minute, the
compilations were 17,848, then 4,391, then 1,836, then 917. The tier 4 compilations alone were
1,910, then 1,362, then 707, then 273. The recording adds the price. Its compiler statistics
report a total compile time of 1 minute 47 seconds, in a run of 217 seconds, on one CPU. The
JVM had chosen two compiler threads, as CICompilerCount=2 in its flags. Their compile tasks lasted 1 minute 47 seconds in
total, most of them in the first minutes. The run did not measure their share of the CPU. But
the request threads shared that one CPU with them, and the request code itself still ran in the
interpreter or in C1 code. That is the slow
start, and it has two parts. The code is slow until it is compiled, and compiling it is work
on the same CPU.
The protagonist shows the scale. OrderService::place was called about 2,600
times in the run, once per order. Its line in the log is one:
131048 22717 3 com.acme.orderservice.order.application.OrderService::place (60 bytes)The line puts OrderService::place at tier 3, after 131 seconds, with compile id
22717. The method never reached C2 in the run, because 2,600 calls are below the
tier 4 threshold of 5,000. At 50 orders a second in production it would reach C2 within two
minutes of the deploy. Fourteen methods of the service's own code did reach tier 4. Seven of them
were property accessors that Spring Data generates. Those run for every
field of every saved document. Everything else at tier 4 was the JDK, Spring and the drivers.
Compiled code is kept in the code cache, outside the heap. The memory chapter counted it at 58 MB committed. The run asked for its layout:
CodeHeap 'non-profiled nmethods': size=120036Kb used=10122Kb max_used=10482Kb free=109913Kb
CodeHeap 'profiled nmethods': size=120036Kb used=24904Kb max_used=33795Kb free=95131Kb
CodeHeap 'non-nmethods': size=5692Kb used=1553Kb max_used=1633Kb free=4139Kb
CodeCache: size=245764Kb, used=36579Kb, max_used=45910Kb, free=209183KbThe CodeCache line is the whole. It is 240 MB reserved, the default with tiered
compilation, and it is split in three since Java 9. The 'profiled nmethods' heap
takes tier 2 and 3 code. The counters make that code large, and tier 4 replaces it soon.
Its high mark was 33.8 MB and it had 24.9 MB at the end. The difference is
the not used code that was dropped. The non-profiled heap takes tier 1 and 4 code,
which stays. The third heap takes the interpreter itself and the compilers' buffers. A full
code cache stops the compilers. The JVM logs it and raises a Flight Recorder event named
jdk.CodeCacheFull. The service used 37 MB of 240, and the cache never filled. But
the code cache can stop the service in another way, and scene 5 finds it.
What you can see here#
-XX:+PrintCompilation, whose columns are above, written to the container's
standard output. jcmd 1 Compiler.codecache for the layout, and
jcmd 1 Compiler.queue for the methods waiting. In a recording, the view
compiler-statistics gives the totals and longest-compilations the
methods that cost most. In the run the longest single compilation was 1.87 seconds, for a
Spring caching method at tier 4. Five of the ten longest were one
bean factory method, compiled again and again. Scene 4 says
why.
What you can tune here#
Not the thresholds. -XX:CompileThresholdScaling moves them all at once, and a
service that needs it is a service without a warm-up
plan. -XX:CICompilerCount sets the compiler threads, and the JVM's choice fits the
CPUs it sees. The real knob is the AOT cache of scene 1. Java 25 stores the method profiles of
the training run in it, so the JIT starts earlier and with better information. It still
compiles. The other real knob is the deploy itself. A rolling deploy that sends full traffic to
a new instance at once makes the first minutes the user's problem. Warm the instance with its
own traffic first. And never -XX:TieredStopAtLevel=1 for a service. It is for a
tool that runs for seconds.
Scene 4: the compiler's betstwist#
C2 is fast because it bets. It reads the profile that tier 3 collected and compiles the
method for the cases the profile saw. Three bets matter in place.
The first bet is inlining. A call costs a frame, and it hides the callee from every
optimisation of the caller. So C2 copies small callees into the caller. Any method up to 35
bytes of bytecode is inlined where it is called. A hot method is inlined up to a platform
limit, which is 325 bytes in the reference's example. C2 inlines up to fifteen levels deep.
deliveryMethod.code() is a record accessor of a few bytes, and
OrderId.newId() is a small static method. Both disappear into place
when C2 compiles it. The CLR inlines too, with its own limits, and the C# reader has written
AggressiveInlining for a reason.
The second bet is about the calls that cannot be resolved at compile time.
stock.reserve is an invokeinterface. Which class answers it is a
run-time question, and a virtual call through a table is the slow way to answer it. C2 has two
ways around that. Class hierarchy analysis asks the JVM whether only one loaded class
implements the interface. In the running service one adapter
implements the port. So the call becomes a direct call, and the compiled code records a
dependency on that fact. If the profile shows one receiver class at the call site, C2 inlines
that class's method behind a class check. With two classes it inlines both, with two checks.
The source calls these sites monomorphic and bimorphic. With more it leaves a virtual call,
which the source calls megamorphic. When a call site has seen more than two
classes, C2 still inlines the most common one if it covers 90 percent of the calls. Since .NET 8 the CLR makes the same bet by default, under the
name guarded devirtualisation, from the profile of an instrumented tier. In both runtimes, an interface with one
implementation on a hot path is free. An instanceof chain over five types pays a
virtual call.
The third bet is about branches. A branch that the profile never saw taken is compiled as an uncommon trap. The compiled code for that path is a jump out of the compiled method. The same trick removes null checks that never failed and array bounds checks that never failed. Escape analysis is the bet that an object never leaves the method, and it removes the allocation and any lock on that object.
Every bet has a check, and when a check fails the compiled code cannot continue. The JVM
then deoptimises. It converts the compiled frame back into interpreter frames, at the exact
bytecode, with every local variable restored. The method continues in the interpreter as if it
had never been compiled. The source calls this an uncommon trap when C2 planted it. Each trap
carries a reason and an action. The reasons say which bet failed, and the actions say what to
do with the compiled code. maybe_recompile keeps the code and asks for a new
compilation with the new profile. reinterpret throws the code away and resets the
inline cache of the call, and the JVM may compile the method again later. make_not_entrant throws it away and recompiles. After 400 recompilations
of one method the JVM gives up and leaves it interpreted. The recording of the run has 516
deoptimisation events, and the view by reason
sorts them:
Reason Count
----------------------------------- -----
unstable_if 170
bimorphic_or_optimized_type_check 103
class_check 98
array_check 58
profile_predicate 29
speculate_class_check 24
predicate 9
null_check 8unstable_if is the third bet failing. A branch that was predicted never taken
was taken. class_check and the bimorphic reason are the second bet failing. A new
receiver class arrived at an inlined call site. array_check is an array store of
a type the compiled code did not expect, and null_check is a null where none had
been seen. The first event of the recording is a good example of the second bet:
jdk.Deoptimization {
startTime = 21:30:39.895
compileId = 1251
compiler = "c2"
method = java.util.Collections$UnmodifiableCollection$1.hasNext()
lineNumber = 1079
bci = 4
instruction = "invokeinterface"
reason = "class_check"
action = "maybe_recompile"
eventThread = "main" (javaThreadId = 3)
}Three seconds into the start, C2 had compiled the hasNext() of an unmodifiable
collection wrapper. The method calls hasNext on the iterator it wraps, through an
interface, at bytecode index 4. The profile had seen one iterator class, so C2 inlined it. A
second iterator class appeared, the check failed, and the method went back to the interpreter
with a request to recompile. The stack trace shows that the recording's own start-up code
made the call, not the service. Methods that many callers share live this way during a start.
The bean factory method of scene 3 is the extreme case. AbstractBeanFactory::isTypeMatch
is 776 bytes of bytecode. The log shows it compiled at tier 4 seven times, with eleven
made not entrant lines between. Five of the seven tier 4 compilations took about a second each. Spring
calls this method for every type it looks up during startup, and each new type can break the
bet of the last compilation. So the seven compilations of one method lasted about 6.6 seconds in all, during the thirty seconds the service took to start. The JVM is not wrong to do it.
After startup the method settles, and its final code stays.
The log counts the fallout. There are 6,413 made not entrant lines in it. Most
of them, 5,823, say not used. That is tier 3 code replaced by tier 4. Another 299
say uncommon trap, a bet that failed. Another 265 say
marked for deoptimization. That is a dependency that a class load broke, such as a
second implementor of an interface that had one. The remaining 26 are on-stack replacement and patching cases. And the service's own code
appears in none of the 516 events. Most of it never reached C2, so C2 made few bets on it.
What you can see here#
The event jdk.Deoptimization, on in the default recording settings and with
stack traces in the profile settings. jfr view deoptimizations-by-reason and
deoptimizations-by-site summarise them. In the compilation log, the
made not entrant lines with their reason. With
-XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation, the JVM writes every inlining
decision to hotspot.log, for the day you must know whether one call was
inlined.
What you can tune here#
Nothing, in the JVM. Compiler directives exist, and jcmd 1 Compiler.directives_add
loads them, and a service should not need them. Tune the code instead. Keep one implementation
behind each interface on a hot path, and keep the test doubles in the tests. Keep
instanceof chains and switch over types out of the hottest loop.
Make small objects and let escape analysis remove them. Then measure, because the compiler
makes the bets and not the author.
Scene 5: the stop, and the slow requesttrap#
The second mystery. In the timed minute, 1,433 orders were placed. The median took 19 ms, the 99th percentile 56 ms, and the slowest 241 ms. The slowest did nothing different from the others. It waited.
Many things the JVM does need every Java thread to stand still. A collector that moves objects must know that no thread holds a reference it is about to move. A thread dump must read every stack while no stack changes. The JVM does this with a safepoint. A safepoint is a point in the code where the state of the thread is fully known. Every reference on its stack is accounted for, and the heap is consistent. HotSpot does not interrupt threads to get there. It asks them. Compiled code polls a page when a method returns, and some loops poll too. The interpreter switches to a second table of instruction handlers, with the question in each. A thread in native code is stopped on its way back. When the JVM arms the poll, each thread blocks at the next poll it reaches. When the last one has blocked, the JVM is at a safepoint and does its work. Then it releases them all.
The run logged every safepoint with -Xlog:safepoint, and the first line of
the log reads so:
[0.652s][info][safepoint] Safepoint "SerialCollectForAllocation", Time since last: 584265372 ns,
Reaching safepoint: 553092 ns, At safepoint: 1857472 ns, Leaving safepoint: 993898 ns,
Total: 3404462 ns, Threads: 2 runnable, 9 totalThe line is one in the file, and it is wrapped here. The name in quotes is the operation
that asked for the stop, and this one is a young collection of the Serial collector. The
numbers are the three parts of a stop. Reaching safepoint is the time from arming
the poll to the last thread blocking, 0.55 ms here. At safepoint is the work,
1.86 ms. Leaving safepoint is the wake-up. The reaching time is the one that
monitoring hides. A collector reports its pause as its work. The time the JVM spent waiting for
one thread to reach a poll is not in that number. In the run the longest reaching time was
49 ms, before an ordinary young collection. One thread was somewhere without a poll for 49 ms,
and every other thread waited for it.
The log has 196 safepoints in 217 seconds, and they sort by name:
| Operation | Count | What asked for the stop |
|---|---|---|
| SerialCollectForAllocation | 176 | a young collection, because a TLAB could not be refilled |
| CollectForMetadataAllocation | 4 | a collection because metaspace needed room, during and just after startup |
| SerialGCCollect | 1 | a full collection, which the code cache asked for |
| GC_HeapInspection | 1 | the class histogram of scene 2, taken after the timed minute |
| PrintThreads and FindDeadlocks | 3 each | the thread dump that the recording takes once a minute |
| ClassLoaderStatsOperation | 3 | class loader statistics for the recording |
| JFR operations | 4 | the recording's own bookkeeping |
| SetNotifyJvmtiEventsMode | 1 | the tooling interface, at startup |
Two lessons are in the table. The first is that the collector is not the only one who
stops the world. A thread dump is a safepoint, and the recording took three of them. The class
histogram of scene 2 was one, GC_HeapInspection, and it stopped every thread for
772 ms. The memory chapter met the same pause
and called it the pause you caused yourself. Since Java 10, some operations that stopped every
thread now stop one at a time, through a handshake. The view counts 73 of those as
HandshakeAllThreads, with a longest of 3.4 ms. The second lesson is the slow
request.
Here are the pauses of the run that lasted longer than 100 ms, with their time since the JVM started. The histogram's pause at 213.7 s is left out, because the script asked for it:
9.7 s CollectForMetadataAllocation 104 ms
16.2 s CollectForMetadataAllocation 101 ms
22.2 s CollectForMetadataAllocation 140 ms
36.1 s CollectForMetadataAllocation 238 ms
174.2 s SerialGCCollect 193 msThe four CollectForMetadataAllocation pauses are in the first 36 seconds. The
service loaded about 22,000 classes then, and metaspace
grew in steps. Each step was a collection, and the longest took 238 ms. That is the slow start
again, from another side. The SerialGCCollect is the one that matters. At 174
seconds, inside the timed minute, the Serial collector ran a full collection. Every thread
stood still for 193 ms. The heap did not ask for it. The recording names the cause of every
collection, and the run wrote them to jfr-gc-causes.txt:
startTime = 21:33:31.803 name = "SerialOld" cause = "CodeCache GC Threshold" sumOfPauses = 193 msThe cause is CodeCache GC Threshold. In the HotSpot source, the code cache asks for
a collection when the compilers have filled a set share of it since the last collection. The
collection lets the JVM unload compiled code that is no longer used. On Serial, the collection
was a full one. So the compilers of scene 3, still compiling in the third minute, caused the stop
of scene 5. The two mysteries of the chapter are one story. The script recorded durations and not clock times, so the log cannot
name the request that waited. The arithmetic can. A normal request takes 19 ms. Add 193 ms of
stop, and a request in flight when the poll was armed took about 212 ms. The script sent one
request at a time, so no other request waited behind it. The slowest request took 241 ms. The recording's view of pauses
agrees from the other side. Its 172 pauses have a median of 4.2 ms, a 99th percentile of 211 ms
and a maximum of 237 ms. One request in a thousand is one full collection in a quiet minute.
The CLR stops managed threads for its collector too. Java gives the stop a name, a log line, and three
numbers.
The memory chapter said that a container with one CPU gets the Serial collector, and that its pauses are then the service's business. This is what that sentence costs. A young collection of Serial is a few milliseconds, and the service pays 176 of them without anyone noticing. A full collection is the whole old generation, in one thread, with everything stopped. On two CPUs and 2 GB the JVM chooses G1, whose pause target is 200 ms by default and whose young collections are parallel. With a larger heap and more cores, ZGC aims to keep every pause below a millisecond, because it moves objects while the threads run. The choice is the container's size, and the memory chapter's arithmetic sets the size. Fewer late compilations help too, and the AOT cache of scene 1 gives the compilers their profiles earlier.
What you can see here#
-Xlog:safepoint, one line per stop with the three times, written to a file
with -Xlog:safepoint:file=/tmp/safepoint.log. In a recording, the view
safepoints and the view vm-operations, which names what asked for
each stop, with the longest duration of each kind. The event jdk.SafepointBegin
is on in the default settings for any safepoint over 10 ms. And the view gc-pauses
for the collector's share, with its percentiles.
What you can tune here#
The collector, through the container's size, as the memory chapter showed. Nothing about safepoints themselves. A flag exists that keeps a poll in every counted loop. Its default depends on the collector, and the JVM knows better than the author when to set it. A thread dump or a class histogram in production is a stop of every thread. Take one before a restart, not one every minute.
Scene 6: the threadstwist#
The request that waited 193 ms was running on a thread, and this scene is about which. The
run dumped every thread of the service after three minutes of load, with
jcmd 1 Thread.dump_to_file -format=json. There were 52 of them. Tomcat owns the
largest group, and Spring's task executor the
next. The drivers for MongoDB and for the Amazon Simple Queue Service (SQS) bring their own. Sorted
into groups, they are these:
| Threads | Count | Who made them |
|---|---|---|
| http-nio-8080-exec-1 to 10 | 10 | Tomcat, ten kept ready even when idle, from a pool of up to 200 |
| http-nio-8080-Acceptor and Poller | 2 | Tomcat, to accept connections and to wait for bytes on them |
| task-1 to 8 | 8 | the application task executor of Spring Boot, for @Async and the event listeners |
| scheduling-1 | 1 | the task scheduler, for @Scheduled |
| cluster-* and cluster-rtt-* | 2 | the MongoDB driver, to watch the server and to measure its round trip |
| aws-java-sdk-NettyEventLoop-1-0 and -1 | 2 | the AWS SDK, for its asynchronous HTTP client |
| sqsListenerEndpointContainer#0 ... | 3 | Spring Cloud AWS, to poll the SQS queue and to run the listener |
| HttpClient-1-* | 4 | the JDK HTTP client, for the calls to the other services |
| JFR Recorder Thread and JFR Periodic Tasks | 2 | the recording |
| Reference Handler, Finalizer, Common-Cleaner, Signal Dispatcher, and others | 18 | the JVM, the JDK, Tomcat's utility threads and the tracing exporters |
Each of these is a platform thread. A platform
thread is an operating-system thread with a Java object in front of it. The JVM creates the
native thread when start() is called and gives it back when run()
returns. It reserves 1 MB of stack for each on Linux x64, as the memory chapter counted, and
the kernel schedules it. The C# reader has exactly this. A Thread in .NET is an
operating-system thread, and the thread pool lends them out. Tomcat keeps ten threads ready even
when idle, and the pool grows to 200 when more requests arrive at once. When all 200 are inside a
call to MongoDB, the next request waits in the acceptor's queue. A thread does nothing while it waits. It
keeps its stack, its kernel entry and its place in the pool, and the kernel does not run it.
The .NET answer to that waste is async and await. The method is
cut into pieces at each await. The thread goes back to the pool while the I/O is
in flight, and a continuation runs the next piece. The code changes shape to get there. Every
method on the path returns a Task, and lock around an await
is a compile error. Java took the other road in Java 21. A virtual thread is a
Thread with its stack on the heap, in chunk objects. The JVM mounts it on a
platform thread to run it, and that platform thread is called its
carrier. When the virtual thread blocks, on a
socket, a lock or a sleep, the JVM copies its frames to the heap and unmounts it. The carrier
takes another one. The code keeps its blocking shape. stock.reserve still returns
a value, and the method under it still blocks, and the thread under that is swapped out. The
carriers are a ForkJoinPool in first-in first-out mode. Its parallelism is the
number of CPUs by default, and it may grow to 256 platform threads when carriers are held.
Spring Boot switches the whole service with one setting,
spring.threads.virtual.enabled=true. Tomcat then runs each request on a new
virtual thread. The application task executor becomes one that starts a virtual thread per
task, and the scheduler does the same. The run started the service a second time with the
setting, and dumped its threads under load. There were 39 platform threads and three virtual
ones. The ten exec threads were gone, and so were the eight task
threads. In their place were two carriers, ForkJoinPool-1-worker-1 and
-3. A thread named VirtualThread-unblocker had appeared beside them.
The JSON lists the virtual threads under the executor that made them. Two containers hold
virtual threads. One holds the scheduler's thread, scheduling-1. This is the other,
with its threads shortened:
{
"container": "java.util.concurrent.ThreadPerTaskExecutor@644d4e23",
"parent": "<root>",
"threadCount": "2",
"threads": [
{ "tid": "48", "virtual": true, "name": "SubPoller-0", "state": "WAITING",
"stack": [ "java.base/java.lang.VirtualThread.park(VirtualThread.java:745)",
"java.base/sun.nio.ch.Poller.poll(Poller.java:197)" ] },
{ "tid": "49", "virtual": true, "name": "SubPoller-1", "state": "WAITING" }
]
}These two virtual threads are the SubPoller threads of the JDK itself. They wait
on sockets for every virtual thread that is blocked in a read, and they are virtual threads
too. No request was in flight at the moment of the dump, so no request thread appears. The log
shows a request thread named tomcat-handler-0. The acceptor
and the poller of Tomcat were still platform threads, because waiting on a selector is their
job.
The latencies did not improve, and the chapter would be dishonest to hide it. On platform threads the timed minute gave a median of 19 ms, a 99th percentile of 56 ms and a maximum of 241 ms. That was 1,433 orders. On virtual threads it gave 22 ms, 74 ms and 307 ms, for 1,250. The script sent one request at a time, so at most one request was in flight, and the container had one CPU. The virtual run also had one minute of warm-up, and the platform run had two. So the two minutes show no gain, but they do not prove a loss. A virtual thread saves a waiting platform thread, and the service was never short of one. The gain appears when ten thousand requests wait on a slow dependency at once. Then Tomcat's pool of 200 is the limit on platform threads, and no limit on virtual threads. The cost appears when the work is CPU. A virtual thread that computes holds its carrier, and there are as many carriers as CPUs.
The strict rule. A service whose requests block on a database and on other services runs them on virtual threads. A thread pool is then never the limit on concurrency.
What we do. The service leaves spring.threads.virtual.enabled
unset, so Tomcat runs requests on its pool of platform threads.
Why. The run showed no gain in a container with one CPU. The pool of 200 threads is far above the requests a 512 MB instance can have in flight. The MongoDB driver, the SQS listener and the AWS client bring their own threads either way. One setting fewer is one surprise fewer, and the switch is one line when it is needed.
When to stop. When the thread dump under load shows every exec
thread waiting on a socket and requests queueing at the acceptor. Set the property, run the
recording of scene 7, and look for pinned threads before
you ship it.
Pinning is the trap that the C# reader will have read about, and Java 25 has mostly closed
it. In Java 21, a virtual thread inside a synchronized block or method could not
be unmounted. It was pinned to its carrier, and the carrier waited with it. With few carriers,
a few pinned threads stalled the service. Java 24 changed the JVM. A virtual thread that blocks
on a monitor, or waits in Object.wait, is now unmounted like any other. What still
pins in Java 25 is native code that calls back into Java and then blocks. A block while a class
is loaded or initialised pins too. The event jdk.VirtualThreadPinned records every
pin over 20 ms, with the reason and the carrier. The recording of the second run has none. The
old tracing property jdk.tracePinnedThreads is gone, so the event is the way to
look.
One paragraph on what threads see, because the C# reader will ask. The Java memory model
is in the language specification, and it is a set of
happens-before rules. An unlock happens before the
next lock of the same monitor. A write to a volatile
field happens before every later read of it. start() happens before anything in
the started thread, and everything in a thread happens before join() on it
returns. A write that is not ordered by one of these rules may never be seen by another thread.
The rules are the same on a virtual thread, because they are rules of the language and not of
the carrier. The handbook chapter Locks, atomics and the
memory model gives the rules with examples.
What you can see here#
jcmd 1 Thread.dump_to_file -format=json /tmp/threads.json, which includes
virtual threads grouped by the executor that owns them. The older Thread.print
and a SIGQUIT to the process print only platform threads, and they will not show
you a request on a virtual thread. The six thread states, from NEW to
TERMINATED, are JVM states and not kernel states, so RUNNABLE may be
waiting for a CPU. The view thread-count of a recording shows the platform thread
count over time, and pinned-threads lists the pins.
What you can tune here#
spring.threads.virtual.enabled, after a run with the recording on. The
carrier pool's parallelism, jdk.virtualThreadScheduler.parallelism, which defaults
to the CPU count, and its maximum, which defaults to 256. The default is right for a service
that blocks, and a service that computes should keep platform threads. And
server.tomcat.threads.max for the pool on platform threads, whose default of 200
the memory chapter weighed against 1 MB of stack each.
Scene 7: the recording, as the endingnew#
Every number in scenes 3 to 6 that did not come from a log line came from one file, the
Flight Recorder recording. Flight Recorder is in the JDK since Java 11 and in the
Java Runtime Environment (JRE) image of the service. The run
started it with -XX:StartFlightRecording=filename=/tmp/recording.jfr,settings=profile.
The default settings cost under one percent and are meant to stay on in production. The
profile settings cost about two percent and record more. They add stack traces to
deoptimisations, and they sample methods every 10 ms instead of 20. The run used them. After
214 seconds the file was 26 MB, and its summary begins so:
Event Type Count Size (bytes)
=========================================================
jdk.ObjectAllocationSample 53522 892936
jdk.ThreadPark 27344 1079136
jdk.JavaExceptionThrow 15060 376989
jdk.SocketRead 13625 576941
jdk.NativeMethodSample 9004 115010
jdk.ExecutionSample 3851 49100
jdk.Deoptimization 516 13348
jdk.SafepointBegin 184 2736
jdk.Compilation 139 4291A recording is a list of typed events. Each has a timestamp and a thread, and often a stack
trace, and the summary counts them by type. jdk.ExecutionSample is the profiler.
Every 10 ms it writes the stack of each thread that is running Java code. The run has 3,851
samples in 214 seconds on one CPU, a sample of where the CPU time went. The view
hot-methods sorts them. No method of the service's own code appears in its top.
The top methods were a method handle allocation and hash map lookups, at about one percent
each. A logging filter had about half a percent. That is the profile of a service that spends its CPU in the framework
and its time in the database. jdk.ThreadPark counts threads that waited on a lock or a queue, 27,344 times,
mostly pool threads waiting for work. jdk.JavaExceptionThrow counts 15,060 exceptions in three and a
half minutes. That is a number to look at on another day. An exception that is thrown and
caught inside a library pays for a stack trace every time.
Reading a recording needs memory, and the container did not have it. The jfr
tool loads the file into a heap, and the 26 MB file needed more than the 512 MB container
could give. So the script copied the file out with docker cp and ran the tool on
the laptop's JDK with a 12 GB heap. That is the normal shape. Record in the container, and read
anywhere. The tool has four commands that matter. jfr summary is the table above.
jfr print --events jdk.Deoptimization prints each event of a type in full, as
scene 4 showed. jfr view hot-methods runs one of about seventy named views, and
this chapter used ten of them. And jfr print --json gives the events to a script.
The desktop tool, JDK Mission Control, opens the same file with graphs, and version 9.1.2 is
current.
The ending of the chapter is a runbook, because the recording is only useful if it exists when the question comes. These are the commands the service's team runs on a container that misbehaves, before anyone restarts it:
| Question | Command, inside the container, where the service is process 1 | Cost |
|---|---|---|
| is a recording running? | jcmd 1 JFR.check | nothing |
| start one for the next ten minutes | jcmd 1 JFR.start duration=10m filename=/dumps/now.jfr settings=profile | about two percent |
| write what the running recording has so far | jcmd 1 JFR.dump filename=/dumps/sofar.jfr | a file, and one safepoint |
| what is every thread doing? | jcmd 1 Thread.dump_to_file -format=json /dumps/threads.json | no stop of every thread in the run's safepoint log |
| is the heap the problem? | jcmd 1 GC.heap_info, and the memory chapter's commands | nothing |
| what did the compilers do? | jfr view compiler-statistics now.jfr, on your machine | nothing in the container |
| what stopped the world? | jfr view vm-operations now.jfr and jfr view safepoints now.jfr | the same |
| which bets failed? | jfr view deoptimizations-by-site now.jfr | the same |
| who is pinned? | jfr view pinned-threads now.jfr | the same |
/dumps is a mounted volume, for the reason the memory chapter gave. A file
written inside the container counts against its limit through the page cache. The commands
need jcmd, which the JRE image lacks and the JDK image has, and the memory chapter
weighed the three ways out. With the JRE image, a recording started at launch is the one that
is always there. Start it with -XX:StartFlightRecording and maxage=1h,
and dumponexit=true writes it when the JVM exits. It does not write it when the
kernel kills the process, because nothing runs after that.
And the two mysteries, closed. The slow minutes after a deploy are three things. The interpreter runs the code. Two compiler threads share the CPU with it. And the compilers compile the hot methods several times as the bets settle. The answer is the AOT cache and a warm-up before traffic, and not a flag on the compiler. The one slow request in a quiet minute waited for a full collection of the Serial collector. It stopped every thread for 193 ms, and the code cache asked for it. The safepoint log and the recording both show it. The answer is the same AOT cache, and a container size that gives the service a collector with short stops. The recording shows no service method among the hot methods or the deoptimisations.
What you can see here#
jcmd 1 JFR.check for the recordings that run. jfr summary for what
a file holds, jfr view for the seventy views, and jfr print for one
event at a time. java -XX:StartFlightRecording:help for the options of the flag,
and jfr --help view for the list of views.
What you can tune here#
The settings file, between default and profile, and
jfr configure to write your own. maxage and maxsize on
a recording that runs for the life of the process. And the volume the files go to.
1A Spring Boot service is slow for the first minutes after every deploy, and then fast. The
heap is quiet and the database is fast. Name the two causes in the JVM, and the fix that is
not a compiler flag.
2An expert asksWhy does the JVM throw away compiled code and run a method in the interpreter
again, when the compiled code was correct?
3In a timed minute, the median request takes 19 ms and one request takes 241 ms. Which two
sources show whether a stop of the whole JVM caused it, and what do they name?
-Xlog:safepoint, one line per stop. It names the
operation and gives the time to reach the safepoint and the time inside it. And a Flight
Recorder recording, whose views vm-operations and gc-pauses name what
asked for each stop and how long it lasted. In the run, a full collection of the Serial
collector stopped every thread for 193 ms inside that minute. Its cause, in the recording, was
the code cache.4Tomcat is switched to virtual threads with spring.threads.virtual.enabled=true.
What changes in a thread dump, and which event tells you whether a virtual thread is holding
its carrier?
spring.threads.virtual.enabled=true.
What changes in a thread dump, and which event tells you whether a virtual thread is holding
its carrier?http-nio-8080-exec pool threads and the task threads
disappear. In their place are a few ForkJoinPool carrier threads and an executor
that lists one virtual thread per request in flight. Only Thread.dump_to_file
shows the virtual threads. The event jdk.VirtualThreadPinned records every pin
over 20 ms with its reason and its carrier, and jfr view pinned-threads lists
them.5TranslateTranslate this C# into Java. It starts ten thousand tasks that each wait
on a slow call, without holding ten thousand operating-system threads.
var tasks = Enumerable.Range(0, 10_000)
.Select(i => Task.Run(async () => await CheckStockAsync(i)));
await Task.WhenAll(tasks);
var tasks = Enumerable.Range(0, 10_000)
.Select(i => Task.Run(async () => await CheckStockAsync(i)));
await Task.WhenAll(tasks);try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int item = i;
executor.submit(() -> checkStock(item));
}
}checkStock blocks on its socket as before. The JVM unmounts the virtual thread
while it waits, so ten thousand of them share as many carrier threads as the machine has
CPUs.Changing the service#
This chapter is for the day you change the service. It gives the checklists for a new service, a new feature and an existing service. It lists the files that each common task touches. It ends with the review checklist for a pull request. Then it gives short answers to "why do we do it this way?", each with a link to the chapter that explains it.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | no equivalent | a reading order for the part | read the why first, then one request through the layers, then the rest as reference Why |
| new | dotnet new from a team template | a new service from the parent POM, the platform library and seven steps | the parent and the library give every service the same build, security, errors and logs Why |
| new | no equivalent | the checklist for a new feature | decide that it is a feature and choose its level before you write code Why |
| new | no equivalent | changing an older service feature by feature | move a feature to the new structure only when you change it anyway, and freeze the rules Why |
| twist | [Authorize(Policy = "orders.write")] on a new action | @OrdersWrite on the new controller method | the permission goes on the controller method, and the use case carries none Why |
| new | a new column with a default value in an EF Core migration | a field in the value object, the aggregate, the document, the mapper and the DTOs | old documents lack the new field, so the mapper or a migration decides its value Why |
| new | no equivalent | a query method, a Spring Data method and a named index in a new migration | every query needs an index, and only a migration creates one Why |
| new | no equivalent | a changed seed file and a new migration that upserts it | a migration that has run anywhere is never edited, so the change is a new one Why |
| twist | a Refit interface, registered with AddRefitClient | a port, an @HttpExchange client, an adapter, settings, a stub and a test | the other service stays behind a port, and its client never leaves the client package Why |
| new | no equivalent | a domain event, a V1 record with @Externalized, a mapper case, a schema and a topic | an event leaves the service only when the mapper says so, and its schema is a contract Why |
| new | an IMessageHandler<T> from AWS.Messaging | a record of the service's own and a listener that extends InboxMessageHandler | each consumed event is processed once, through the inbox, with its own queue and dead-letter queue Why |
| new | no equivalent | a new feature with its own packages | other features use it only through its application service Why |
| trap | no equivalent | an upgrade of Spring Boot or the platform | a renamed property or class name still compiles and is then ignored, so run the wiring test after every upgrade Why |
| new | no equivalent | the review checklist for a pull request | the template asks five domain questions, and the reviewer adds ten technical ones Why |
| new | no equivalent | "why we do it this way", in short answers | each rule of the service has a reason, and a chapter that explains it Why |
| twist | a primary constructor in C# 12 | an explicit constructor, with no Lombok | older code and guides use Lombok and other forms that this part replaces Why |
How to use this partnew#
A developer who joins the team does not need every chapter of the part on the first day. The service has a fixed shape, and once you know the shape, the chapters become a reference. This is the shape of the order service's code:
src/main/java/com/acme/orderservice/
OrderServiceApplication.java the class that starts the service
order/ the order feature, full style
domain/ plain Java: the aggregate, its events and exceptions, the ports
application/ the use cases: OrderService and its views
api/ the controller, its DTOs and its mapper
persistence/ the document, its mapper and the adapter
client/ the stock service's client and adapter
messaging/ the published events, their mapper and the payment listener
note/ the note feature, light style
reference/ lookup values, simple style
migration/ indexes and seed data
shared/ permission annotations and audit namesEach feature, such as order/, owns one aggregate, or a set of lookup data. Each
feature has the same layers inside, though a lighter feature has fewer of them. Read the part in
this order:
- Why, and the shape. Read these three chapters:
- One request through the layers. Follow
POST /ordersfrom the controller to the database, in these chapters: - The rules. Read these two chapters:
- The rest, as reference. Read the chapter on security, messaging, configuration or testing when you first change that part.
- This chapter, for each task. Find the task, and follow its list of files.
The tree above is the map for every task below. A task names the packages it touches, and the tree shows where they are.
Checklist: a new servicenew#
The shop needs a returns service. A team copies the order service and starts deleting. Two weeks later, the new service has the order service's old settings, and a security setup that someone changed for the order service only. A checklist avoids that. A new service takes seven steps:
- The structure, the parent and the library. Create the packages as in the
tree above. The Project Object Model (POM) names the shop's parent, and depends on
shop-platform. - The name and the settings. Set
spring.application.name, the MongoDB URI, the token issuer and audience, and the path variables for the log context. - The first migration. One named index for each query, in
V001. - The API contract. Write the controllers and the data transfer objects (DTOs)
with stub methods first. Generate
contracts/openapi.yaml, and agree it with the teams that call the service. Write a JSON schema incontracts/events/for each event that the service publishes. - The local environment. Add stubs for every service it calls, and its topics
and queues to
init.sh. Check that the two commands start it. - The tests that protect the shape. The wiring test, the architecture test, the OpenAPI drift test and the event schema test, from the first commit.
- The documents. The README holds the rules, the endpoints, the events, the
dependencies, the settings and how to run it, as the order service's README does. Add a design
note for each feature in
docs/design/, a folderdocs/adr/for the decisions, and the pull request template in.github/pull_request_template.md.
The POM of a service is short, because the parent does the rest:
<parent>
<groupId>com.acme.platform</groupId>
<artifactId>shop-platform-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../shop-platform/shop-platform-parent/pom.xml</relativePath>
</parent>
<groupId>com.acme</groupId>
<artifactId>order-service</artifactId>
<version>1.0.0-SNAPSHOT</version>
<dependencies>
<!-- Everything cross-cutting. No version: the parent manages it. -->
<dependency>
<groupId>com.acme.platform</groupId>
<artifactId>shop-platform</artifactId>
</dependency><relativePath>../shop-platform/shop-platform-parent/pom.xml</relativePath>In the companion, the three repositories are in one folder, so the parent is found by its path. In a real repository of its own, the comment in the POM says to remove this line. The parent then comes from the company's artifact repository.
<artifactId>shop-platform</artifactId>One dependency brings the security chain, the error responses, the logging, the MongoDB setup, the outbox and the inbox. For what the library holds, see the chapter Writing the platform library.
Step 2 sets few values, because the platform's defaults apply first. The service's
application.yml starts with its name:
spring:
application:
name: order-serviceThe name order-service appears in every log line of the service. The other
values come from environment variables, which the deployment sets. For the settings and their
order, see the chapter Configuration in depth.
Step 4 is a choice that the contracts chapter explains. The order service writes its
controllers first and generates the contract. To agree an API before it is built, the controllers
start with stub methods. A team that writes the contract first uses the parent's profile instead.
The command mvn verify -Dcontract-first generates Java interfaces from
contracts/openapi.yaml, and the controllers implement them. For the choice between
code first and contract first, see the chapter API and event contracts.
Some teams make the architecture test of step 6 optional. The book recommends it from the first commit, because a new service breaks none of the rules. For the reasons, see the chapter Testing in depth.
Checklist: a new featurenew#
A feature is a package such as order or note. It owns one
aggregate, or a set of lookup data, with its use cases and adapters. A new feature takes these
steps:
- Check that it is a feature. Does it own data and rules of its own? If it only adds a use case to an aggregate that exists, it belongs in that feature. See the chapter Finding the aggregates.
- Choose its level. Simple, light or full, by the rules it holds. The design
group writes the level, with the aggregates, states, rules and events, in the feature's design
note in
docs/design/. See the section Choosing the level for each feature, and the section The feature design note. - Start in the domain, with plain JUnit. Write the aggregate and its tests first. They need no Spring and no database.
- Then the application layer, then the adapters. The use cases, with Mockito tests. Then the controller, the storage, any client and any listener, each with its test.
- The migration. A new migration with the indexes for the feature's queries.
- Tell the others. Update the OpenAPI contract and any event schemas. Add the stubs, topics and queues to the local environment. Tell QA what changed, because their tests use the local environment.
A feature in the full style also gets a line in ArchitectureTest, which lists the
full-style features by package. The rule that its application layer uses only ports then applies
to it too. See the section A level for each feature.
The order of steps 3 and 4 is the point of the list. A feature written from the controller down tends to put its rules where the code first needed them, in the controller or the service. A feature written from the domain up has its rules in the aggregate before anything calls them.
Each feature has a package-info.java that says what it holds and which level it
uses. This is the order feature's:
/**
* Orders: what a customer bought, the lines that warehouse staff pick, the payment, and shipping.
*
* <p>This feature uses the full style. Its {@code domain} package is plain Java with typed IDs.
* The {@code persistence} package stores the aggregate as a separate document through a mapper,
* behind the {@code OrderRepository} port. The stock service is called through the
* {@code StockChecker} port.The comment says This feature uses the full style, and names the ports. A file
named package-info.java holds the documentation of a package, and no class. A reader who opens a feature for the first time reads this comment first. Write it when
you create the feature.
Checklist: an existing servicenew#
An older service has a different structure, and a team cannot stop for a month to rebuild it. So the change happens feature by feature, and only where the team changes code anyway:
- Add the cheap protection first. The wiring test, and the architecture rules
wrapped in
FreezingArchRule.freeze, so only new violations fail. See the section Adding the rules to an existing service. - Move a feature when you change it. When a task touches a feature, move that feature to the new packages in the same pull request, or in the pull request before it.
- Move its rules into the domain, with tests. Each rule that moves gets its plain JUnit test.
- Leave untouched features alone. A feature that nobody changes does not need the new structure yet.
This is slower than a rewrite, and it leaves two structures side by side for a while. But each pull request stays small, and each moved feature is tested before the next. For what to test first, see the section What to test first in an older service.
The architecture test of the order service runs the rules on every build:
@Test
void featuresUseEachOtherOnlyThroughApplication() {
ArchitectureRules.featuresUseEachOtherOnlyThroughApplication(BASE).check(CLASSES);
}In an existing service, the rule
featuresUseEachOtherOnlyThroughApplication(BASE) is wrapped in
FreezingArchRule.freeze. The frozen rule keeps the violations it accepts in a folder
of text files, called the store. The file archunit.properties in the test resources
names the folder:
freeze.store.default.path=src/test/resources/archunit_store
# Never create a store by accident, for example in CI, where it would accept every violation
# without a review. To create the store, set this to true once, run the test, commit the
# folder, and set it back to false.
freeze.store.default.allowStoreCreation=falseCommit the folder archunit_store with the code, so each pull request shows whether
it grew. The setting allowStoreCreation=false makes a build fail when the store is
missing. So a build never accepts every violation without a review. The order service shows a frozen rule in
FrozenRuleTest, which the section Adding the rules to an
existing service explains.
Task: add an endpointtwist#
The business wants agents to cancel an order. The order service has this endpoint, so it shows every file that such a change touches:
| Layer | File | What changes |
|---|---|---|
| domain | order/domain/model/Order.java | a method cancel(Clock) that checks the rule and raises OrderCancelled |
| domain | order/domain/event/OrderCancelled.java | the new domain event |
| domain | order/domain/exception/OrderFinishedException.java | the exception for a shipped or cancelled order, if it is new |
| domain test | OrderTest.java | a nested group Cancelling, with the allowed case and each refusal |
| application | order/application/OrderService.java | a method cancel(OrderId) with @Transactional, which saves, publishes and audits |
| shared | shared/AuditActions.java | the name ORDER_CANCELLED |
| api | order/api/OrderController.java | a method with @OrdersWrite and @PostMapping |
| api test | OrderControllerTest.java | the success, each refusal with its status and code, and the 403 without the scope |
| contract | contracts/openapi.yaml | the drift test fails until you run mvn verify -Dopenapi.update=true and commit the file |
| event, if published | OrderCancelledV1, a case in the mapper, its schema | only if another service needs to know, as the section on publishing an event shows |
The controller method is the entry point, so it names the permission:
@OrdersWrite
@PostMapping("/{orderId}/cancel")
@Operation(operationId = "cancelOrder", summary = "Cancel an order that has not shipped")
@ApiResponse(responseCode = "204", description = "Cancelled")
// ...
ResponseEntity<Void> cancel(@PathVariable String orderId) {
orders.cancel(OrderId.of(orderId));
return ResponseEntity.noContent().build();
}@OrdersWriteThe method needs the scope
orders.write. The architecture rule fails the build if an endpoint has no permission annotation.@Operation(operationId = "cancelOrder", summary = "Cancel an order that has not shipped")springdoc reads this annotation into the OpenAPI document. Each error response gets an
@ApiResponseline, which the excerpt leaves out.orders.cancel(OrderId.of(orderId));The controller turns the text into a typed ID and calls the use case. It holds no rule and catches no exception.
The use case has no permission annotation. Other features and message listeners call use cases directly, so a check there would need a second, unprotected copy of each method. For the full reasoning, see the section Choosing the place by the shape of the service.
In ASP.NET Core, the action would carry [Authorize(Policy = "orders.write")] and
[HttpPost("{orderId}/cancel")]. The two forms put the check in the same place, on
the method that receives the request.
Task: add a fieldnew#
The business wants the customer's phone number on an order. A field in the full style goes through every layer, because each layer has its own type:
| Layer | File | What changes |
|---|---|---|
| domain | CustomerContact.java, or a new value object PhoneNumber | the field and its check, in the compact constructor |
| domain | Order.java | place and rehydrate take the value, and the getter returns it |
| domain test | ValueObjectTest.java, OrderTest.java | the check of a valid and an invalid value |
| persistence | OrderDocument.java | a plain field, such as customerPhone |
| persistence | OrderDocumentMapper.java | the field in both directions, toDocument and toDomain |
| api | PlaceOrderRequest, OrderResponse | the field in the DTOs, with validation |
| api | OrderController.java or OrderApiMapper.java | the field in the translation |
| contract | contracts/openapi.yaml | regenerated and committed |
| old data | the mapper, or a new migration | what an order stored before the change means |
The value object checks the value once, where it is created:
public record CustomerContact(String name, String email) implements ValueObject {
public CustomerContact {
Guard.notBlank(name, "customer.name");
Guard.maxLength(name, 200, "customer.name");
Guard.maxLength(email, 254, "customer.email");
Guard.matches(email, "[^@\\s]+@[^@\\s]+\\.[^@\\s]+", "customer.email");
}
@Override
public String toString() {
return "CustomerContact[masked]";
}
}A phone number is personal data, like the e-mail address. So it stays inside the masked
toString, and it stays out of the views and the integration events.
The mapper copies each field in both directions. Forget one direction, and the field is stored but never read, or read but never stored:
@Override
public OrderDocument toDocument(Order order) {
OrderDocument document = new OrderDocument();
document.customerName = order.customer().name();
document.customerEmail = order.customer().email();
// ...
@Override
public Order toDomain(OrderDocument document) {
return Order.rehydrate(
OrderId.of(document.getId()),
document.getVersion(),
new CustomerContact(document.customerName, document.customerEmail),toDocument writes the field, and toDomain reads it back through
rehydrate. The persistence test round-trips a whole aggregate, so it catches a
missing direction. Add the new field to the values that test compares.
The hard part is the old data. Orders stored before the change have no phone number. Decide what that means. If an order may have no phone, the value object allows an empty value, and the mapper passes it through. If every order needs one, a new migration fills a value into the old documents before the new code reads them. The section Task: add a lookup value shows the shape of such a migration. A migration that changes data is harder to undo than a code change, so prefer an empty value where the business allows it.
The light style needs fewer steps. The note is its own document, so a new field goes into
Note and its @PersistenceCreator constructor, with no separate document
and no mapper. That is one reason to keep a feature light while its data is simple. See the
chapter How much DDD: three levels in one service.
Task: add a querynew#
Agents want to list the orders with one delivery method. A query in the full style touches the port, the adapter and a migration:
| Layer | File | What changes |
|---|---|---|
| domain | order/domain/repository/OrderRepository.java | a method in the domain's types, such as findByDeliveryMethod |
| persistence | OrderMongoRepository.java | a Spring Data method on the document, if a derived query is enough |
| persistence | OrderPersistenceAdapter.java | the port's method, which calls the Spring Data method and maps the page |
| migration | a new migration, such as V005 | a named index for the new filter and sort |
| application | OrderService.java | a method that returns a page of views |
| api | OrderController.java | a GET method with @OrdersRead, or a new filter on the list |
| tests | the adapter IT, the service test, the controller test | the query, the page and the 400 for a bad filter |
The port speaks the domain's language:
PageResult<Order> findAll(PageQuery page);
PageResult<Order> findByStatus(OrderStatus status, PageQuery page);PageQuery and PageResult are the platform's plain types for a page.
The domain knows nothing of Spring Data's Pageable. The adapter translates.
Spring Data writes the query from the method name:
interface OrderMongoRepository extends MongoRepository<OrderDocument, String> {
Page<OrderDocument> findByStatus(String status, Pageable pageable);
}findByStatus becomes a query on the field status. For derived
queries, see the section Repositories and derived queries.
Every query needs an index. Without one, MongoDB reads the whole collection for each request, and a list that was fast with a hundred orders is slow with a million. The service creates its indexes only in migrations, never at startup. The first migration names an index for the list by status:
// GET /orders?status=PAID, newest first
mongo.indexOps("orders").createIndex(new Index()
.on("status", Sort.Direction.ASC).on("placedAt", Sort.Direction.DESC)
.named("orders_status_placedAt"));The comment names the request that needs the index, and
named("orders_status_placedAt") gives the index a name that says what it is for. A new query adds its index in a new migration,
such as V005, written the same way. Never add it to V001, because environments
where V001 has run would never create it. For named indexes, see the
section A named index for each query.
Task: add a lookup valuenew#
The shop starts a new delivery method, collection from a locker. The delivery methods are seed data, in a JSON file:
[
{ "code": "STANDARD", "label": "Standard delivery (3 to 5 days)", "active": true },
{ "code": "EXPRESS", "label": "Express delivery (next day)", "active": true },
{ "code": "COLLECT", "label": "Collect from a store", "active": true },
{ "code": "SECOND_CLASS_POST", "label": "Second-class post (no longer offered)", "active": false }
]Each value has a code, a label and an active flag. A
value that is no longer offered stays in the list with "active": false, so old
orders that used it still have a label.
The change has two parts. Add the new value to the file. Then add a new migration that runs the same upserts again. The migration that loaded the file first says why:
/**
* Loads the lookup values from {@code resources/seed/}. Each value is upserted by its type and
* code, so the migration is safe to run twice.
*
* <p>To change the values, edit the JSON file and add a new migration that runs the same upserts.
* Never edit this one: environments where it has run would not see the change.
*/The comment's advice, add a new migration that runs the same upserts, follows from
how the runner works. The runner records each migration's ID once it has run. An environment where
V002 has run will never run it again, even if its code changes. So the new
migration, with a new ID, is what reaches those environments. An upsert updates a value that
exists and inserts one that does not, matched by its code. So the new migration can safely load
the whole file again. For the rule, see the section Never edit a migration
that ran.
Lookup values are cached with no expiry. A new value arrives with a deployment, which restarts every instance and so empties the cache. For the cache, see the chapter Caching and scheduling.
Task: call a new servicetwist#
The order service must ask a new pricing service for prices. A call to another service touches the most files of any task, because the service keeps the other service behind a port:
| Layer | File | What changes |
|---|---|---|
| application | order/application/port/PriceChecker.java | the port, in the domain's types |
| client | order/client/PricingHttpClient.java | an interface with @HttpExchange, one method for each endpoint |
| client | order/client/dto/ | records for the other service's request and response |
| client | order/client/PricingAdapter.java | implements the port, calls the client through ResilientCalls, and translates |
| client | order/client/PricingClientConfig.java | binds HttpClientProperties to clients.pricing and creates the client with HttpClientFactory |
| settings | application.yml and application-local.yml | clients.pricing.base-url, from an environment variable and from WireMock |
| settings | application.yml | a client registration, such as pricing-service, and clients.pricing.token-registration-id, so each call carries a token |
| local environment | wiremock/mappings/pricing-service/ | a default stub, and stubs for the special cases |
| test | PricingAdapterTest.java | the client against a JDK HttpServer, with fast retries |
The client is an interface, and Spring creates its implementation:
/**
* The stock service's HTTP API, declared as an interface. Spring creates the implementation,
* like Refit does in .NET.
*/
@HttpExchange("/stock")
interface StockHttpClient {@HttpExchange is like a Refit interface in .NET. For the basics, see the section
Declarative clients. The configuration class creates
the client with the platform's factory, which adds the timeouts, the token and the error
handling:
/** Binds the platform's reusable properties class to this client's prefix. */
@Bean
@ConfigurationProperties("clients.stock")
HttpClientProperties stockClientProperties() {
return new HttpClientProperties();
}
@Bean
StockHttpClient stockHttpClient(HttpClientFactory clients, HttpClientProperties stockClientProperties) {
return clients.create(StockHttpClient.class, stockClientProperties);
}The class binds the settings under clients.stock. A new client copies this class
with a new prefix, such as clients.pricing. The
settings are then under that prefix in both YAML files. For the timeouts, retries, the circuit
breaker and the idempotency key, see the chapter Calling other
services.
The stub comes in the same pull request. Without it, the two commands start a service that fails on its first call. See the section The rules of the local environment.
Task: publish a new eventtwist#
The warehouse service wants to know when an order is cancelled. The order service publishes
OrderCancelledV1, so it shows every file:
| Layer | File | What changes |
|---|---|---|
| domain | order/domain/event/OrderCancelled.java | the domain event, raised by the aggregate |
| messaging | order/messaging/event/OrderCancelledV1.java | the integration event, a record with @Externalized and plain types |
| messaging | OrderIntegrationEventMapper.java | a case that turns the domain event into the integration event |
| contract | contracts/events/OrderCancelledV1.json | the JSON schema, for the consumers |
| local environment | aws-init/init.sh | the topic, if the event goes to a new one |
| test | OrderIntegrationEventMapperTest.java | the mapping, field by field |
| test | PublishedEventSchemaTest.java | the domain event in publishedDomainEvents, so the JSON is checked against its schema |
| design note | docs/design/order.md | the row of the event in the table of events raised |
The integration event names its topic:
/** Published when an order ships. The schema is in {@code contracts/events/OrderShippedV1.json}. */
@Externalized(OrderTopics.ORDER_EVENTS)
public record OrderShippedV1(String eventId, Instant occurredAt, String orderId) implements IntegrationEvent {
}@Externalized tells Spring Modulith to send the event to the topic
order-events after the transaction commits. The V1 in the name is the
version of the contract. A change that breaks consumers is a new record,
OrderShippedV2, published beside the old one for a while.
The mapper decides what leaves the service:
case OrderCancelled cancelled -> Optional.of(new OrderCancelledV1(newEventId(), cancelled.occurredAt(),
cancelled.orderId().value()));
default -> Optional.empty();A domain event without a case falls to default, and stays inside the service.
So adding a domain event publishes nothing by itself. That is safe, because every published event
is a contract to maintain. But it is also a trap. A developer who forgets the case wonders why no
consumer hears the event. The mapper test is the place to check both. The schema test then
writes each published event as JSON, and validates it against its file in
contracts/events/. So a renamed field fails the build before a consumer breaks. For the outbox, see the
chapter Publishing events: the outbox, and for schemas and versions, see
the chapter API and event contracts.
Task: consume a new eventnew#
The order service consumes PaymentReceivedV1 from the payment service. Its files
are the template for any consumed event:
| Layer | File | What changes |
|---|---|---|
| messaging | order/messaging/PaymentReceivedV1.java | the service's own record of the event, with only the fields it uses |
| messaging | order/messaging/PaymentReceivedListener.java | extends InboxMessageHandler, with @SqsListener on a method that calls process |
| application | OrderService.java | the use case that the listener calls, with no permission check |
| settings | application.yml | messaging.queues.payment-received, the queue name |
| local environment | aws-init/init.sh | the queue, its dead-letter queue and the subscription with raw delivery |
| contract | contracts/events/consumed-PaymentReceivedV1.json | the part of the producer's schema that this service relies on |
| tests | PaymentReceivedV1Test, PaymentReceivedListenerTest | the JSON, and the translation into the use case |
| design note | docs/design/order.md | the producer and the event, in the list of who the feature talks to |
The record is the service's own copy, not a shared class:
@JsonIgnoreProperties(ignoreUnknown = true)
record PaymentReceivedV1(String eventId, Instant occurredAt, String orderId, String paymentReference,
BigDecimal amount, String currency, Instant paidAt) implements IntegrationEvent {@JsonIgnoreProperties(ignoreUnknown = true) lets the producer add fields freely.
The record's compact constructor refuses a missing field, so a wrong message fails loudly. The
listener only translates:
/** The queue name comes from configuration, so each environment can name its own queue. */
@SqsListener("${messaging.queues.payment-received}")
void on(PaymentReceivedV1 event) {
process(event);
}
@Override
protected void handle(PaymentReceivedV1 event) {
Payment payment = new Payment(new PaymentReference(event.paymentReference()),
new Money(event.amount(), event.currency()), event.paidAt());
orders.recordPayment(OrderId.of(event.orderId()), payment);
}@SqsListener("${messaging.queues.payment-received}")Spring Cloud AWS calls this method for each message on the queue.
${...}reads the queue name from the settings.process(event);The base class runs
handleonce per event, through the inbox, as the system actor.orders.recordPayment(OrderId.of(event.orderId()), payment);The listener calls the use case directly. The use case has no permission check, so the listener needs no identity.
The queue, its dead-letter queue and the subscription go into init.sh in the
same pull request. That takes one call of queue and one of subscribe. The
service fails at startup when its queue does not exist, so a missing line is found at once. For
the inbox, raw delivery and the visibility timeout, see the chapter Consuming
events: SQS and the inbox.
Task: add a featurenew#
The shop starts taking returns. A return has its own rules, such as a time limit and a condition of the goods, so it is a feature. The steps are those of the checklist for a new feature. The files follow the tree of the service:
src/main/java/com/acme/orderservice/returns/
package-info.java what the feature holds, and its level
model/ or domain/ the aggregate and its tests, light or full style
application/ ReturnService, the only class other features may use
api/ ReturnController, its DTOs and mapper
persistence/ only in the full styleThe rule that matters most is the boundary. Other features use the returns feature only
through ReturnService, in its application package. They never take its
repository, its entity or its ports. The architecture rules
featuresUseEachOtherOnlyThroughApplication and
servicesDoNotUseOtherFeaturesRepositoriesOrEntities fail the build when they do.
Beside the code, the feature's design note goes into docs/design/returns.md, as
the order feature's note is in docs/design/order.md.
The rest follows the earlier tasks. The feature's queries get indexes in a new migration. Its endpoints appear in the regenerated contract. Any service it calls gets a stub. A feature that calls another feature, and is called back, is a warning sign. See the section Two features that call each other.
The name of the package is returns, not return, because
return is a Java keyword. A package name cannot be a keyword.
Task: upgrade Spring Boot or the platformtrap#
An upgrade changes one version, and it looks like the smallest task of all. The platform's parent names the Spring Boot version:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>The <version> line is the whole change for Spring Boot. A service upgrades by
moving to the new platform version. The risk is in what the compiler cannot see.
A setting or a class name in a string is never checked by the compiler. Spring
Boot 4 reads the MongoDB URI from spring.mongodb.uri. A service that still sets the
old key, spring.data.mongodb.uri, compiles and starts. A run for this book showed
that the old key is ignored, with no warning, and the client went to
localhost:27017. The platform also names Spring Boot's auto-configurations in
beforeName, as strings. If a new Spring Boot version renames one of them, nothing
fails at compile time.
So an upgrade has its own checklist:
- Read the migration guide of the new Spring Boot version, and search the code for every key and class that it renames.
- Run
mvn verify. The wiring test starts the whole service, and checks the results that depend on such names. Its testusesTheConfiguredMongoUrifails if the URI key is ignored. Its first test fails if the transaction manager is not the MongoDB one. It also fails if the service has a security chain besides the platform's own chain. A second chain appears when the platform's auto-configuration runs after the auto-configuration of Spring Boot. - Run
mvn verify -Pit, against real MongoDB and Floci. - Upgrade the platform first, in its own repository, and release it. Then move each service to the new platform version in its own pull request.
The companion was checked this way. The whole build ran on Spring Boot 4.1.2, and every test passed, including the wiring test and the integration tests. For how a new version reaches every service, see the section Upgrading Spring Boot for every service. For Boot 3 to Boot 4, see the chapter Migrating Spring Boot 3 to 4.
The review checklist for a pull requestnew#
A reviewer cannot check every line of a large change. A short list of questions catches the mistakes that cost the most later. The questions come in two parts.
The first part is in the service. Its pull request template,
.github/pull_request_template.md, asks the author five domain questions. Can a change
of state skip the aggregate? Is each rule in the aggregate, with a test? Does each change of state
record its event? Does the change reach into another feature? Are the names business words, at
the right level? The author answers them in the description, and the reviewer checks the answers
against the code. For the template itself, see the section The pull
request checklist.
The second part is the reviewer's own. These ten technical questions cover what the template does not ask, each with the place in the part that explains it:
| Question | Why it matters | Where it is explained |
|---|---|---|
| Is each class in the right feature and the right layer? | a class in the wrong place pulls its dependencies with it | the chapter on features and layers |
| Does each exception have the right kind, and a message with no personal data? | the kind decides the status, and messages reach logs and responses | the chapter on the domain model |
| Does each new query have an index, in a new migration? Was no migration edited? | a query without an index reads the whole collection, and an edited migration never reruns | the chapter on migrations |
| Is each transaction short, with no call to another service inside? | a long transaction holds locks and meets the time limit of DocumentDB | the chapter on transactions |
| Does each new call use the platform's factory and ResilientCalls, with a stub? | the factory adds timeouts and the token, and the stub keeps the local environment working | the chapter on calling other services |
| Does each new integration event have a case in the mapper, and a schema that PublishedEventSchemaTest checks? | a published event is a contract with other teams, and the test finds a field that drifts from its schema | the chapter on contracts |
| Does each new listener extend InboxMessageHandler? | the inbox processes a repeated message once | the chapter on the inbox |
| Do the logs leave out personal data? | a log line is kept and searched long after the request | the chapter on logging and health |
| Does each new endpoint name its permission, with a 403 test and a success test? | the architecture rule checks that an annotation exists, not that it is the right one | the chapter on security |
| Is contracts/openapi.yaml updated, and does its diff match the intent? | the diff is the change to the API that other teams see | the chapter on contracts |
The question about personal data has a code answer too. A value object that holds personal
data hides it in toString, so a careless log line prints nothing personal:
@Override
public String toString() {
return "CustomerContact[masked]";
}The test customerContactHidesItsPersonalDataInToString checks that
toString() returns "CustomerContact[masked]".
The build answers some questions by itself. The architecture rules check the layers, the setters, the repositories in controllers and the permission annotations. The drift test checks the OpenAPI contract, and the schema test checks the events. So a reviewer spends time on the questions that need judgement. Where does a rule belong? Is this event, or this field, the right one?
Why we do it this waynew#
A new developer asks why the service does something in a way that looks longer than needed.
Each answer below is short, and links to the chapter that gives the full reason. The first
question shows the pattern. The class comment of OrderService answers it in the
code itself:
* <p>The class has no permission annotation. Permissions are checked on the controllers, where
* users enter. Every method here can therefore be called by another feature or by a message
* listener without a second, unprotected copy of the method.The comment says why there is no permission annotation, and it says so where a
developer reads the code. The other
questions follow:
| Question | Short answer | Chapter |
|---|---|---|
| Why are permissions checked on the controllers, not the use cases? | Users enter through the controllers. A check there lets listeners and other features call the use cases with no second copy. | the chapter on security |
| Why does the domain use no Spring? | Its rules are then tested in milliseconds, and no framework change can break them. | the chapter on the domain model |
| Why a separate document and a mapper for an order? | The aggregate keeps its rules and its shape, and the stored shape can change on its own. | the chapter on storage |
| Why is the note its own document, with no mapper? | Its data is simple, so the light style costs less and loses nothing yet. | the chapter on the levels of DDD |
| Why does save return a new copy of the order? | The copy holds the new version. Continuing with the old object would fail the next save. | the chapter on storage |
| Why is the stock service called before the transaction? | A slow call inside a transaction holds it open, and DocumentDB limits a transaction's time. | the chapter on use cases |
| Why an idempotency key on the reservation? | A retry after a lost response must not hold the stock twice. | the chapter on calling other services |
| Why an outbox, and not a direct send to SNS? | The event is stored in the same transaction as the change, so neither is lost without the other. | the chapter on the outbox |
| Why an inbox for the payment event? | SQS delivers at least once, so a payment would otherwise be recorded twice. | the chapter on the inbox |
| Why raw message delivery on the subscription? | Without it, the queue holds an SNS envelope, and the listener cannot read the event. | the chapter on the inbox |
| Why indexes only in migrations? | An index created at startup is invisible and unreviewed, and can block a large collection. | the chapter on migrations |
| Why never edit a migration that has run? | An environment where it ran will never run it again, so the change would never reach it. | the chapter on migrations |
| Why a generated OpenAPI file, committed and checked? | Every API change then appears as a diff in the pull request, and the file cannot drift. | the chapter on contracts |
| Why one platform library for every service? | Security, errors, logs and storage then work the same in every service, and are fixed once. | the chapter on the platform library |
| Why a String ID between features? | The feature that owns the ID checks it, and the other feature does not depend on its types. | the chapter on aggregates |
| Why do the architecture tests run in every build? | A rule in a document is forgotten, and a rule that fails the build is kept. | the chapter on testing |
| Why two commands to start the service? | A new developer, and QA, can run it on the first day with no manual step. | the chapter on running locally |
A question that has no row here deserves a new row. If the answer is "because we always did",
the rule may have lost its reason. Then the team discusses it, and writes the decision down. A
short decision is a paragraph in the section "Decisions" of the README. A decision that needs more
than a paragraph gets a numbered record in docs/adr/, as the order service does. See
the section How each decision is recorded.
Older forms you will still meettwist#
[ApiController]
[Route("orders")]
public class OrdersController(IOrderService orders, OrderApiMapper mapper) : ControllerBase // a primary constructor
{
}@RestController
@RequestMapping("/orders")
@Tag(name = "orders")
class OrderController {
private final OrderService orders;
private final OrderApiMapper mapper;
OrderController(OrderService orders, OrderApiMapper mapper) {
this.orders = orders;
this.mapper = mapper;
}C# 12 has primary constructors, so the C# pane declares the dependencies in one line,
OrdersController(IOrderService orders, OrderApiMapper mapper). Java has
no primary constructor for a class, so the service writes the constructor and the fields. Many
older Java services use Lombok's @RequiredArgsConstructor, which writes that
constructor while the class compiles. The order service uses no Lombok, so every line that runs
is in the source. For Lombok, see the section A word on Lombok.
Older guides and services use other forms that this part replaces. Each has a reason:
| Older form | The form in this part | Why it changed |
|---|---|---|
| Lombok annotations for constructors and accessors | explicit constructors, and records for values | the code shows every line, and records need no Lombok |
| spring.data.mongodb.uri | spring.mongodb.uri | Spring Boot 4 reads the new key and ignores the old one without a warning |
| @Retry and @CircuitBreaker annotations, with resilience4j settings in YAML | the platform's ResilientCalls, with Resilience4j used directly | the platform gives every service the same retry rules, and tests them once |
| Mongock change units | the platform's own migration runner | Mongock has no Spring Boot 4 artifact, as of October 2026 |
| ArchUnit tests with @AnalyzeClasses and @ArchTest fields | plain JUnit tests that call the platform's rules | the rules ship in a library, take parameters, and need no extra test engine |
| a container in a static @Container field | a container as a bean in a shared test configuration | the container is declared once for every test class and service |
| mongo:5.0, wiremock:latest, or Floci with the tag latest | an exact version of each image, the same in every place | everyone runs the same thing, and an upgrade is a reviewed change |
| a separate query class and command class for each feature | one application service for each area of a feature | the service has one entry point for each use case, with no CQRS split it does not need |
| an exception handler in each feature | the platform's one handler | every error has the same problem shape, with a traceId |
| a shared folder in each service, until a library exists | the platform library from the start | shared code is fixed once, for every service |
When you meet one of these forms in a service, it still works. Replace it when you change that code, as the checklist for an existing service says. Do not replace all of them in one large pull request, because a reviewer cannot check it.
1An expert asksA developer adds a delivery method by editing the JSON file and the migration
V002SeedReferenceData. The change works on their machine. Why does it fail in the
test environment?
V002SeedReferenceData. The change works on their machine. Why does it fail in the
test environment?V002 ran in the test
environment long ago, so it never runs again there, even with new code. On the developer's
machine, the database was new, so V002 ran. The fix is to edit the file and add a
new migration with a new ID that runs the same upserts.2Which files does the order service touch to consume a new event from another service?
InboxMessageHandler with @SqsListener. Also the use case it calls, the
queue name in application.yml, and the queue, dead-letter queue and subscription in
init.sh. Add the consumed schema, and tests of the JSON and of the
translation.3An expert asksWhy does an upgrade of Spring Boot need the wiring test, when every unit test
passes?
beforeName. A renamed key or class still compiles, and can then be ignored. Only
a test that starts the whole service and checks the result sees it. The wiring test does that in
seconds. It checks the MongoDB URI that the service really uses, and the one security
chain.4TranslateThe order service uses no Lombok. Write the start of this C# 12 controller
as the order service's Java controller, with its fields and its constructor:
[ApiController]
[Route("orders")]
public class OrdersController(IOrderService orders, OrderApiMapper mapper) : ControllerBase
{
}
[ApiController]
[Route("orders")]
public class OrdersController(IOrderService orders, OrderApiMapper mapper) : ControllerBase
{
}@RestController
@RequestMapping("/orders")
@Tag(name = "orders")
class OrderController {
private final OrderService orders;
private final OrderApiMapper mapper;
OrderController(OrderService orders, OrderApiMapper mapper) {
this.orders = orders;
this.mapper = mapper;
}A Java class has no primary constructor. So the controller declares each dependency as a
final field, and writes the constructor that sets it. Spring passes the two beans to
that constructor, because it is the only one.
Quick reference#
The words of the part, each with the chapter that teaches it:
| Word | What it means | Chapter |
|---|---|---|
| aggregate | a group of objects that changes as one, inside one transaction | the chapter on aggregates |
| aggregate root | the one entity of an aggregate that outside code may use | the chapter on aggregates |
| entity | an object with an identity that stays the same while its data changes | the chapter on the domain model |
| value object | an object defined only by its values, such as Money, and never changed | the chapter on the domain model |
| domain event | a record of something that happened in the domain, such as OrderShipped | the chapter on the domain model |
| integration event | a versioned event that leaves the service, such as OrderShippedV1 | the chapter on the outbox |
| domain service | a class for a rule that spans aggregates, such as ShippingPolicy | the chapter on where rules go |
| application service | the class with the use cases, which runs the steps in order | the chapter on use cases |
| port | an interface that the domain or application owns, for storage or another service | the chapter on use cases |
| adapter | the class that implements a port with a real technology | the chapter on storage |
| anti-corruption layer | the code that translates another service's model into this one | the chapter on calling other services |
| view | a read-only shape of an aggregate that a use case returns | the chapter on use cases |
| DTO | a data transfer object, the request or response shape of the API | the chapter on the web layer |
| bean | an object that the Spring container creates and wires | the chapter on the container |
| auto-configuration | beans that a library adds when its conditions hold | the chapter on the platform library |
| proxy | a generated subclass that runs code around a method, such as a transaction | the chapter on the container |
| optimistic locking | refusing a save when the stored version changed after the load | the chapter on storage |
| outbox | stored events that are sent after the transaction commits | the chapter on the outbox |
| inbox | the record of processed events, so a repeated one is skipped | the chapter on the inbox |
| idempotent | giving the same result when it runs twice | the chapter on calling other services |
| DLQ | a dead-letter queue, where SQS moves a message that failed too often | the chapter on the inbox |
| circuit breaker | a switch that stops calls to a failing service for a while | the chapter on calling other services |
| migration | a numbered change to the database that runs once | the chapter on migrations |
| seed data | lookup values that a migration loads | the chapter on migrations |
| MDC | the mapped diagnostic context, values added to every log line of a request | the chapter on logging and health |
| trace ID | the ID that links the log lines and spans of one request across services | the chapter on logging and health |
| scope | a word in a token that names one permission, such as orders.write | the chapter on security |
| replica set | a group of MongoDB servers with one primary, which transactions need | the chapter on running locally |
| Testcontainers | a library that runs a real dependency in Docker for a test | the chapter on testing |
| ArchUnit | a library that checks design rules on the compiled classes | the chapter on testing |
The .NET names that the part maps, each with the Java form in the order service:
| .NET | The order service | Note |
|---|---|---|
| Program.cs | OrderServiceApplication | one class with a main method starts the service |
| NuGet feed of the company | the company's Maven artifact repository | the parent POM and the platform library come from there |
| Directory.Build.props | the parent POM, shop-platform-parent | every service inherits the build settings from it |
| Directory.Packages.props | dependencyManagement and BOMs in the parent | versions are set once, so services write none |
| ASP.NET Core Web API controllers | @RestController classes in the api packages | each method of the class maps one request to a use case |
| IOptions<T> | @ConfigurationProperties classes | a typed class bound from settings |
| Refit | @HttpExchange interfaces | Spring creates the client from the interface |
| EF Core migrations | MongoMigration beans and the platform's runner | numbered changes that run once, in order |
| an IMessageHandler<T> | a listener that extends InboxMessageHandler | the inbox adds processing once per event |
| WebApplicationFactory<Program> | @SpringBootTest | the whole application in a test |
| ArchUnitNET | ArchUnit | ArchUnitNET is the C# fork of ArchUnit |
DDD for a team: the playbook#
A Domain-Driven Design (DDD) service stays clean only when the whole team works the same way. This playbook says who decides what, and how each decision is written down. It lists the building blocks a developer may use, and the eight rules every engineer follows. It gives the checks that guard the rules, the wrong decisions to watch for, and a way to roll DDD out to a team.
| Kind | A usual .NET team | The order service's team | Note |
|---|---|---|---|
| new | each developer designs what they build | a small design group decides boundaries, aggregates, rules and workflows | the decisions that are hardest to change are made by the most experienced people, with business experts Why |
| new | decisions in chat and in people's heads | an ADR for each decision of a service | a decision written beside the code can be found, questioned and replaced later Why |
| new | a ticket, and then the code | a one-page feature design note | aggregates, states, rules with examples, events and partners are agreed before the code Why |
| new | one style for every feature | simple, light or full, chosen in the design note | each feature gets the least DDD that protects its rules Why |
| new | any pattern that a developer likes | allowed building blocks, and a short list that needs approval | event sourcing, full CQRS, generic repositories, shared models and new sagas need the design group Why |
| new | conventions on a wiki page | eight rules for every engineer | short rules that each map to a test or a review question Why |
| twist | an ArchUnitNET rule, if the team writes one | ArchUnit rules from the platform library, in every service | the build fails when code breaks a design rule Why |
| new | a general code review | a five-question pull request checklist | the reviewer asks the questions that the build cannot answer Why |
| new | dotnet new webapi, then folders by kind of class | a template feature to copy | a new feature starts with the right layout, tests and comments Why |
| trap | a design that looks finished | nine wrong decisions, each with its sign and its fix | most DDD failures are the same few decisions, and each one shows early Why |
| new | no equivalent | a workflow engine or a process manager for long flows | a flow across services and days keeps its state in one place Why |
| new | a framework adopted by every team at once | a pilot service, training from its code, reviews and a weekly slot | DDD spreads from one service that works, and the template is fixed when people keep making a mistake Why |
Who decides whatnew#
Two developers design the same idea in two ways. One puts the notes inside the order. The other makes them an aggregate of their own. A year later, the service has both designs, and nobody knows which one is right. Some decisions shape everything that comes after them. They must be made once, by the people who can make them well.
So split the decisions in two groups. A small design group makes the first group. It has two or three of the most experienced engineers, and a business expert, such as the product owner. The team makes everything else.
| Decision | Who decides | Why |
|---|---|---|
| the boundaries of the services | the design group, with business experts | a wrong boundary means moving data between services later |
| the aggregates and what is inside each one | the design group | a wrong boundary means migrating stored documents |
| the business rules, and where each one goes | the design group, with business experts | a rule is only right if the business agrees with it |
| workflows across aggregates and services | the design group | a flow decides which data may disagree, and for how long |
| the level of a feature | the design group, in the feature design note | the level decides how much code the feature needs |
| everything inside those decisions | the team | names, methods, tests, queries, endpoints and the order of the work |
The design group does not write all the code. It writes the design note of each feature, and reviews each new aggregate before code is written. The team builds the feature, and asks the group when a decision of the first kind comes up during the work.
The order service shows a decision of the first kind in its code. The note feature is its own
aggregate, and the comment of Order says so. That boundary was chosen once, and every
later change follows it. For the questions that the group uses to find aggregates, see the chapter
Finding the aggregates.
* <p>The aggregate holds what its rules need at every change: the status, the lines and the last
* payment. Notes are a separate aggregate, because no order rule needs every note, and they can
* grow without limit.The three lines of the comment name what is inside the aggregate. The sentence
Notes are a separate aggregate starts the reason why the notes are outside. A developer who wants to add notes to the order finds
the reason before writing any code.
How each decision is recordednew#
A decision that nobody wrote down is made again by the next person. So the team writes down two kinds of record.
- An ADR for each decision of a service. An architecture decision record (ADR) is a short file about one decision. Michael Nygard described the format in a blog post of November 2011. He gave each record five parts. They are a title, the context, the decision, its status and its consequences. The records are kept in the repository and numbered in order. A number is never used twice. A decision that is changed later keeps its record, marked as replaced by the new one.
- A design note for each feature. One page that the design group writes before the feature is built. The next section shows one.
The order service records its decisions in the section "Decisions" of its README, one paragraph each. Here is one of them:
**One request saves two aggregates.** Placing an order with its first notes stores both in one
transaction, because the agent sees it as one action. Keep such transactions small, and never
let them span services.The record One request saves two aggregates. names the decision, its reason and
its limit. The words "never let them span
services" tell a later developer where the decision stops. Keep a test beside each decision that
changes behaviour, as the chapter Finding the aggregates shows for the
shipping rule.
A paragraph is enough for a short decision. The section "Decisions" of the README starts with the rule for a longer one:
The feature design notes are in `docs/design/`. A decision that needs more than a paragraph gets
a numbered record in `docs/adr/`, such as `0001-order-and-first-notes-in-one-transaction.md`.The README names both folders, docs/design/ and docs/adr/, so a reader
of the README finds the longer records. The decision
to save the order and its first notes together needs more than a paragraph. It has a context, a
test, and a sign for when to revisit it. So it also has an ADR file, in Nygard's five parts:
# 1. The order and its first notes are saved in one transaction
Status: accepted, 25 October 2026
## Context
An agent places an order and writes its first notes in one form, and sees it as one action. The
order and each note are separate aggregates, in two features. The usual rule is one aggregate for
each transaction. With two transactions, an order could be stored while its notes are lost, and
the agent would not know.
## Decision
`OrderService.place` saves the order and calls `NoteService.addNotes`, which joins the same
transaction. Both are stored, or neither is. The call to the stock service happens before the
transaction begins, so the transaction holds no HTTP call.
## Consequences
- One request writes two collections.
`OrderJourneyIT.anOrderAndItsNotesAreStoredTogetherOrNotAtAll` checks that a failed note rolls
back the order too.
- The transaction stays small: one order and a few notes, far below the limits of Amazon
DocumentDB.
- The two features depend on each other at this point. Only this use case may write both.
- Revisit the decision if notes move to another service, or if a transaction starts to include
a call to another service. Then the order is saved first, and the notes follow from an event.The file name starts with the number, 0001, so the records sort in order. The
line Status: accepted, 25 October 2026 holds the status and its date. A later record
that replaces this one would change the line to "replaced by 0007", and keep the rest. The last
item of the consequences says when to look at the decision again.
The feature design notenew#
A feature design note is one page that answers five questions before any code exists. What are the aggregates, and what is inside each one? Which states does each one have, and which changes are allowed? Which rules apply, each with an example? Which events does it raise? Which other features and services does it talk to?
The companion keeps the design note of the order feature in docs/design/order.md.
It is a Markdown file, so its tables are plain text with a | between the columns. The
note opens with the level, then names the aggregate and its states:
# Feature design note: order
Level: full. Orders, the lines that warehouse staff pick, the last payment, and shipping.
The design group wrote this note before the feature was built. Change it in the same pull
request as the code, so the two never disagree.
## 1. Aggregates and their contents
| Aggregate | Root | Inside | Outside, and why |
|---|---|---|---|
| Order | `Order`, with an `OrderId` | status, delivery method, customer contact, placed and finished times, up to 50 order lines, the last payment | notes, because they have no limit and no order rule needs all of them |
## 2. States and allowed changes
| From | To | Through | Allowed when |
|---|---|---|---|
| none | `NEW` | `place` | every product is held by the stock service, with 1 to 50 lines |
| `NEW` | `PAID` | `recordPayment` | the first payment arrives, and a later one only replaces it |
| `PAID` | `SHIPPED` | `ship` | every line is picked, and no note needs action |
| `NEW` or `PAID` | `CANCELLED` | `cancel` | the order is not shipped or cancelled |
| `SHIPPED` or `CANCELLED` | no change | any method except `recordPayment` | never, because both states are final |The first line, Level: full, is the decision about the level. The next sentence
asks for the note to change in the same pull request as the code. The table of states lists every
method that changes the status, and the condition for each one. The last row says that
SHIPPED and CANCELLED are final.
Parts 3 and 4 give each rule with an example, and each event with the service that needs it:
## 3. Rules, each with an example
| Rule | Example |
|---|---|
| an order is placed only if the stock service holds every product | an order for a mug that is out of stock is refused with `PRODUCTS_UNAVAILABLE` |
| an order has 1 to 50 lines | an order with 51 lines is refused with a 400 that names `lines` |
| a line is picked once | picking the same line twice is refused with `LINE_ALREADY_PICKED` |
| an order ships only when it is paid | shipping a new order is refused with `ORDER_NOT_PAID` |
| an order ships only when every line is picked | shipping a paid order with an open line is refused with `UNPICKED_LINES` |
| an order ships only when no note needs action | shipping with one open note is refused with `ORDER_HAS_OPEN_ACTIONS` |
| a repeated or earlier payment changes nothing | the same payment event delivered twice is recorded once |
| a payment for a finished order is still recorded | a payment for a cancelled order is stored, and the status stays `CANCELLED` |
## 4. Events raised
| Domain event | Leaves the service as | Who needs it |
|---|---|---|
| `OrderPlaced` | `OrderPlacedV1` on `order-events` | the stock service, which keeps the products held |
| `LinePicked` | stays inside | nobody outside the service |
| `PaymentRecorded` | stays inside | nobody, because the payment service already knows |
| `OrderShipped` | `OrderShippedV1` on `order-events` | other services that tell the customer |
| `OrderCancelled` | `OrderCancelledV1` on `order-events` | other services that act on a cancelled order |Each example in part 3 names the code of the error, such as ORDER_NOT_PAID. So a
business expert can read the rule, and a developer can find its test. Part 4 says which events
stay inside the service. LinePicked and PaymentRecorded have no
integration event, because nobody outside needs them.
Parts 5 and 6 name the partners of the feature and its decisions:
## 5. Who it talks to
- The stock service, over HTTP, to reserve the products before the order is placed.
- The payment service, through the event `PaymentReceivedV1` on an SQS queue.
- The note feature, for the number of notes that need action.
- The reference feature, to check the delivery method.
## 6. Decisions
- The shipping rule holds only at the moment of shipping (option A). See the README.
- The order and its first notes are saved in one transaction. See
`docs/adr/0001-order-and-first-notes-in-one-transaction.md`.Part 6 points to the decisions, and does not repeat them. The words See the README.
point to a paragraph of the README. The second decision points to the ADR file shown earlier.
Every line of the note can be checked against the code. The state changes are the methods of
Order, and the rules are its tests. The comment of the order status already holds the
table of states in short:
/**
* Where an order is in its life. It is stored as its name, so never rename a value.
*
* <p>NEW becomes PAID when the payment arrives, and PAID becomes SHIPPED. NEW or PAID may become
* CANCELLED. SHIPPED and CANCELLED are final.
*/
public enum OrderStatus {
NEW, PAID, SHIPPED, CANCELLED
}The comment of OrderStatus lists the same changes as the table of states. When the
note and the code disagree, one of them is wrong, and the review finds it. Keep the note in the
repository of the service, beside its README, so it changes in the same pull request as the
code.
A level for each featurenew#
The design note names the level of the feature, which is simple, light or full. The level decides how many classes the feature has. The design group chooses it with three questions. Does the feature change state under business rules? Are its rules only about its own data, with a stored shape that matches the model? Does it call another service, hold the core rules, or need types that Spring Data cannot store as they are? For the questions and the three levels in code, see the chapter How much DDD: three levels in one service.
The level is also a fact that the build can check. The architecture test of the order service lists its full-style features:
private static final String BASE = "com.acme.orderservice";
/** The features in the full style, whose application layer must use ports only. */
private static final String ORDER = BASE + ".order";ORDER is the one full-style feature. A feature that moves to the full style is
added here in the same pull request.
The strict rule. A DDD model is plain code with no framework types, behind repository ports.
What we do. A new feature with rules starts in the light style. The full style needs a reason in the design note.
Why. The light style keeps every part that protects a rule, with far fewer classes. The note feature has 17 source files, and the order feature has 57. Most features have a stored shape that matches their model.
When to stop. Choose the full style when the stored shape bends the model. Choose it too when the feature calls another service, or holds the core rules of the business.
The allowed building blocks, and what needs approvalnew#
A team can use these building blocks without asking. Each one has a home and an example in the order service.
| Building block | Example in the order service | Taught in |
|---|---|---|
| aggregate root, entity, value object, typed ID | Order, OrderLine, Money, OrderId | the chapter on the domain model |
| domain event, recorded by the aggregate | OrderShipped | the outbox chapter |
| domain service named after a rule across aggregates | ShippingPolicy | the chapter on rules |
| repository port for one aggregate, with an adapter | OrderRepository and OrderPersistenceAdapter | the chapter on storage |
| application service with steps only, returning views | OrderService and OrderView | the use-case chapter |
| integration event with one mapper per feature | OrderShippedV1 and OrderIntegrationEventMapper | the contracts chapter |
| port and adapter for another service | StockChecker and StockAdapter | the chapter on other services |
| the simple level, with no building blocks | the reference feature | the chapter on levels |
These need the approval of the design group, with a written reason:
- Event sourcing. It changes how every change is stored and read.
- Full Command Query Responsibility Segregation (CQRS). A second model for reads doubles the code that must stay in step.
- Generic repositories. A repository that loads any type, or returns a query that
callers can extend, lets code reach past the root. The platform's
Repositoryis generic in its types, but each feature has one port for one aggregate, with only the queries it needs. - A shared domain library. Domain classes shared by several services tie their releases together.
- Models shared between features. A feature that uses another feature's entity skips that feature's rules.
- Sagas outside the chosen workflow approach. A new way to run a long flow means a second way to find, retry and repair it.
The platform library states the rule about a shared domain library in its README:
The parent POM and the library that every shop service uses. It holds no business code and no
domain model: services share contracts (OpenAPI files and event schemas), never domain classes,
because a shared domain library would tie their release cycles together.The words a shared domain library name the thing to avoid. The platform holds
cross-cutting code only. Services share contracts, such as OpenAPI files and
event schemas. For what the platform holds, see the chapter Writing the
platform library.
Eight rules for every engineernew#
Every engineer on the team follows these eight rules. Each one has a test or a review question that checks it.
- Change state only through a method of the aggregate. Never add a public setter to an entity. A test checks it.
- Put each business rule in the aggregate. A rule that needs two aggregates goes in a domain service named after the rule. A rule never goes in a controller or an application service.
- Record an event for every change that matters to someone. The aggregate records it, and the save step publishes it.
- Refer to other aggregates by ID. Never hold another aggregate as a field.
- Use another feature only through its application service. Never use its repository, its entities or its domain package. A test checks it.
- Change one aggregate in a transaction, and call other services before it starts. An exception is written down as a decision.
- Use the business words. A class or method named after a business idea, never
Manager,ProcessororHelper. - Test each rule with a plain unit test named after the rule. No Spring and no mocks for a rule of an aggregate.
The use case of the order feature states rules 2 and 6 in its own comment, so a developer who opens the class reads them:
/**
* The use cases of the order feature. This is the one entry point for the controller, the
* message listener and other features.
*
* <p>Every change follows the same steps: load, let the domain apply its rules and change
* itself, save, publish the events, record the audit. The steps that call other services run
* before the transaction. This class decides the order of the steps and never contains a
* business rule: a line that a business expert would recognise as a rule belongs in the domain.
*
* <p>Methods take value objects, which are valid already, and return IDs or views, never an
* aggregate.
*
* <p>The class has no permission annotation. Permissions are checked on the controllers, where
* users enter. Every method here can therefore be called by another feature or by a message
* listener without a second, unprotected copy of the method.
*
* <p>With about ten use cases, split the class by area, such as {@code OrderFulfilmentService},
* not into a read side and a write side.
*/The comment gives the steps. It says that the class decides the order of the steps
and holds no business rule. It
also gives the test for a rule in the wrong place. A line that a business expert would recognise as a rule belongs in the
domain.
Guardrails: the architecture rulestwist#
IArchRule domainIsPlain = Types().That().ResideInNamespaceMatching(@"Shop\..*\.Domain")
.Should().NotDependOnAnyTypesThat().ResideInNamespaceMatching(@"Microsoft\.EntityFrameworkCore.*")
.Because("a domain namespace is plain C#");/** A {@code domain} package uses no framework, no adapter and no other layer. */
public static ArchRule domainIsPlainJava() {
return noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage(
"org.springframework..", "com.mongodb..", "org.bson..", "jakarta..",
"tools.jackson..", "com.fasterxml..", "io.awspring..",
"..application..", "..api..", "..persistence..", "..client..", "..messaging..",
"com.acme.platform.core..", "com.acme.platform.mongo..")
.as("A domain package is plain Java");
}Both panes state that the domain uses no framework. C# writes
NotDependOnAnyTypesThat. Java writes dependOnClassesThat after
noClasses(). The C# rule uses ArchUnitNET, the C# fork of
ArchUnit, and was run against a domain class that held a DbContext. It reported the
dependency. The Java rule is in the platform library, so every service runs the same rules. A
.NET team rarely has such rules unless someone writes them. The platform makes them part of every
build.
A rule that never fails protects nothing. So the companion also tests that the rules fail for code that breaks them:
/** A light-style entity, a document outside persistence, that someone gave a setter. */
@Document("parcels")
static class ParcelWithASetter {
private String status;
public void setStatus(String status) {
this.status = status;
}
}
@Test
void anEntityWithAPublicSetterFailsTheBuild() {
JavaClasses classes = new ClassFileImporter().importClasses(ParcelWithASetter.class);
assertThatThrownBy(() -> ArchitectureRules.entitiesHaveNoPublicSetters().check(classes))
.isInstanceOf(AssertionError.class)
.hasMessageContaining("setStatus");
}ParcelWithASetter is a light-style entity with a public setter. The test checks that
entitiesHaveNoPublicSetters throws an AssertionError that names
setStatus. For every rule of the platform and how the tests run, see the chapter
Testing in depth.
The pull request checklistnew#
The build checks dependencies and setters. It cannot check whether a rule is in the right place, or whether a name is a business word. So every pull request that changes a feature answers five questions. The author answers them in the description, and the reviewer checks the answers.
The companion keeps the questions as the template for the description of a pull request. GitHub
reads such a template from a file named pull_request_template.md. The file may be in
the root of the repository, in docs/, or in .github/. The order service
keeps it in .github/pull_request_template.md:
<!--
Answer each question in a sentence or two, or write "no change". The reviewer checks the
answers against the code. The build already checks the dependencies between packages and
the setters, so these questions cover what it cannot see.
-->
## What changes, and why
## Domain questions
1. **Can any new change of state skip a method of the aggregate?**
Look for a setter, a direct update of a document, or a script.
2. **Is every new rule in the aggregate, or in a domain service named after it, with a test named
after the rule?**
3. **Does every new change of state record its event?** Is a new public event a versioned
contract with a schema in `contracts/events`?
4. **Does the change reach into another feature?** Look for another feature's repository, entity
or domain class. `ArchitectureTest` catches the common cases, not all of them.
5. **Are the names business words, and is the feature still at the right level?**
If it needs the next level, update its design note in `docs/design/` first.GitHub shows the contents of the file in the description of each new pull request, once the
file is on the default branch. The comment at the top says how to answer. The
heading ## What changes, and why asks for a short summary first. The five questions
follow the heading ## Domain questions, and the author writes each answer below its
question. So a skipped question stays visible to the reviewer. The fifth question links the
template to the design note. A feature that needs the next level changes its note in
docs/design/ first.
The fourth question has a test of its own. The architecture test fails when a service uses another feature's repository or entity:
@Test
void servicesUseNoRepositoryOrEntityOfAnotherFeature() {
ArchitectureRules.servicesDoNotUseOtherFeaturesRepositoriesOrEntities(BASE).check(CLASSES);
}servicesDoNotUseOtherFeaturesRepositoriesOrEntities covers the most common way to
reach into another feature. The question in the checklist covers the rest, such as a copied query
on another feature's collection. For the review checklist of the whole service, see the chapter
Changing the service.
Start each feature from a template featurenew#
In a usual .NET team, a new service starts from dotnet new webapi, and each
developer adds folders by kind of class. Each service then looks a little different. In this team,
a new feature starts as a copy of a template feature. The template has the right packages, the
right tests, and a comment in each package that says what goes there.
The companion has no separate template. Its note feature serves as the template of a light-style feature, and its order feature as the template of a full-style one. Here are the files that a light-style feature starts with:
note/
package-info.java what the feature holds, its level, and when to move up
api/
NoteController.java endpoints, each with a permission annotation
NoteApiMapper.java DTOs to values, views to DTOs
dto/ AddNoteRequest, NoteResponse, NoteAddedResponse
application/
NoteService.java the use cases: load, decide, save, publish, audit
NewNote.java, NoteView.java what callers pass in and get back
model/
Note.java the entity, its rules and its events, stored as a document
NoteRepository.java the Spring Data repository
NoteAdded.java, NoteActionResolved.java
Note...Exception.java one exception for each broken rule
test/.../note/
model/NoteTest.java the rules, in plain JUnit
model/NoteMappingTest.java the stored shape, without a database
model/NoteRepositoryIT.java the stored behaviour, against MongoDB
application/NoteServiceTest.java the steps of each use case
api/NoteControllerTest.java the HTTP layer and the permissionsA developer copies the folder, renames the classes, and replaces the rules. The tests come with
it, so no feature starts without tests of its rules. The first file, package-info.java,
is the first thing to rewrite, because it states the level and the rules of the new feature.
When the team keeps making the same mistake, fix the template, not the people. If developers keep forgetting the permission annotation, the template's controller shows it on every endpoint, and a test fails without it.
The wrong decisions to watch fortrap#
Most DDD services fail in the same few ways. Each wrong decision shows early, if someone looks for it. The table gives the sign of each one and how to prevent it.
| Wrong decision | What it looks like | How to prevent it |
|---|---|---|
| aggregates copied from old tables | one aggregate for each table of the old database, with the same columns | find the aggregates from the rules and the events, in the design group, before looking at the old schema |
| an aggregate that is too big | a list with no limit inside, frequent 409 responses, large documents | ask for the maximum of every list, and move a list with no limit to its own aggregate |
| an aggregate that is too small | every use case saves the same two aggregates, or a rule needs a lock across them | merge them when a rule must change both every time |
| rules drifting into services | an if in an application service that a business expert would recognise | the review question about rules, and a test of each rule on the aggregate |
| a batch job or loader that bypasses the entity | a script or job that writes documents directly | jobs call the use case, and the stored document class is not visible outside its package |
| forgotten events | a state change with no event, found when another service misses it | the aggregate records the event in the method, and the save step always publishes |
| features reaching into each other | a service that takes another feature's repository or entity | the architecture test, and calls only through the other feature's application service |
| jargon | names such as OrderManager, process, handle or update | the design note names the methods in business words, and the review checks them |
| gold-plating | event sourcing, CQRS or a full model for a feature with no rules | the level in the design note, and approval for the building blocks on the list |
Two of these need a closer look, because they look harmless.
A batch job that writes documents directly skips every rule of the aggregate. A
data fix that sets status to SHIPPED in the database ships an unpaid order
without an event. Make every job load the aggregate and call its method through the use case, as
the payment listener does.
The order service makes the shortcut hard. Its stored document is package-private, so no code outside the persistence package can build one:
* <p>Only plain types are stored. Enums and codes are strings, so renaming a Java class never
* breaks stored data. Money is DECIMAL128, so it is exact. The class and its fields are
* package-private: nothing outside this package knows the storage shape.
*/
@Document("orders")
class OrderDocument extends BaseDocument {class OrderDocument has no public, so a job in another package cannot
use it. A job can still write raw documents with MongoTemplate. The review question
about skipping a method covers that case.
Gold-plating is the other one. A team that learns DDD wants to use all of it. A feature with no rules then gets an aggregate, ports and events. The level in the design note stops it before the code exists.
Each wrong decision breaks a principle. For the principles, and for the questions that teams ask most often, see the part Reasoning from first principles. It starts with the chapter The principles.
Long flows: a workflow engine or a process managernew#
Some flows take many steps across services and days. A return, for example, could need a label, a parcel scan, a check in the warehouse and a refund. Events between services can run such a flow. But then no single place knows where each return is, and a stuck return is hard to find.
Two options keep the state of the flow in one place, as of October 2026.
- A process manager. The pattern is from the book Enterprise Integration Patterns. A process manager keeps the state of a sequence of steps, and decides the next step from the results so far. It can be an aggregate of its own in one service. The pattern's page warns that the central unit can become a bottleneck.
- A workflow engine. A product that stores the state of each flow and runs its steps. Temporal is one example. It describes itself as an open-source platform for the durable execution of code, and it has a Java SDK.
The order service has no long flow. Its one step across services, the stock reservation, uses an idempotency key and a timeout:
/**
* Asks the stock service to confirm that the items are available, and to hold them for the
* order. The stock service keeps the hold until it sees {@code OrderPlacedV1}, or releases it
* after a timeout if the order is never placed.
*
* <p>Calling it twice for the same order holds nothing twice, because the order ID is the
* idempotency key.
*/
StockAvailability reserve(OrderId order, List<OrderItem> items);reserve needs no flow state, because the stock service releases a hold that no
OrderPlacedV1 confirms. Choose one approach for the whole team when the first long flow
appears, and record it in an ADR. A second approach then needs approval, as the list of building
blocks says.
Rolling it outnew#
A team does not learn DDD from a document. It learns from a service that works, and from people who built it. Roll it out in five steps.
- A pilot service. The strongest engineers build one service in the new way. Each of them works in a pair with a developer who is new to it. The pilot proves the platform, the template and the rules on real work.
- Training from the pilot's code. Teach with the pilot's own classes and tests, not with slides. Each lesson follows one use case, from the controller to the event.
- A design review of each new aggregate, before code. The design group reviews the design note. A boundary that is wrong costs an hour here, and weeks after release.
- A weekly slot for questions. One hour a week, the design group answers questions about real code. Questions that come back often become changes to the template or to the rules.
- Fix the template, not the people. When several people make the same mistake, the template or a guardrail is missing something. Change it, and add a test where one is possible.
An existing service can adopt the architecture rules without fixing every old violation first. The comment of the platform's rules says how:
* <p>To add the rules to an existing service with old violations, wrap a rule in
* {@code FreezingArchRule.freeze(rule)}. ArchUnit then records today's violations, and only new
* ones fail the build.FreezingArchRule.freeze(rule) records the violations that exist today. After that,
only new violations fail the build. So an old service gets the guardrails on its first day, and its
old code is fixed feature by feature. For how the freeze store works, see the chapter
Testing in depth.
1Which decisions does the small design group make, and which does the team make?
2What does a feature design note contain?
3An expert asksWhy does the team fix the template when people keep making the same
mistake?
4An expert asksWhy does a batch job that updates documents directly break a DDD service?
5TranslateWrite this ArchUnitNET rule as an ArchUnit rule in Java:
Types().That().ResideInNamespaceMatching(@"Shop\..*\.Domain").Should().NotDependOnAnyTypesThat().ResideInNamespaceMatching(@"Microsoft\.EntityFrameworkCore.*")
Types().That().ResideInNamespaceMatching(@"Shop\..*\.Domain").Should().NotDependOnAnyTypesThat().ResideInNamespaceMatching(@"Microsoft\.EntityFrameworkCore.*")noClasses().that().resideInAPackage("..domain..").should().dependOnClassesThat().resideInAnyPackage("org.springframework..").
noClasses is a static method of ArchUnit's ArchRuleDefinition, and
.. matches any number of package levels.6Name four building blocks that need the approval of the design group.
The words#
This chapter is a refresher. Read it straight through before a design discussion. It defines the main words of Domain-Driven Design (DDD) and microservices that this book and the Atlas use, in one or two sentences. The words are grouped by topic, in the order in which the ideas build. Each word links to the place that teaches it.
Strategic design#
These words describe the business and its borders. They come before any code.
DDD is a way of building software around a model of the business, in the words of the business. It exists because the business is usually the hardest part of a system.
The domain is the whole area of business that the software serves. For the shop, the domain is selling, paying for and shipping goods.
The problem space is the business as it is, with its needs and its rules. You study it before you decide what to build.
The solution space is the software that you design to answer the problem space. Its parts are models, contexts and services.
A subdomain is one part of the business, such as payments, shipping or the catalogue. It belongs to the problem space, so it exists before any border in the code.
A core subdomain is the part of the business that makes customers choose you. It gets your best people and your richest model, because no vendor can build it for you.
A supporting subdomain is a part that the business needs, but that does not set it apart. You build it simply, because it is specific to you but not a reason to choose you.
A generic subdomain is a part that every business needs in the same way, such as sending email. You buy it or use an open-source product, because building it gives you nothing.
A core domain chart places each subdomain by how complex it is and by how much it sets the business apart. It shows where modelling effort pays.
The ubiquitous language is the set of words that business experts and developers share, used unchanged in the code. It exists so that a conversation and the code mean the same thing.
Event Storming is a workshop in which business people and developers put the events of the business on a wall, in time order. It finds the words, the rules and the borders quickly.
A hotspot is a note on the Event Storming wall that marks a question, a conflict or a pain. Each hotspot needs an owner in the business.
Domain storytelling is a workshop in which a business expert tells one real story, and a moderator draws it as pictures and arrows. It finds the words and the steps of one process.
A bounded context is an area in which one model applies and each word has one meaning. Its border is where a word starts to mean something else.
Strategic design is the part of DDD that finds the subdomains, the bounded contexts and the relations between them. Tactical design is the other part, the building blocks inside one context.
A bounded context canvas is a one-page template that describes one context. It lists the purpose, the language, the inputs and the outputs of the context.
A context map is a picture of the bounded contexts and the relations between them. It shows who depends on whom, and how each border is handled.
Upstream and downstream describe the relation between two contexts. The upstream context decides its own model, and the downstream context depends on it.
A partnership is a relation in which two contexts succeed or fail together. So their two teams plan their changes together.
A shared kernel is a small part of a model that two contexts own together. No team may change it alone, so it must stay small and stable.
A customer-supplier relation is one in which the downstream context is a customer of the upstream context. The upstream team plans its changes with the needs of the downstream team.
A conformist is a downstream context that uses the upstream model as it is, with no translation. It costs little, but every change upstream reaches the downstream model.
An anti-corruption layer is code at a border that translates another model into your own words. It keeps a model that you do not control out of your domain.
An open host service is an interface that a context offers to every consumer, in one documented form. It saves the upstream team from building one integration for each consumer.
A published language is a documented format for the data that crosses a border, such as a JSON Schema for an event. It is separate from the internal model of each side.
Separate ways is a relation in which two contexts do not integrate at all. Each context meets its own need, because integration would cost more than it gives.
A big ball of mud is a system with no clear borders, in which any part may depend on any other part. A change anywhere can then break something anywhere else.
The model#
These words describe the building blocks inside one bounded context. Together they are called tactical design.
An entity is an object with an identity that stays the same while its data changes, such as an order with its ID. Two entities with equal data are still different when their IDs differ.
A value object is an object that is only its values, such as an amount of money. It never changes, so it is safe to share and to compare by value.
An aggregate is a group of objects that one business rule must see at once. The service loads, changes and saves the group as one unit.
The aggregate root is the one object of an aggregate that outside code may hold. Every change to the aggregate goes through a method of the root.
An invariant is a business rule that must be true after every change, such as "an order ships only when every line is picked". The aggregate is drawn around its invariants.
A consistency boundary is the set of data that one transaction keeps consistent. An aggregate is a consistency boundary.
Eventual consistency means that two places agree a little later, not at once. An event carries the change from one place to the other.
A set-based invariant is a rule over many aggregates, such as "each email address is used once". One aggregate cannot check it, so a unique index or a separate process holds it.
A typed ID is a small class that wraps an ID, such as OrderId. The compiler then stops you from passing the wrong kind of ID.
A reference by ID means that one aggregate holds only the ID of another aggregate, never the object. So the two aggregates load, save and change apart.
A factory creates an aggregate in a valid state. A factory for a new aggregate checks the rules, and Order.place is one.
To rehydrate an aggregate is to rebuild it from its stored form. The rules for a new aggregate do not run again.
A repository loads and saves whole aggregates, as if they were in a collection. There is one repository for each aggregate root.
A domain service holds a business rule that needs more than one aggregate, or that belongs to no single object. In this book, it is almost always a rule across aggregates.
A use case is one thing that a user or another system wants to do, such as placing an order.
An application service runs the steps of a use case. It loads aggregates, calls their methods and saves them, but it holds no business rule.
A domain event records that something happened in the domain, such as an order being placed. Its name is in the past tense.
A command asks one named receiver to do something, such as "place this order". A command can be refused, but an event reports something that already happened.
An anaemic domain model has classes with data and setters, but no rules. The rules are in service classes, so they spread and repeat.
A rich domain model keeps each rule in the object that owns the data. So every change goes through a method that checks the rule.
A transaction script is one procedure for each business operation, which reads data, applies the rules and writes the result. It is the simplest choice for a part with few rules.
An active record is an object that holds one row of data and also saves itself. It suits simple data with few rules.
Optimistic locking finds two writers of the same data by comparing a version number on each save. The second save fails, so no change disappears in silence.
A lost update happens when two writers read the same data and the second save overwrites the first. Optimistic locking prevents it.
The business-expert test asks whether a business expert would call a line of code a rule. If the expert would, the line belongs in the domain.
The order service uses three levels of DDD, and the full style is the richest. The order feature uses it, with a rich aggregate, ports and a mapper to a stored document.
The light style keeps the rules in the entity, but the entity is its own document and the service uses Spring Data directly. The note feature uses it.
The simple level has no domain model, because the feature has almost no rules. The reference feature uses it.
A mapper converts one shape of data into another shape, such as an aggregate into its stored document. The order service also uses a mapper to turn domain events into integration events.
A view is a read-only copy of some data of an aggregate, which an application service returns. It keeps the aggregate itself inside the service.
Architecture inside a service#
These words describe how the code of one service is arranged.
A layered architecture stacks the code in layers, such as web, business and data. Each layer calls only the layer below it.
A port is an interface that the domain or the application declares for something that it needs from outside, such as storage.
An adapter implements a port with a real technology, such as a database driver or an HTTP client.
A persistence adapter is the class that implements a repository with a real database. It maps the aggregate to a stored document and back.
Hexagonal architecture puts the domain in the middle and connects everything outside through ports and adapters. It is also called ports and adapters.
Onion architecture draws the code as rings, with the domain model at the centre and the infrastructure at the edge. Every dependency points inward.
Clean architecture is another version of the same idea. Its rings are entities, use cases, adapters and frameworks.
The dependency rule says that code depends only inward, toward the domain. The domain depends on no framework and no adapter.
A feature package holds all the code of one business feature, from the controller to the storage. A change to the feature then stays in one folder.
A vertical slice is the code of one use case, from the request to the database, kept together. It is another way to arrange code than folders by layer.
A modular monolith is one deployable application, divided into modules with enforced borders. It gives clear borders without a network between them.
Spring Modulith is a Spring project that checks the borders between the modules of one application. The order service uses its registry of published events.
The event publication registry is the store in Spring Modulith of the events that transactional listeners must receive. In the order service, it is the outbox.
An architecture rule is a test that fails the build when code breaks a rule of structure. An example is a domain class that imports a framework.
A platform library is a shared library that gives every service the same technical defaults, such as logging, security and the outbox. It holds no business code.
Command query responsibility segregation (CQRS) uses one model to change data and another model to read it. It pays when the screens need a shape that the aggregates do not have.
A read model is data shaped for one screen or one query, built from the data of the write side. It never decides a command.
A projection is the code that builds a read model from a stream of events.
Event sourcing stores every change of an aggregate as an event, and rebuilds the current state by replaying the events. It pays when the history itself matters to the business.
A snapshot is a saved copy of the state of an event-sourced aggregate at one point. A load then replays only the events after it.
An event store is the database of an event-sourced system. It keeps every event in order and never changes one.
Upcasting converts an old stored event into its newer shape when the event is read. The old events stay unchanged in the store.
Integration#
These words describe how services work together, and what happens when a call or a message fails.
A synchronous call waits for the answer before it continues, like an HTTP request. Both services must be up at the same moment.
An asynchronous message is sent without waiting for an answer, through a broker. The receiver reads it when it is ready.
An integration event is an event published to other services, in a stable and versioned form. It is separate from the domain event, so the internal model can still change.
An event notification carries little more than an ID and what happened. A consumer that needs more data asks the producer.
Event-carried state transfer puts the data that consumers need into the event itself. The consumers then need no call back to the producer.
The dual-write problem is the risk of writing to a database and to a broker as two separate steps. One write can succeed while the other fails.
A transactional outbox is a collection in which the service writes its outgoing messages, in the same transaction as its data. A separate process then sends the messages.
Change data capture reads the database's own log of changes and publishes the changes as messages. It is another way to avoid a dual write.
At-least-once delivery means that a message is never lost, but it may arrive more than once.
An operation is idempotent when running it twice has the same effect as running it once. A consumer of messages must be idempotent, because a message can arrive twice.
An idempotency key is a value that the caller chooses once and sends with every attempt of the call. The receiver then recognises a repeat.
An inbox is a list of the IDs of the messages that a consumer has processed, stored in the same transaction as the change. A duplicate then finds its ID and is ignored.
Ordering per key means that a broker keeps the order only among messages with the same key, if it keeps an order at all. An Amazon Simple Queue Service (SQS) standard queue does not.
A FIFO queue is a queue in SQS that keeps the order of the messages in a message group, and removes duplicates for five minutes. A message group is the set of messages that share one group ID.
The visibility timeout is how long SQS hides a received message from other consumers. If the consumer does not delete the message in that time, it can be received again.
A dead-letter queue (DLQ) holds messages that failed too many times. So they stop being delivered again and again, and a person can look at them.
A redrive policy moves a message to the dead-letter queue after a set number of failed receives. A redrive can also move messages back, once the cause is fixed.
A poison message is a message that fails every time it is processed, such as a message with a broken format.
A tolerant reader reads only the fields that it needs and ignores the rest. So a producer can add fields without breaking it.
Expand and contract changes a published contract in three steps. You add the new form, move every consumer to it, and then remove the old form.
An API contract is the shape of an API or an event that other teams build against. It is kept in a file, such as an OpenAPI file.
Contract first means that a team writes the API contract before the code, and generates or checks the code against it.
A consumer-driven contract is a test, written from the needs of a consumer, that the producer runs in its own build. It finds a breaking change before release.
A timeout is the longest time that a caller waits for an answer. Without one, a slow service holds the threads of its callers.
A transient failure is a failure that may pass if you try again, such as a timeout or a 503 answer.
A retry repeats a call that failed with a transient failure. It is safe only for a call that is idempotent.
A circuit breaker stops calls to a failing service for a while, and fails them at once. It gives the failing service time to recover.
A cascading failure spreads from one slow or failed service to the services that call it, and then to their callers. Retries without limits make it worse.
A bulkhead gives each dependency its own pool of threads or connections. So one slow dependency cannot use up the resources of the others.
Load shedding refuses some requests when a service is overloaded, so that the other requests still succeed.
Backpressure is a signal from a busy receiver that tells the sender to slow down, such as a full queue that refuses the next item.
A saga is a business process that spans several services as a chain of local transactions. Each step that may need undoing has a compensating action.
A compensating action undoes the effect of an earlier step in business terms, such as a refund for a payment.
The pivot step is the step of a saga after which the saga must finish. Steps before it can be compensated, and steps after it are retried until they succeed.
A semantic lock marks data as pending while a saga runs, such as a reserved item. Other work then treats the pending data with care.
Choreography runs a saga with no central coordinator. Each service reacts to the events of the other services.
Smart endpoints and dumb pipes means that the services hold the logic, and the broker only carries the messages. James Lewis and Martin Fowler wrote it down in 2014.
Orchestration runs a saga from one coordinator, which tells each service what to do next.
A process manager holds the state of a long process and decides the next step. It is one way to orchestrate a saga.
A workflow engine is a product that runs long processes safely, with their state, their timers and their retries.
Two-phase commit is a protocol that joins several databases into one transaction. It holds locks while the network talks, so services rarely use it.
API composition builds one answer by calling several services and joining their results.
A backend for frontend (BFF) is a small service for one kind of client, such as a mobile app. It shapes the data of several services for that client.
A trace ID is an ID that a service sends on with every HTTP call and writes in its log lines. An event carries it only if the service adds it, so a business ID such as the order ID often connects the traces.
A span is one timed step of a trace, such as the part of the work that one service does.
A correlation ID is an ID that ties together the calls and the messages of one piece of work. In the order service, the order ID does this job across events.
Distributed tracing records each step of a request across services, under one trace ID. A tool then shows the whole path and the time of each step.
Organisation and migration#
These words describe services as a whole, the teams that own them, and the way from an old system to a new one.
A microservice is a service that one team can change and deploy alone, with its own data. Its size follows a business border, not a count of lines.
Independent deployability means that a service can go to production while no other service changes. It is the main promise of microservices.
An entity service is a service around one noun, such as a product, with little more than create, read, update and delete. A real operation then needs calls to many of them.
A shared database is one database that several services read and write. It ties the services together, because no service can change a table alone.
Database per service gives each service its own data store, which only that service may touch.
A distributed monolith is a set of services that must change and deploy together. It has the costs of services and the coupling of a monolith.
The fallacies of distributed computing are eight false beliefs about networks, such as "the network is reliable". Peter Deutsch listed seven of them in 1994, and James Gosling added the eighth.
Conway's law says that the structure of a system copies the communication structure of the organisation that builds it. Melvin Conway described it in 1968.
The inverse Conway manoeuvre shapes the teams first, so that the system grows the borders that you want.
A stream-aligned team owns one flow of business change from end to end, such as checkout. Most teams are of this kind.
A platform team builds the shared tools that the other teams use, such as the pipelines and the platform library.
An enabling team helps other teams to learn a skill, and then moves on.
A complicated-subsystem team owns a part that needs rare expert knowledge, such as a pricing engine.
Cognitive load is the amount that a team must hold in mind to do its work. A team that owns too much makes slow and unsafe changes.
The design group is two or three senior engineers and a business expert. It decides the boundaries, the aggregates, where rules go, the workflows and the level of DDD for each feature.
A feature design note is a short document that records how a new feature fits the model, before the code is written.
An architecture decision record (ADR) is a short document that records one decision, its context and its consequences. It tells later readers why the code is as it is.
A template feature is a small, complete feature that a team copies to start a new feature in the agreed shape.
The microservices premium is the extra cost of running many services, such as pipelines, monitoring and contracts. A team pays it before the services give any benefit.
Monolith first is the advice to start with one application and to split it later, when the borders are known. Martin Fowler argued for it in 2015.
The strangler fig pattern replaces an old system piece by piece, by sending more and more requests to new code. Martin Fowler described it in 2004 as the strangler application, and renamed it in 2019.
A seam is a place where you can change behaviour without editing the code around it, such as an interface or a routing point.
Branch by abstraction puts an interface in front of the code that you replace. The old code and the new code then live side by side behind it.
A bubble context is a small new context with a clean model, protected from a legacy system by an anti-corruption layer.
A parallel run sends the same work to the old system and to the new system, and compares the results before the switch.
A feature flag switches a piece of code on or off while the application runs, with no new deployment.
To deploy is to put code into production, and to release is to let users reach it. A feature flag keeps the two steps apart.
A canary release sends a small share of the traffic to a new version first, and watches it before sending the rest.
A service level indicator is a measurement of what users experience, such as the share of requests answered within 300 milliseconds.
A service level objective is a target for a service level indicator, such as 99.9 percent of requests answered within 300 milliseconds.
An error budget is the amount of failure that the service level objective still allows. When it is used up, the team works on reliability before new features.
A data mesh gives each context the job of publishing its own data for analysis, as a product. Zhamak Dehghani described it in 2019.
The principles#
Experienced teams do not remember hundreds of answers. They remember a few principles, and they ask which principle decides the case. This chapter gives nineteen principles. Each one says what is true, why it is true, and when it stops being true. The next four chapters answer the common questions with them.
How to use the principles#
Most questions about Domain-Driven Design (DDD) and microservices have a short answer. The answer comes from one or two principles and one fact about your case. A principle tells you what matters. The fact tells you which way it goes.
The principles are in four groups. The first six are about the model, its data, and how much of it to build. The next four are about borders, and the structure of the code inside them. The next six are about calls, messages and callers that cross a border. The last three are about the cost of borders, the teams that build them, and the order of decisions.
Each principle has a short name. Every field question in the next four chapters names the principles that decide it, with a link back here. Review is the mode of this book that asks you one question at a time. In Review, each principle is also a card. The card asks what the principle says and when it stops holding.
A transaction protects one place#
A transaction makes several changes happen together, or not at all. It can do that only inside one database system, because that system keeps one log of the changes. Anything outside that system is consistent later, or not at all.
In the order service, one transaction usually holds one aggregate, its outbox row and its audit entry. All of them are in one DocumentDB cluster.
A database can undo its own changes, because it keeps a log of them. A second database, a message broker or another service keeps its own log. No single log covers both places, so no single undo covers both.
The rule "one aggregate per transaction" follows from this principle and from the next one. Each aggregate keeps its own rules. So a transaction that spans two aggregates is a border that you can never move to a second database later.
Two databases can share a transaction through a protocol called two-phase commit. It holds locks while the two sides talk over the network. So one slow or failed partner blocks everyone who waits for those locks. It is rarely worth that cost, and most cloud message services do not support it.
The order service breaks the rule "one aggregate per transaction" once. An order and its first notes are saved together, because the agent sees them as one action. The cost is that the two aggregates now succeed or fail together, and must stay in one database. See the section One aggregate per transaction, and the order with its notes.
Now, or a little later#
Some rules must be true at every moment. Other rules may be true a little later. A rule of the first kind needs all its data inside one transaction. A rule of the second kind can cover aggregates and services, and an event closes the gap.
A rule that must be true now is checked and saved in one step. Suppose its data is in two places. A second request can then change one place between the check and the save. The rule is broken, and nobody sees it.
A rule that may be true later accepts that window. It is checked again, or repaired, when the event arrives. The line between the two kinds is a business decision. Ask the people who own the rule what happens today when it is broken for a minute. "A refund" or "a phone call" means later. "A fine" or "money that we cannot get back" means now.
A rule of the first kind makes every writer of its data wait for the others. When many people write at once, as in a flash sale, those writers fail or slow down. Then the cost of the waiting can be higher than the cost of being wrong for a moment.
The business then moves the rule to the second kind on purpose. An airline sells more seats than the plane has, and handles the rare problem with a voucher. For the four ways to handle the gap, see the section Four ways to handle the gap, and how to record the choice.
The root guards the rule#
An aggregate has one entry point. The entry point is called the aggregate root. Every change to the aggregate goes through a method of the root. The method checks the rules first, and then it changes the data.
A rule that covers several objects can be checked only by code that sees all of them. If any code can change one object directly, that code can skip the check. Then the rule is true only while every caller remembers it.
One entry point gives one place to check the rule, one place to test it and one place to read it. A new use case cannot forget the rule, because it has no other way in. If ordinary features keep wanting to skip the root, the boundary is wrong. The part that they change is probably an aggregate of its own.
Reads skip the root by design, because a read changes nothing. A data migration and a repair of broken data may also write the stored form directly. They must still leave the data in a state that the root would accept.
A change to a million records is also too slow through the root, one aggregate at a time. A bulk update in the database is then the right tool. The cost is that the rule is now written twice, once in the root and once in the update, and both must agree. For the order aggregate, see the section The root is the only way in.
Every fact has one writer#
Each piece of data has one owner. Only the owner writes it. Everyone else asks the owner, or keeps a copy that the owner's events update.
Two writers of the same data bring two sets of rules for it. Each writer can break the rules of the other writer. And each writer must agree with the other writer before it can change the shape of the data.
One writer gives one place for the rules, and one team that may change the shape. A copy is safe from conflicting writes, because nobody changes the truth through it. But a copy can be out of date. So a copy may be shown, and the owner decides.
Two contexts may own a small part of the model together, with its code and sometimes its data. This is called a shared kernel. Every change to it needs both teams, so keep it small and stable.
Some systems need many writers by design. An app that works offline, or a database that accepts writes in two regions at once, lets two copies change at the same time. The cost is a rule that merges the two versions. For the story of a table that had many writers by accident, see the Atlas chapter Nobody owns the product table.
Spend design effort where the business is different#
Some parts of the business are the reason customers choose you, and their rules are complex. Spend careful modelling there. Other parts are the same in most companies, or their rules are simple. For those parts, write something simple, or buy it.
A rich model costs time to build and time to change. It pays back where the rules are complex and where they set you apart, because mistakes there cost most. A list of delivery methods or a login screen works the same way in most companies. A careful model there gives nothing that a library or a vendor does not already give.
The parts of the business are called subdomains. The three kinds are core, supporting and generic. This is why the order service uses three levels of DDD, and not one level everywhere.
A supporting part can have complex rules. Customers do not choose you for it, but a simple model would still break its rules. Then it needs a rich model too. The cost is careful work on something that does not set you apart.
The kind of a subdomain also changes over time. A supporting part can become the reason that customers come. So review the kinds when the business changes. See the section Choosing the level for each feature.
Data outlives the code that wrote it#
Stored documents, messages in a queue and published events stay after the code that wrote them is gone. New code must still read them. And someone must still find them when the data has to be deleted or moved.
During a rolling deployment, old code and new code run at the same time and read the same documents. A message can wait in a queue, or in a dead-letter queue, for days. No compiler sees any of this data. So every change to a stored shape needs a plan for the old shape. First read both forms, then migrate, then remove the old form.
The same is true for personal data. A fact that was copied into ten places must be found in ten places when a customer asks to be forgotten.
Data that is built again from nothing on every deployment can change its shape freely. A cache is one example. A read model that can be rebuilt from events is another. The cost is the time to rebuild. See the section Changing the shape of stored documents.
A word that changes meaning marks a border#
A model is a set of words with exact meanings. Where a word starts to mean something different, a new model starts. The area where one model holds is called a bounded context.
One class cannot hold two meanings without fields that only one meaning needs. An order in sales has lines and prices. An order in the warehouse has boxes and a pick list. One class for both changes whenever either team changes its idea of an order.
Two models, with a translation at the border, let each team change its own words. The translation is called an anti-corruption layer when it protects your model from a model you do not control.
A difference only in the name, with the same rules, is not a border. Two teams that say "client" and "customer" for the same thing need one word, not two models.
A border also costs a translation. In a small system that one team builds, a border may not yet be worth that cost. For how borders are drawn, see the Atlas chapter Drawing the borders.
What changes together stays together#
Put code that changes for the same reason in the same place. Keep code that changes for different reasons apart. This principle decides folders, modules and services.
A change that touches one place is one review, one test run and one deployment. A change that touches three places needs all three to agree, and to ship in the right order. Between services, that order needs a plan for the releases.
So a boundary that most changes cross is in the wrong place. A good boundary is one that most changes stay inside. David Parnas made this argument in 1972. He said to divide a system by what is likely to change.
Some code changes together but must still be apart. A part with very different load, security or availability needs may need its own deployment. Then every change that crosses the border costs more. To keep that cost low, keep the contract between the two parts small and stable.
For how the order service groups its code, see the section Features at the top, never technologies.
Depend on your own rules, not on the details#
Point each dependency from the details toward the business rules. The details are the web framework, the database and the other services. The rules must never have to change because a detail changed.
So the web layer, the database adapter and the clients for other services depend on the domain. The domain depends on none of them.
Suppose the domain imports the database library. An upgrade of the library can then force a change in the business rules. Now suppose the domain only declares an interface for what it needs. That interface is called a port. The adapter behind the port changes, and the rules do not. The rules still change often, but only for business reasons.
The same logic decides which side of a border translates. The side that depends on the other side translates. So a change on the other side stops at the translation.
In a feature whose rules need no protection from the storage, ports and adapters add files and protect nothing. The order service's reference feature has one simple rule, and it uses a Spring Data repository directly. The ports and adapters are kept for the order feature, where the rules are many.
See the sections The direction of the dependencies and The simple level: the reference feature.
Reads and writes have different shapes#
A write must protect rules, so it works on one aggregate at a time. A read must show what a person needs, and that often comes from many aggregates. When the two shapes differ, give each one its own model.
An aggregate is shaped by its rules, not by a screen. A list of orders with customer names and stock levels needs data from several owners. To load every aggregate for that list is slow. It also pulls the aggregates toward the shape of the screen.
A model for reading is built for one question, and it never decides anything. So it may be a little late, joined and copied, and nothing breaks.
When the screen shows one aggregate, the aggregate is already the right shape. A second model then costs code, storage and delay, and it buys nothing.
A model for reading also must not decide a command. It may be out of date at exactly the moment that matters. See the section One model for reads and writes: CQRS or not.
A remote call fails, waits, repeats or arrives out of order#
A call or a message to another process can fail, can be slow, or can arrive more than once. Messages can also arrive in a different order from the order they were sent in. Each of these happens in production. So every remote call needs a timeout and a decision about retries. And every receiver must handle a duplicate and a late message.
The caller cannot tell a lost request from a lost answer. In both cases it sees nothing. If it retries, the receiver may process the request twice. If it does not retry, the request may be lost.
A message broker has the same choice, and most brokers deliver a message at least once. So the receiver must make a duplicate harmless, because the network cannot. A receiver that does this is called idempotent. To ignore a late message, compare a version or a sequence from one writer. The clocks of two machines are never exactly the same.
A direct method call between two modules of one deployment, in the same transaction, does not fail in these ways. It needs no timeout and no idempotency key.
But an event between two modules that goes through an outbox can still arrive twice, because the outbox sends again after a failure. So its listener must still be idempotent. See the chapters Calling other services and Consuming events: SQS and the inbox.
A synchronous call borrows the other side's availability#
When a request waits for the answer of another service, the request works only while that service works. Each call adds its own chance of failure. So call another service only when you need its answer now.
Suppose each service works 99.9 percent of the time. A request that needs four of them works about 99.6 percent of the time. This is true for a chain of calls, and for four calls made in parallel. The slowest service also sets the speed of the request.
A message does not need the receiver to work at the moment it is sent. The receiver handles it when it can, and the sender is not blocked.
Some answers are needed now. The order service must know that the stock is held before it accepts an order. Then a call is the right choice, and a timeout, a retry and a circuit breaker limit the damage.
A message has a cost too. The sender does not know when the work is done, or whether it succeeded. See the section Why the order service calls the stock service.
Capacity is finite, so refuse early#
Every service can do only a limited amount of work at once. When more work arrives, refuse some of it quickly. That is better than accepting everything and failing slowly. Retries are more work, so they must wait longer each time, and then stop.
A request that waits holds a thread, a connection and memory. When requests arrive faster than they finish, the waiting grows until every request times out. Then the callers retry, and the load grows again. This is called a retry storm.
A limit, a timeout and a quick refusal keep the accepted requests fast. A circuit breaker applies the same logic to a dependency that fails. It stops the calls for a while, so the dependency can recover. See the section Retry and circuit breaker: what each one is for.
Work that can wait, such as a message in a queue, does not need to be refused. The queue holds the peak, and the consumer works at its own speed. The cost is delay. A queue that grows without a limit also hides the problem, so watch how old its oldest message is.
A published contract is hard to take back#
When another team depends on your API or your event, you cannot change it alone. You can add optional things that a tolerant reader ignores. To remove or rename anything, you run the old form and the new form together, until every consumer has moved.
You do not deploy the consumers, and you often do not know all of them. A change that breaks the contract breaks someone at a time that you do not choose. Even an addition can break a consumer. A new required field is one example. A new value in a list that the consumer does not expect is another.
So keep the published contract small, and keep it apart from your internal model. The internal model can then change every day, and only a mapper changes with it. The safe way to change a contract is called expand and contract. First add the new form, then move every consumer, then remove the old form.
Between modules that always deploy together, a rename in the code is a refactoring, and the compiler finds every use. But stored documents and messages in a queue are not code. Old and new versions of the service read them during a deployment. So they still need expand and contract. See the principle Data outlives the code that wrote it. See also the sections Domain events and integration events and The mapper decides what leaves.
You can fix only what you can see#
When work crosses threads or processes, every step must write its log lines with a shared ID. Then a person can follow one request, one message or one order through every service. And the team must measure what the customer feels, such as failed orders, not only how busy the machines are.
A service handles many requests at the same time, so their log lines are mixed together. Across services, each process logs only its own part. Without a shared ID, an incident means matching timestamps across many log files.
So the design includes a trace ID on every request, and an ID on every message. An alert on what the customer feels says that something is wrong before anyone reads a log. These are not extras for later. See the section Keeping event flows traceable.
A small internal tool, used by a few people who report a problem at once, needs little of this. A phone call costs less than the tracing. See the Atlas chapter Can you see it?.
Check permissions where a user's request enters#
A permission belongs to a caller. So check it where the caller's request enters the service, for example in the controller. That place is called an entry point. The code behind it runs for a caller who was already checked, or for the service itself.
Only an entry point knows who the caller is. A message listener, a scheduled job and another feature have no user. A check deep inside the service would refuse work that the service itself decided to do. A developer then writes a helper that pretends to be a user, and the check protects nothing.
A rule about one record, such as "only a note of this order", needs the record. So it is a rule of the domain, and the entry point passes the caller's ID in as a plain value. Between services, each service sends a token of its own. The stock service trusts the order service, not every user.
A service with several entry points for users, such as REST and GraphQL, may check the permission on the use case instead. Then each new entry point is protected without a new line. The cost is that listeners and jobs need use cases of their own, which carry no check. See the sections Choosing the place by the shape of the service and Why an identity-swapping helper is a warning sign.
Systems copy the teams that build them#
The borders of a system form where the teams that build it talk least. Melvin Conway described this in 1968, and it is called Conway's law.
Inside a team, a change across two modules is a conversation at one desk. Across two teams, it is a meeting, a ticket and a wait. People avoid the expensive path, so code grows where talking is cheap.
If two teams share one context, the context soon has two models in one place. If one team owns parts of five contexts, the borders between the five contexts blur. So the borders of the teams and the borders of the contexts should match.
A small company with one team can own several contexts well. The borders then come from the language, not from the teams, and rules in the build hold them.
The law describes a pressure, not a rule. A team can work against it, but it pays for that every day. See the Atlas chapter You ship your org chart.
Every boundary has a price#
Each boundary has a running cost, which you pay every day, whether it helps or not. A layer costs files. An aggregate costs a rule about transactions. A service costs a network, a pipeline, people who answer alerts at night, and a contract. Draw a boundary only where it gives more than it costs.
The running cost comes in builds, in deployments and in the work to run the system. The return comes only when a part must scale, fail or ship alone, or when a rule needs the protection.
A boundary that no rule needs, and that no part needs alone, is only cost. See the section Three levels in one service.
Some boundaries cost almost nothing. A package-private class, or an architecture rule in the build, adds no network and no pipeline. Then the test is not needed. Draw the boundary whenever it helps a reader.
Prefer the decision you can undo#
When you are not sure, choose the option that is cheap to change later. Learn first. Then make the expensive decision with what you learned.
At the start of a project, you know least about the business. A wrong border between modules costs a refactoring. A wrong border between services costs a data migration, two contracts and two pipelines.
So start with the form that you can undo, such as a module. Move to the expensive form, such as a service, when you have evidence that you need it. Feature flags, the strangler pattern and parallel runs follow the same logic.
Sometimes the cheap form is not cheap to leave. Stefan Tilkov argued in 2015 that a monolith teaches habits, such as shared tables and direct calls, that make a later split harder. When the borders are already known, and the team can run services, it can cost less to start with them.
Some borders are also expensive to add later, whatever you choose first. A border around personal data or payment data is hard to add once code depends on the open form. Draw those borders early, even before they pay. See the Atlas chapter The consultant who said microservices.
A new question in four steps#
Most questions in a design meeting are new in some small way. These four steps turn a new question into an answer that you can explain.
- Say the question as a choice between two options.
- Find the rule or the data that the question is about. Ask who writes it, and when it must be true.
- Find the principle that decides it. Most questions need one principle. A hard question needs two principles that pull in different directions.
- Check when that principle stops holding. If your case is one of those cases, the answer changes.
Here is an example. Should the stock level be inside the order aggregate? The stock service writes the stock level. So the principle Every fact has one writer says no. Then ask whether "never sell more than the stock" must be true now. It must, so the stock service checks it now, in its own transaction, and holds the products. The order keeps only the answer.
One part may be true a little later. That part is the link between the hold and the placed
order. The stock service keeps the hold until it sees the event OrderPlacedV1, or
it releases the hold after a timeout.
Field questions: the model#
These are the questions that teams ask about the inside of one service built with Domain-Driven Design (DDD). They are about aggregates, entities, value objects, rules, repositories and events. Each answer starts with what you would say in a meeting. Then it names the principle that decides it, shows where the order service does it, and says when the answer changes.
What an aggregate is#
What qualifies a group of objects as an aggregate?#
Decided by Now, or a little later and A transaction protects one place.
An aggregate is not a group of things that belong together in a diagram. It is the data that one rule must check and save in one step. "An order ships only when every line is picked" needs the status and every line at the same moment. So the status and the lines are one aggregate.
A rule of that kind is called an invariant. If a rule may be true a little later, it does not pull data into the aggregate. That data can be in another aggregate, and an event or a later check closes the gap.
See the sections What an aggregate is for and Art or science? Questions you can apply. The second section gives seven questions and four tests.
A table design or a class diagram does not show an aggregate. Two objects joined by a foreign key may be two aggregates. Only the rules decide.
Should every entity be part of an aggregate?#
Decided by Every fact has one writer and The root guards the rule.
Every piece of data needs one owner that writes it and checks its rules. In the model, that owner is an aggregate. An entity outside every aggregate has no root to guard it, so any code may change it.
An aggregate with one entity is common. A note in the order service is one entity, and it is also the root of its own aggregate. The word aggregate does not mean "several objects". It means "the unit that one rule protects".
The note is an aggregate of one entity. See the section Why order and note are separate aggregates.
A feature with no rules has no aggregates at all. The reference feature of the order service stores lookup values as plain records. See the section The simple level: the reference feature.
Can I update one entity inside an aggregate without going through the root?#
Decided by The root guards the rule.
A rule such as "a finished order cannot change" covers every line of the order. Only the root sees the status and the lines together. If code changes a line directly, it skips that check. Then the rule holds only while every caller remembers it.
The root also records the event of the change. A direct update changes the data, and no other part of the system hears about it.
The order picks a line through order.pickLine(lineId, clock), and the methods of
the line are package-private. See the section The root is the only way
in.
A data migration or a repair of broken data may write the stored form directly. It must still leave data that the root would accept. If ordinary features keep wanting to change the inner entity alone, that entity is probably an aggregate of its own.
Which entity of an aggregate is the root?#
Decided by The root guards the rule.
The root is the only way in, so it must be the object that callers can reach. Callers reach an object by an ID that they know. A customer knows an order number. Nobody outside the order knows a line ID first.
The root must also see all the data of the rules. So the root is the object at the top of those rules, and the other entities hang below it.
See the section The order aggregate, piece by piece.
If outside code often needs to find an inner entity by its own ID, that entity may be the root of a separate aggregate. Check whether a rule really needs it inside.
The size of an aggregate#
How big should an aggregate be?#
Decided by Now, or a little later and Every boundary has a price.
Every change loads and saves the whole aggregate. So each extra piece makes every change slower, and makes conflicts between users more likely. That cost comes on every change.
The cost buys something only for the rules that must be true now. So ask what each change must check, not what belongs to the order in a diagram.
The order holds at most 50 lines and its last payment. Its notes are outside. See the section The order aggregate, piece by piece.
Too small is also wrong. If every change must update two aggregates together, they are one aggregate. See the section Signs of an aggregate that is too small.
Users often get a 409 conflict when they change one kind of aggregate. What does that tell
you?#
Decided by Now, or a little later and A transaction protects one place.
An aggregate has one version for all its parts. Two saves of the same version conflict, and the second one fails. That is correct when both changes touch the same rule.
It is wrong when the two changes have no rule in common. A note and a picked line need not be consistent with each other now. So they should not share a version.
See the section Signs of an aggregate that is too big. The platform answers every conflict with 409.
A conflict between a cancel and a ship of the same order is the aggregate doing its job. Do not split it to remove that conflict.
Can I change the boundary of an aggregate after the service is in production?#
Decided by Prefer the decision you can undo and Data outlives the code that wrote it.
A boundary inside one service is a decision that you can undo. The aggregates share one database and one deployment. So a split or a merge changes classes, one migration and some tests.
The stored documents keep their old shape until the migration moves them. So the migration is part of the change, and it runs before the new code takes traffic.
A rule that moves out of the aggregate gets a new home, such as a domain service that takes a count. That rule gets new tests. The rules that stay keep their tests.
See the section How to split or merge an aggregate.
The cost rises when the boundary is published. If another service stores the aggregate's ID or receives its events, a split changes a contract too.
A small change loads the whole aggregate. Should I load only the part that I change?#
Decided by The root guards the rule and A transaction protects one place.
A partial load gives the root half of its data. A rule such as "a finished order cannot change" then checks a status that may not be loaded. And a save of half an aggregate may write over the other half.
One load and one save of the whole aggregate keep the check and the save in one step.
Each use case that changes an existing order starts with orders.get(id), which
loads the whole order. See the section The steps of a use case.
If most changes need only a small part of the aggregate, the aggregate is too big. Fix the boundary, not the load.
Between aggregates#
Why does an aggregate refer to another aggregate by its ID, and not by an object
reference?#
Decided by Every fact has one writer and A transaction protects one place.
With an object reference, a note could reach its order and change it. The note would then be a second writer of the order. A save of the note might also save the order, in the same transaction.
An ID gives no such path. To change the order, code must ask the order's own feature. So the order keeps one writer and one version.
See the section Refer to another aggregate by its ID.
Inside one aggregate, the root holds its entities as objects. The rule applies only between aggregates.
Should a reference to another aggregate's ID have its own type?#
OrderRef. The order service keeps such IDs as text, and records that choice.Decided by Every boundary has a price.
A type of its own gives a check at compile time. The note's order ID can then never be passed where another ID belongs.
Plain text also keeps the note free of the order's classes, so the type adds no independence. It costs a class and a conversion, for a value that the note only stores and compares. So the order service keeps text. The order feature, which owns the ID, checks it when a note is added. A bad ID gets the order feature's own 400, and an unknown one gets its 404.
See the section A type for a foreign ID, or plain text.
Add a type when the ID takes part in a rule, or when a method takes two IDs that could be swapped.
Where does a rule go that needs data from two aggregates?#
Decided by The root guards the rule and Now, or a little later.
Each root guards only its own data. A rule that needs two aggregates cannot be in one root, because that root does not see the other data.
A domain service checks the rule with the values that it receives. It loads nothing itself. So it stays plain code that a test calls with plain values.
ShippingPolicy.assertCanShip(order, notesNeedingAction) checks that no note needs
action. See the section Worked example: no shipping while a note needs
action.
When a rule needs only one aggregate's data, it belongs in that aggregate. Prefer the aggregate whenever its own data is enough.
A domain service checks the rule only at one moment. Do not add a separate lock to make it hold longer. If the rule must hold at every moment, make both changes write the same document. That is a lock through the version that already exists, and it costs the conflicts of a bigger aggregate.
May one transaction change two aggregates?#
Decided by A transaction protects one place and Now, or a little later.
A transaction that changes two aggregates holds both of them at once. It is larger and slower, and it can conflict with every writer of either aggregate. Vaughn Vernon gives the rule in Part I of his essays Effective Aggregate Design (2011). One transaction changes one aggregate, and the other aggregates follow by events.
In Part II, Vernon also names reasons to break the rule. One reason is the convenience of the user interface. It is safe when creating several new aggregates together means the same as creating them one at a time. New aggregates have no other writers, so they cannot conflict.
See the sections One aggregate per transaction, and the order with its notes and One request that saves the order and its first notes.
Use one transaction when one user action creates new aggregates together. Use an event when the transaction would change an existing aggregate of another feature. That transaction would conflict with the writers of that aggregate.
One aggregate changes, and another aggregate must react. How does the second one learn about
it?#
Decided by Now, or a little later and A transaction protects one place.
The reaction may be true a little later, so it does not need the first transaction. Each aggregate keeps its own transaction, its own version and its own conflicts.
With @ApplicationModuleListener, Spring Modulith stores a row for the event with the
first change. So the reaction is not lost if the service stops between the two steps. A plain
@EventListener stores nothing, and it runs inside the first transaction.
See the sections One aggregate per transaction, and the order with its notes and Reacting inside the service.
If the user must see both changes as one action, and both aggregates are new, one transaction may be the better choice.
An aggregate that others refer to is closed or removed. What happens to the aggregates that hold
its ID?#
Decided by Every fact has one writer and Now, or a little later.
Each aggregate has one writer, its own feature. So the order feature must not delete or change notes. The note feature reacts to the order's event with its own rules.
Most such reactions may happen a little later. A note on a cancelled order can stay, or be closed by a handler.
The order service never deletes orders, so a note's order always exists. See the table in the section What holds between two aggregates, and what does not.
A legal duty to erase personal data is different. Then each owner must erase its own data, and the event must reach every owner.
Entities, value objects and IDs#
How do I decide between an entity and a value object?#
Decided by The root guards the rule.
An entity changes in place, so the root must find it to change it. That is why an entity has an ID. The root changes a line by its line ID, and checks its rules first.
A value object never changes in place. The root replaces it with a new value, so it needs no ID. A payment is replaced by a later payment.
See the section Inside the aggregate: entities and value objects.
The same thing can be a value in one place and an entity in another. The order keeps the customer's contact as a value object. A shop that tracks each delivery address over time may make the address an entity.
Can a value object change after it is created?#
Decided by The root guards the rule.
A value object is often handed to callers, for example in a view. If it could change, a caller could change the aggregate's data without the root. Then the root's rules would not run.
A value that cannot change can be shared freely. The only way to change the aggregate is still a method of the root.
The value objects of the order service are Java records, and their fields cannot be reassigned. See the section Value objects as records.
A record that holds a list is not fully fixed. The list inside can still change, so copy it in the compact constructor.
Is a separate type for each ID worth the extra classes?#
Decided by Spend design effort where the business is different.
A typed ID costs one small class for each aggregate and entity. In the order, which holds the most important rules, that effort buys a check on every call. An order ID passed where a line ID belongs does not compile.
It also checks the format once, when the ID is created. So every OrderId in the
service is valid.
See the sections Typed IDs inside the aggregate and Typed IDs: Identifier and its subclasses.
In the light style, the note keeps its IDs as text. NoteService.resolveAction takes
two IDs, the order ID and the note ID. If a caller swaps them, the note is not found, and 404 is a
correct answer. Add a type when a swap could change the wrong note.
Should every string and number in the model be a value object?#
Decided by Every boundary has a price and The root guards the rule.
A value object puts the rule of a value in one place. Money refuses an amount with
too many decimal places, on every path that builds it. That check pays for its class.
A free text, such as the text of a note, has only a length limit. A guard in the entity checks it. A class around it adds a file and protects nothing more.
See the sections Money: a currency, and no silent rounding and Guard: checks that name the field.
Make a value object when the same check appears in two places. That is the sign that the value has a rule of its own.
The light style keeps some text with a format as text. Note.category must match a
pattern. It stays a String, and Note.create checks it with a guard.
The request is already validated. Why does the value object check the same value again?#
Decided by The root guards the rule and Depend on your own rules, not on the details.
A request check lists every bad field at once, so the client can fix them all. But only one path uses that request. A message, a test, a migration or another feature builds the same value without it.
The value object is the one place that every path passes. So its check is the one that counts. It also needs no web library, so the domain stays plain Java.
See the sections Value objects as records and Guard: checks that name the field.
A check that needs stored data, such as "the code is an active lookup value", cannot be in the value object. It goes into the service that owns the data.
Where the rules go#
What is the difference between a domain service and an application service?#
Decided by Depend on your own rules, not on the details and The root guards the rule.
Eric Evans (2003) defines a domain service as domain logic that is not the job of one entity or
value object. In this book, that is almost always a rule across aggregates, such as
ShippingPolicy. A domain service uses only domain types.
An application service loads, calls other services, saves, publishes and audits. It holds no rule. Its steps depend on storage and on other services, so they stay outside the domain, and the domain depends on none of them.
See the sections Three places for a rule and Name a domain service after its rule.
The simple level has no domain. Its one rule, "a code must be an active lookup value", is in the application service as a recorded choice.
Is an anaemic model always wrong?#
Decided by Spend design effort where the business is different and The root guards the rule.
Martin Fowler (2002) describes a transaction script as a fair choice for simple logic. A transaction script is one procedure for each request, with no domain model.
Fowler (2003) calls the anaemic domain model an anti-pattern. It has the classes and the mapping of a domain model, but its rules are in services. So it has the cost of a model, and none of its protection.
The two statements agree. Spend the careful model where the business is different and the rules are many. A feature with a few simple rules gets the light style in the order service, an entity with its own methods.
See the sections What an anaemic model looks like, and what goes wrong and An anaemic model with an outbox, compared fairly.
When a feature without a model gains a real rule, give it an entity with methods. The same check in two places is the sign.
Can an aggregate call another service, or a repository, inside one of its methods?#
Decided by Depend on your own rules, not on the details and A remote call fails, waits, repeats or arrives out of order.
A call from the model would make the domain depend on a client or on storage. It would also hide a slow call that can fail inside a rule, and perhaps inside a transaction.
So the use case asks the stock service first. Order.place receives a
StockAvailability value, and it refuses an order when a product is not available.
See the sections A decision from another service, a rule in the domain and Repositories in the domain, other services in the application.
The domain may declare the repository as a port, because storage is part of the model. But only the use case calls it.
Can domain code read the current time?#
Clock, so a
test can fix it.Decided by Depend on your own rules, not on the details.
The system clock is a detail of the machine. A rule that calls Instant.now() gives a
different result on every run, so no test can check it at an exact time.
A Clock passed in keeps the rule independent of that detail. Tests pass a fixed
clock, and production passes the real one. The rule never changes because the source of the time
changed.
See the section The time comes from an injected Clock.
No common case changes this answer. The platform states a wider rule in the comment on its
Clock bean. No code of the service calls Instant.now(), not only the
domain.
Should the aggregate check who the user is, and what they may do?#
Decided by Check permissions where a user's request enters and The root guards the rule.
Only an entry point knows who the caller is. A message listener, a scheduled job and another feature have no user. If the aggregate checked permissions, it would refuse work that the service itself decided to do.
A rule about one record is different, because it needs the record. "Only a note of this order" is checked by the note itself, and a note of another order gets 404. "The agent who wrote a note may not resolve it" receives the actor's ID as text.
Note.requireBelongsTo(orderId) and Note.resolveAction(actorId, clock)
never read the security context. See the section Rules about one record:
in the domain, with the actor passed in. For where the check goes, see the section
Choosing the place by the shape of the service.
A service with several entry points for users may check the permission on the use case instead. The aggregate still checks only its rules about one record.
Repositories and storage#
Do I need a repository for each entity?#
Decided by The root guards the rule and Every fact has one writer.
A repository for an inner entity is a way around the root. Code could load a line, change it and save it, and the order would never know.
One repository for each aggregate makes the root the only door. It also makes the aggregate the unit that is stored, with one version.
OrderRepository stores orders, and there is no repository for lines. See the
section The repository port.
Writes that store no aggregate, such as the inbox key or the audit record, need no repository.
The order service uses MongoTemplate for them.
Is a generic repository, which loads any type or returns an open query, a good idea?#
Decided by The root guards the rule.
A query that callers can extend can load an inner entity, or a part of an aggregate. Code can then change data that the root never sees.
A port with named methods states what the feature may do. A reader sees every way in.
The platform's Repository is generic in its types, but each feature has its own
port. A generic repository needs the approval of the design group. See the section
The allowed building blocks, and what needs approval.
A base class that implements findById and save for every adapter is
fine. It only saves typing, and each port stays specific.
The light-style note uses NoteRepository, a Spring Data repository with generic
methods. The note is one entity with few rules, so there is little to reach past.
My repository has a query method for every screen. Is that wrong?#
Decided by Reads and writes have different shapes.
A repository loads whole aggregates for changes. A query that returns orders by status, a page at a time, still returns aggregates. That is normal.
A query that joins data from other aggregates, or returns a few fields for one screen, serves a read. Such queries grow with every screen, and they bend the repository toward the screens.
OrderRepository has two list queries, and both return orders. See the sections
The repository port and One model for reads and
writes: CQRS or not.
When a read needs data from several aggregates at once, add a read model. Keep the repository for the writes.
Should an aggregate be one document, or one document for each entity?#
Decided by A transaction protects one place.
One read returns the whole aggregate, with no join. One version on the document covers all of it. A write of one document is atomic, so the order, its lines and its last payment are never stored half changed.
A document for each entity would need several reads to load one order. It would also need a version check across several documents.
See the sections One aggregate, one document and Embed the lines, keep the notes apart.
In a relational database, an aggregate is usually several tables, saved in one transaction. The rule is the same. The aggregate is still loaded and saved as one unit.
Does every aggregate need a version?#
Decided by Now, or a little later.
Two users load the same order. Each one changes it and saves. The root checked its rules on each copy, but the stored state changed between that check and the second save. Without a version, the second save writes over the first.
A version makes the second save fail. The user then reloads, and the root checks the rules against the real state. This is called optimistic locking.
See the sections Optimistic locking with @Version and One optimistic-lock test for each aggregate.
Data that the service never changes needs no version. The lookup values of the reference feature are an example. They are not an aggregate, and only a seed migration changes them.
Should the domain class carry the storage annotations, or should a separate document class
hold them?#
Decided by Depend on your own rules, not on the details and Every boundary has a price.
A separate document class keeps the domain free of the storage library. The model can then use types that storage cannot hold as they are, such as typed IDs.
In the light style, the entity itself is stored, with its annotations. That is enough while the stored fields and the model's fields are the same. A separate class would then only copy each field, and protect nothing.
See the sections The full style: a document class and The light style: the note is its own document.
Give a light feature a document class when storage starts to force changes on the model. A field kept only for the database, or a constructor that only the database needs, is the warning. Remove the document class when every line of the mapper copies a field to a field of the same name.
Domain events#
Who records a domain event, the aggregate or the use case?#
Decided by The root guards the rule.
If the use case adds the event, each use case that ships must remember it. A new use case can forget it, and no test fails.
In the aggregate, the change and its event are one method. Every path that ships an order also
records OrderShipped.
See the section Domain events: collected by the aggregate.
A record of a step of the use case, and not of a change of the model, comes from the use case. The audit record is an example, and it is not a domain event.
Does every change of state need a domain event?#
Decided by Every boundary has a price and The root guards the rule.
An internal domain event has a small, fixed cost. It is one record class, and it is not a contract, so it may change with the model. Because the method that changes the state records it, no path can forget it.
The gain is possible, not certain. A new listener inside the service can react to the change, and the model does not change. If no listener ever needs the event, it costs a class and nothing more.
LinePicked is recorded, although no other service receives it. See the table in
the section Domain events: collected by the aggregate.
A change with no business meaning, such as an internal counter, needs no event. A feature at the simple level has no events, because it has no business changes.
How much DDD#
Should every feature of a service use the full DDD model?#
Decided by Spend design effort where the business is different and Every boundary has a price.
The order has the most rules of the service, and they are the hardest to get right. Its stored shape also differs from its model, and it calls another service. So the order service chose the full model there, with an aggregate root, a typed ID, a document class, a mapper and a port.
A lookup list works the same way in every shop. It has no state changes and one rule, that a stored code must be an active lookup value. The same five files would protect nothing, and the time would be missing from the order. See the section The simple level: the reference feature.
See the sections Three levels in one service and Choosing the level for each feature.
A level is not chosen forever. A feature moves up when storage starts to force changes on its model. It also moves up when it gains a rule across aggregates, or a call to another service. The move is one change, with its tests, so starting low costs little. See the section The sign that a feature needs the next level.
Testing the model#
How should a rule of an aggregate be tested?#
Decided by Depend on your own rules, not on the details.
The domain depends on nothing. So its test needs nothing either. Each test runs in milliseconds, and it reads like a list of the rules.
A test of a rule that needs mocks of repositories is a warning. The rule is probably in a service, and not in the aggregate.
See the sections Testing the model and Domain tests: plain JUnit, no Spring, no mocks.
Optimistic locking needs a real database, because the version check happens there. That one test for each aggregate is an integration test.
Field questions: boundaries#
These questions are about borders. Some borders are in the business, such as subdomains and bounded contexts. Others are in the code, such as layers, features and modules. Each answer starts with what you would say in a meeting. Then it names the principle that decides it, shows where the order service has the case, and says when the answer changes.
Domains and subdomains#
What makes a subdomain core, and not only important?#
Decided by Spend design effort where the business is different.
In Domain-Driven Design (DDD), the label "core" decides where the best people and the most careful modelling go. That effort pays only where it makes the business different. A part can be critical and still be the same in every shop, such as taking a card payment.
A classification with no generic or supporting entries has stopped working. If everything is core, the extra effort has no target.
The order service treats the order as the core of the shop, and gives it the full level. See the section Choosing the level for each feature. For the three kinds of subdomain, see the Atlas chapter Problem space, solution space. Whether a core subdomain must also be complex is still argued. See the question A rule is simple, but it is the reason customers choose us.
The answer changes when the market changes. A part that was generic can become the reason customers come, and then it deserves core treatment. The cost of a late change is a rewrite of code that was built cheaply.
Still arguedA rule is simple, but it is the reason customers choose us. Is it core or supporting?#
Decided by Spend design effort where the business is different.
Vlad Khononov, in Learning Domain-Driven Design (2021), defines core strictly. A core subdomain is both complex and different from competitors, so it is hard to copy. A simple rule that differs is then a supporting subdomain that happens to matter. This side uses the principle to spend design effort where the business is different. A simple rule needs little modelling, even there.
Nick Tune draws a softer line. In 2020 he published the Core Domain Chart with the DDD-Crew. The chart places each subdomain by its complexity and by how much it makes the business different. His patterns for the chart count a simple rule that makes you different as core. He calls it a short-term core, because rivals can copy it. He still said, in a talk at DDD Europe in February 2020, that a core domain needs some complexity too.
The chart and its patterns came later than the first books on DDD. Both sides agree on the purpose. The best people work on what wins customers.
The order service does not have such a rule. For the argument in full, see the Atlas chapter Problem space, solution space.
The strict definition is safer when a team calls everything core. The softer definition is safer when one small rule is the whole business. Choose by the danger that your team faces.
Should we build a generic subdomain ourselves, such as e-mail sending or authentication?#
Decided by Spend design effort where the business is different.
A generic subdomain works the same in every company. Several good products already solve it. Your own code for it must still be written, tested and kept up to date. And it wins no customer.
The common reason to build is "our needs are special". In a generic subdomain, the needs are rarely special. The cost of building is certain, and the gain is not.
The order service builds none of these parts. It uses a token issuer for identity, and Amazon Simple Notification Service (SNS) for publishing events. See the Atlas chapter Problem space, solution space.
Build it when no product fits a rule that makes you different. Then the part was not generic. Build it also when the product's cost or limits are worse than your own code. Write down that reason, because it will be questioned.
Once we classify our subdomains, is the classification fixed?#
Decided by Spend design effort where the business is different and Prefer the decision you can undo.
The classification decides where effort goes. Effort pays only while the classification is true. A supporting subdomain can become the business, and a core one can become a product that anyone can buy.
Reviewing catches a wrong classification before much code is built on it. So make the classification early, and expect to change it.
The order service has no such change yet. For the trap of classifying once, see the Atlas chapter Problem space, solution space.
A stable market changes rarely, so a yearly review is enough there. The cost of never reviewing is effort spent on yesterday's core.
Is a subdomain the same thing as a bounded context?#
Decided by A word that changes meaning marks a border.
The business has subdomains whether or not software exists. A bounded context is drawn by the team, where the meaning of the words changes. The two often match one to one, and that is a good sign.
They can also differ. One subdomain may need two contexts, because two groups of users mean different things by the same word. Two small subdomains may share one context, because they share one language.
The order service is one bounded context. It serves the ordering part of the shop. See the Atlas chapter Problem space, solution space.
When a context serves two subdomains with different importance, check it again. The core part may need more care than the rest of the context allows.
Language and discovery#
Must the code use exactly the words of the business experts?#
Decided by A word that changes meaning marks a border.
The words of the business inside one context are called the ubiquitous language. If the code uses other words, every conversation needs a translation. Each translation can lose a meaning, and the error then hides in the code.
A method named ship also tells the reader which rule applies. A method named
update could do anything.
An order in the order service is placed, paid, shipped or cancelled, and its lines are picked. See the section What DDD is, in this book's words.
Two teams use the same word with two meanings. Do we need two models?#
Decided by A word that changes meaning marks a border.
Two meanings with different rules cannot share one class without fields and checks that only one meaning needs. Each team would then change a class that the other team depends on.
A difference only in the name is not a border. "Client" and "customer" for the same person with the same rules need one word, not two models.
The order service has no case of one word with two meanings. It has the opposite case, two
words for one thing. The stock service says sku, the order service says
ProductCode, and the adapter translates. See the section
The adapter: the anti-corruption layer. For a word with two
meanings, see the Atlas chapter
Drawing the borders.
In a small system that one team builds, two meanings can share one model for a while. The cost grows with each rule that one meaning adds and the other meaning must ignore.
Should the company keep one glossary of terms for all teams?#
Decided by A word that changes meaning marks a border.
A word may mean one thing in sales and another thing in the warehouse. A single glossary must choose one meaning. Then one team uses a definition that is wrong for its work, or the glossary becomes vague enough to fit both.
A glossary for each context can be exact. Where two contexts meet, a map shows how their words translate.
The book has no glossary for each context. For the language of a context, see the Atlas chapter Drawing the borders.
The words of a published contract are shared across contexts by design. Those words belong to the contract, and the contract may keep its own glossary.
Can we design the domain model from the existing database schema?#
Decided by A word that changes meaning marks a border and Spend design effort where the business is different.
Tables and foreign keys record the shape of the data. They do not record the rules, the states or the words that change meaning. A model copied from the schema has one class per table, and the rules are placed in services around it.
The borders of contexts are where words change meaning. A schema hides that, because one table often serves several meanings.
The order is an aggregate. Its model differs from its stored document, and a mapper connects the model and the document. See the section The mapper: from the aggregate to the document and back. For the trap, see the Atlas chapter The workshop with the orange stickies.
For a feature with no rules about changing state, such as a lookup list, the stored shape is the whole model. Then the schema is a fine starting point.
Who must be in the room when we look for the borders of the business?#
Decided by A word that changes meaning marks a border.
Borders are where words change meaning. Only the people who use the words know where the meaning changes. Developers know the current code, which may already mix the meanings.
A workshop that maps business events on a wall is called event storming. Its value comes from the disagreements between the people in the room.
The order service starts after this work is done. See the Atlas chapter The workshop with the orange stickies.
For a small change inside a known context, a developer and one expert are enough. A full workshop costs a day of many people, so keep it for new areas and unclear borders.
Bounded contexts#
How do we find the borders of bounded contexts?#
Decided by A word that changes meaning marks a border and What changes together stays together.
A word that changes meaning shows where one model must stop. Rules that change for the same reason show what must stay inside one model. Each signal alone can mislead. Together they are strong.
The current database is a weak signal of business borders, because it reflects history. The org chart is also a weak signal of business borders. But it is a strong force on the borders of the code, because code grows where teams talk. So check that the two match.
Orders, stock and payments change for different reasons, so each one is a context of its own. See the section Do you need DDD? The conditions that make it worth its cost. For the method, see the Atlas chapter Drawing the borders.
When the signals disagree, draw the border as a module first. Moving a module costs a refactoring. Moving a service costs a migration.
How big should a bounded context be?#
Decided by A word that changes meaning marks a border and What changes together stays together.
A context is defined by its model. Inside it, each word has one meaning. When a word starts to mean two things, the context is too big. When one change must cross two contexts that share every word, the contexts are too small. Code that changes together belongs in one context.
The order service is one bounded context. It holds two aggregates, the order and the note, and the note refers to its order by the order's ID. See the section Why order and note are separate aggregates.
A context that is right in language can still be too big for one team to understand. Then split the work by subdomain inside it, or check whether the language has really stayed one.
Can one bounded context be split into several services?#
Decided by A word that changes meaning marks a border and Systems copy the teams that build them.
Sam Newman, in Building Microservices (2015), aligns services with bounded contexts. He suggests starting with large contexts, one service for each. A large context can hold smaller bounded contexts inside it. Each of those can later become a service of its own.
A nested context has its own language, so the split follows a real border. Whether to split also depends on how the teams are organised. A nested context that one team owns can stay inside the service. A nested context that a second team takes over is a good candidate for its own service.
Adam Gluck described Uber's domain-oriented microservices in 2020. There, a domain is a group of related services, not a bounded context. So that practice does not answer this question.
The order service is one deployable for one context. For the opposite question, several contexts in one deployable, see the question Can one deployable hold several bounded contexts?. For the argument, see the Atlas chapter Drawing the borders.
A part of a context that must scale, fail or be secured alone may need its own deployable, even with no nested context. The cost is a contract inside one language, which both sides must keep in step.
Can one deployable hold several bounded contexts?#
Decided by What changes together stays together and Prefer the decision you can undo.
A context is a border in the language, not a deployment. Several contexts can share one deployable if each one is its own module, and no code crosses a border except through a published interface. Then a move to a separate service later is a move of one module.
A border between modules is also cheap to redraw. A border between services needs a data migration and a new contract.
The order service is one context. It has the same kind of border, between features inside one context, and the build checks it. See the section The rules as tests that fail the build. For one context split the other way, see the question Can one bounded context be split into several services?. For the argument, see the Atlas chapter The consultant who said microservices.
A module border that no test checks wears away. Then the modules become one model again, and the later split is a rewrite.
Is a bounded context the same thing as a team?#
Decided by Systems copy the teams that build them.
Two teams that change one model talk less to each other than to their own members. Their parts of the model grow different. The context then has two languages inside it.
One team can keep several contexts apart, because the borders come from the language. The build rules then hold those borders.
For how teams and contexts match, see the Atlas chapter You ship your org chart.
A context can be shared for a short, planned period, such as a joint project. The cost is a meeting for each change, so end the period on purpose. Two teams can also share a small part of the model on purpose. This is called a shared kernel. See the question Is a shared kernel, a small model that two contexts own together, ever a good idea?.
Should the company have one shared model of the customer, used by every team?#
Decided by A word that changes meaning marks a border and Every fact has one writer.
Sales, billing and support each need different facts about a customer, with different rules. One shared model must hold all of them, so it grows fields that most teams never use. Every team must then agree to every change.
Copies are safe when each fact has one owner. The other contexts keep copies of the facts that they need, in their own words.
The shop in this book has no context that owns customers. The order receives the customer's name and e-mail address with the request that places it, as its own value object. For a shared table with many writers, see the Atlas chapter Nobody owns the product table.
A published contract for a customer, read by many teams, is fine. It is the owner's public form, not a model that every team must use inside.
Context maps and the borders between contexts#
Why draw a context map at all?#
Decided by A word that changes meaning marks a border and Systems copy the teams that build them.
A drawing of the borders between contexts, with the relationship at each border, is called a context map. The side whose changes reach the other side is called upstream. The side that receives them is called downstream.
Each border needs a chosen relationship, such as a partnership, customer and supplier, a conformist, an anti-corruption layer or separate ways. Without a map, those choices happen by accident, often when the downstream side imports a class because it was there.
The order service is downstream of the stock service and the payment service, and upstream of every consumer of its events. See the Atlas chapter The map of treaties.
A map is only useful while it is true. Redraw it when a team, a vendor or a border changes. An old map is worse than none, because people trust it.
Should we translate the other side's model at the border, or use it as it is?#
Decided by Depend on your own rules, not on the details and Every boundary has a price.
An anti-corruption layer translates the other model into your words, in one place. Your rules then depend on your model only. When the other side changes, the translation changes, and the rules stay as they are.
A translation also costs code and tests on every change. If the other model already says what you mean, the translation buys nothing. To use the other model as it is, on purpose, is called conformist. The danger is to conform by accident, by importing their classes because they were there.
The order service translates the stock service's answer in one adapter. Without it, a new status from the stock service would reach the order's rules. See the section The adapter: the anti-corruption layer.
Stop conforming when their model disagrees with your rules, or when their changes keep forcing changes in your rules. Old systems and outside vendors almost always need the layer from the start.
In which package does the anti-corruption layer go?#
Decided by Depend on your own rules, not on the details.
The port is written in your own types. The adapter behind it is the one class that knows both models. So the translation has one home, and a test can call it with no server.
If a use case reads the other side's records, their model has entered yours. Then the translation is spread over every use case that reads them.
In the order service, StockAdapter implements StockChecker, and the
stock service's records stay in order.client.dto. See the section
Their DTOs stay in the client package.
For a light feature that calls nothing, there is no adapter, and no layer is needed. When a light feature starts to call another service, it gains a port and an adapter.
Many teams consume our service. Should we build a special integration for each of them?#
Decided by A published contract is hard to take back.
A special integration for each consumer is a contract for each consumer. Each one must be kept, versioned and tested. The cost grows with every new team.
One documented interface is one contract. You change it with care, and every consumer reads the same description.
The order service publishes one API description and one schema for each event. See the section Why the service publishes contracts.
One important consumer with needs that nobody else has may justify its own interface. That interface is another contract to keep, so add it only for a clear reason.
Should our API and events use the names and shapes of our internal model?#
Decided by A published contract is hard to take back and Depend on your own rules, not on the details.
A form of your model that is documented for other teams is called a published language. It changes slowly, because other teams depend on it. Your internal model must change whenever the rules change.
If the internal model and the published form are the same classes, every internal refactoring becomes a change for every consumer. A mapper between them keeps the two speeds apart.
The order service maps domain events to integration events, and request records to domain values. See the sections The mapper decides what leaves and Publish as few events as needed.
Between two modules that always deploy together, nothing is published. A shared class there is fine, and a mapper adds only work.
Should a consumer use the producer's classes for its events or its API?#
Decided by A published contract is hard to take back and Depend on your own rules, not on the details.
A producer's class carries every field that the producer has. If you compile against it, you depend on fields that you never read. A new version of the class then becomes your release too. A shared library of classes also ties every consumer to one language and one set of library versions.
A schema in a standard format avoids the shared artifact. Each team writes or generates its own classes from the part that it reads. A copy of the part that you use makes the dependency small and visible. A reader that ignores the rest is called a tolerant reader.
The order service publishes schemas, not classes. It keeps its own schema and record for the payment service's event. See the sections Why the service publishes contracts and Our copy of a consumed contract.
Generated classes are fine, if each consumer generates them from its own reader schema, or tolerates unknown fields. With Avro, a consumer reads with its own reader schema, and fields that only the writer has are ignored. With Protobuf, unknown fields are skipped, so a consumer can compile a smaller copy of the schema. The coupling comes from sharing the producer's compiled artifact, not from generating code. Inside one team that owns the producer and every consumer, a shared class can be the better choice, because some breaking changes become compile errors.
The upstream team ignores our needs. What can we do?#
Decided by Systems copy the teams that build them.
Code follows how teams talk. If the upstream team never hears your needs, their model will never serve them. A relationship where the downstream team has a say in the upstream plan is called customer and supplier.
When the other team cannot or will not listen, you cannot change their model. You can only protect your own model from it.
The order service treats the stock service as a model that it does not control, behind an adapter. See the section The adapter: the anti-corruption layer. For the patterns, see the Atlas chapter The map of treaties.
When the upstream is an outside vendor, there is no planning to join. Then the choice is the layer, or conforming on purpose. See the question Should we translate the other side's model at the border, or use it as it is?.
When is a partnership between two contexts the right relationship?#
Decided by What changes together stays together and Systems copy the teams that build them.
A relationship where two teams plan their changes together is called a partnership. It suits two contexts that change for the same reason during a project. Each change then needs both teams, which is cheap only while they talk every day.
The order service has no partnership. For the patterns, see the Atlas chapter The map of treaties.
When the shared goal ends, or the teams stop talking daily, change it to customer and supplier. A partnership that nobody reviews turns into a shared model with two owners.
When should two contexts not integrate at all?#
Decided by Every boundary has a price.
Each integration costs a contract, a translation and a dependency at run time. If two contexts need only a little from each other, a person or a simple copy can do that part on purpose. The integration would cost more than it saves.
The order service has no such case. For the pattern, see the Atlas chapter The map of treaties.
When the manual work grows, or its errors start to cost money, integrate. The cost of waiting too long is data that two contexts have already entered differently.
Layers and architecture styles#
Must every feature of a service have the same packages inside it?#
Decided by Every boundary has a price.
Each package is a fixed cost in files and in rules about what it may use. A feature that calls
no other service gains nothing from an empty client package. So it leaves it out.
The names must still agree. The build rules match packages by name, so a
persistence package that held something else would break a rule.
The order feature has domain, application, api, persistence, client and messaging. The note feature has model, application and api. The reference feature has application, api and persistence. See the section The same sub-packages in every feature.
Stop when a reader can no longer tell a feature's level from its package names, or when a team invents a fourth shape. Then write the shapes down, and add a rule for each one.
Are hexagonal, onion and clean architecture really different?#
Decided by Depend on your own rules, not on the details.
Alistair Cockburn described the hexagonal architecture, also called ports and adapters, in 2005. Jeffrey Palermo described the onion architecture in 2008. Robert C. Martin described the clean architecture in 2012. Each one puts the business code in the middle and the technology outside.
The common rule is that source code depends only inward. The differences are the number of rings and their names.
The order service uses the same ports and adapters, with fewer folders. See the section Why not adapter/in and adapter/out.
The names matter when a team already knows one style across many services. Then keep the familiar names, because they save reading time.
Still arguedIs the full set of ports and adapters worth it in every feature?#
Decided by Every boundary has a price and Depend on your own rules, not on the details.
The advocates follow Alistair Cockburn (2005) and Robert C. Martin (2012). They hold that testability and replaceability pay for themselves quickly. Teams that skip the ports get a domain that depends on a framework. This side uses the principle to depend on your own rules, not on the details. The rules must not change because a detail changed.
Jimmy Bogard (2018) and the vertical-slice camp answer that most features are simple. Uniform layers make simple things as expensive as hard ones. This side uses the principle that every boundary has a price.
Both sides agree that the domain must not depend on the infrastructure.
The order feature has ports and adapters, and the note and reference features do not. See the section Three levels in one service. For the argument, see the Atlas chapter Which way do the arrows point.
A feature that starts to call another service, or whose rules become the core, needs the ports. The cost of adding them late is one change in one feature.
Should the top-level folders be features or technical layers?#
Decided by What changes together stays together.
A business change, such as a new rule about notes, belongs to one feature. With folders by technology, it touches the controllers, the services, the repositories and the transfer objects. Each of those folders also holds every other feature.
With folders by feature, the change stays in one place. A feature can also move to its own service later, because its dependencies are few and visible.
The order service has the top-level packages order, note and
reference. See the section Features at the top, never
technologies.
A small service with one business area works well with folders by technology. The cost appears when a second area arrives, and every folder starts to mix the two areas.
Should we use a folder for each use case, with no shared domain model?#
Decided by The root guards the rule and What changes together stays together.
A folder that holds everything for one use case is called a vertical slice. It keeps one change in one folder. But placing, shipping and cancelling all change an order. Suppose each slice changes the order in its own code. Then a rule such as "a shipped order cannot change" is written twice. A third slice can forget it.
The aggregate guards its rules in one place. So its use cases share it.
The order service keeps one domain model per aggregate, and one application service per feature. See the section Why not one folder per use case.
Reports and searches have no shared rules. Each one reads differently. For a feature like that, slices are the better choice, even inside a feature-based service.
Should every class, or every use case, have an interface?#
Decided by Every boundary has a price and Depend on your own rules, not on the details.
An interface at a border lets the inner code depend on its own types. That protects the domain from a technology. An interface with one implementation, in the same package, protects nothing. It doubles the files and makes the code harder to follow.
A test does not need an interface either. A mocking library can replace a class as easily.
The order service has interfaces for OrderRepository and StockChecker.
It has none for OrderService, and a controller test mocks the class. See the section
Why not adapter/in and adapter/out.
A use case needs an interface when it really has several implementations. An example is one use case that each customer of a product implements in its own way.
Where do data transfer objects belong?#
Decided by Depend on your own rules, not on the details and A published contract is hard to take back.
A data transfer object (DTO) has the shape of a contract outside your model. If the domain uses it, a change to the contract becomes a change to the rules. At the edge, a mapper absorbs the change.
In the order service, request and response records are in api.dto, and the stock
service's records are in client.dto. See the section
What goes where.
A simple feature with no domain model can pass its records further in. There is no model to protect there.
Mapping between request records, domain objects and stored documents is a lot of code. Is it worth it?#
Decided by Every boundary has a price and A published contract is hard to take back.
The API shape changes with consumers. The stored shape changes with the database. The model changes with the rules. A mapper lets one of them change without the others.
A mapper that copies every field to a field of the same name protects nothing. It is then only cost.
The order feature maps at each edge. The note feature has no document mapper. See the section The sign that a feature needs the next level.
Keep the mapping at a published contract even when it only copies. The contract will change at a different speed later, and adding the mapper then is harder.
Features and modules inside one service#
Two features in one service call each other. Is that a problem?#
Decided by What changes together stays together.
A dependency in each direction means a change in either feature can break the other one. The two features then behave as one, but they look like two. A cycle also blocks a later move of either feature to its own service.
If the two features always change together, they are one feature. Then merging them is the honest fix.
The order and note features call each other, and the companion breaks the cycle at startup
with @Lazy. Its README lists the ways out. See the section
Two features that call each other.
A cycle that is only a query, such as a count, can also end with a small read class. That class needs no dependency on the other feature.
How do we keep the borders between modules inside one deployable?#
Decided by Every boundary has a price and What changes together stays together.
A border between modules is cheap only while it holds. A reviewer is sometimes tired, or does not see one import at the top of a file. A test that reads the compiled classes sees every dependency, every time.
Without the test, the borders slowly disappear. The modules then change together, and a later split costs a rewrite.
The order service checks its rules with ArchUnit in
ArchitectureTest. One rule fails when a feature uses another feature outside its
application package. See the section The rules as tests that fail the
build.
A single small module with one owner may need no rule yet. Add the test before the second team, or the second feature, arrives.
An application service has grown to fifteen use cases. How should we split it?#
Decided by What changes together stays together.
Use cases of one business area change together. A class for each area keeps a change in one class. A read class and a write class split each area in two. Each change to fulfilment would then touch both classes.
Fifteen use cases on one aggregate can also mean that the aggregate holds two things. So check the aggregate too.
The order service plans to split OrderService by area at about ten use cases. See
the section Splitting a large application service.
A real model for reading is a different thing from a read class. If reads need their own shape, add a read model for that query.
Field questions: integration#
These are the questions that teams ask about events, messages and calls between services. Each question shows its answer when you open it. The answer starts with what you would say in a meeting. Then it names the principles that decide it, shows where the order service does it, and says when the answer changes.
Events and commands#
What is the difference between an event and a command?#
Decided by Every fact has one writer.
An event is a fact that its publisher owns. The publisher wrote the change, so only the publisher can say that it happened. Nobody can refuse a fact.
A command asks for a change that the receiver owns. The receiver checks its own rules first, so it may say no. That is why a command has one receiver, and an event has any number of listeners.
The order service publishes facts such as OrderShippedV1. It sends a command,
"hold these products", to the stock service as an HTTP call, because it needs the answer. See the
section Why the order service calls the stock service.
A command can also travel as a message on a queue, when the sender does not need the answer at once. It is still a command. It still has one receiver, and the receiver may still refuse it.
My team publishes an event called SendInvoice. Is that a problem?#
SendInvoice. Is that a problem?OrderShipped, and let the invoice service decide.Decided by Every fact has one writer and A published contract is hard to take back.
The name gives an order to one service. So the publisher now decides work that the invoice service owns. When the invoice rules change, the publisher must change too.
A fact in the past tense gives no orders. The invoice service reads OrderShipped and
applies its own rules. A second service can react to the same fact, and the publisher changes
nothing.
Every integration event of the order service names a business fact in the past tense, with a version. See the section Domain events and integration events.
Sometimes the sender really decides that the work must happen. Then send a command to that one receiver, and do not publish it to everyone. The receiver can still refuse, so the sender must handle a refusal.
When may I publish my domain events directly, with no separate integration events?#
Decided by A published contract is hard to take back and Systems copy the teams that build them.
A domain event holds the types of your model, such as OrderId. It changes
whenever the model changes. Inside one team, that change is one conversation and one
release.
When a consumer belongs to another team, every refactoring of your model needs their agreement. From that day, publish a separate integration event, and map to it.
The order service declares OrderShipped in order/domain/event. It
declares OrderShippedV1 in order/messaging/event. See the section
Domain events and integration events.
The first consumer from another team ends the exception. Adding the mapper then is cheap, because nobody outside depends on the domain event yet.
Should every domain event become an integration event?#
Decided by A published contract is hard to take back and Every boundary has a price.
Every published event is a contract. It needs a schema, a version and care for its consumers. An event that nobody needs is a contract that costs work and gives nothing.
And once some team uses it, you cannot take it back. So start with the events that other services need today.
The order aggregate raises five domain events. Only the placed, shipped and cancelled
events leave the service. A test checks that LinePicked stays inside. See the section
Publish as few events as needed.
When a new consumer needs an internal fact, add a new integration event for it, with its own schema. That is cheap, because nobody depends on it yet.
Should I publish a change event for every field, such as OrderStatusChanged?#
OrderStatusChanged?OrderShipped. A field change makes every
consumer guess what happened in the business.Decided by A published contract is hard to take back.
A consumer cares about a business fact. OrderStatusChanged from "PAID" to
"SHIPPED" hides that fact behind a column. Each consumer must then decode the column, and each
decodes it in its own way.
The column also becomes the contract. So you cannot rename a status, or split it, without breaking consumers.
The order service publishes OrderPlacedV1, OrderShippedV1 and
OrderCancelledV1. Each one names a fact that a business expert would use. See the
section Event contracts: a JSON schema for each event.
A copy of a whole table for a data warehouse may use change events per row. That is data replication, not a business contract, and the warehouse team accepts that the rows change shape.
What an event carries#
Still arguedShould an integration event carry the full state, or only an ID?#
Decided by A synchronous call borrows the other side's availability and Every fact has one writer.
An event with only an ID is called a notification. Every consumer then calls the publisher back for the data. So the consumer fails when the publisher is down. Martin Kleppmann (2017) argues that a consumer holding a slightly old copy is normal. So he prefers events that carry the state.
An event that carries everything copies data into every consumer. The notification style goes back to Gregor Hohpe and Bobby Woolf (2003). Teams that follow it argue that each copy of personal or commercial data must then be protected and deleted.
Martin Fowler (2017) named both styles, and he did not pick one. Both sides agree on one test. If every consumer calls back, you chose notification, so choose it on purpose.
OrderPlacedV1 carries the order ID, the delivery method and the lines,
because the stock service needs them. It carries no customer name or email address. See the
section Event contracts: a JSON schema for each event.
When the data is sensitive, and few consumers need it, send only the ID. Those consumers then ask the owner, and they depend on the owner being up.
A consumer calls my API back every time it receives my event. Is that wrong?#
Decided by A synchronous call borrows the other side's availability.
A call back on every event brings back a call chain. If your service is slow or down, the consumer's queue stops too.
The call can also return data that is newer than the event. Then the consumer sees a state that the event did not describe. For the two styles of event, see the question on full state or only an ID.
The Atlas shows the cost of thin events in the chapter Something happened.
If the data is large, or it changes faster than events arrive, a call back can be right. Then protect the call with a timeout, a retry and a circuit breaker, as for any call.
Can I put personal data, such as an email address, into an integration event?#
Decided by Data outlives the code that wrote it and Every fact has one writer.
The order holds the customer's name and email address, so the order service owns them. An event that carries them creates a copy in every consumer and in every message store. Each copy is one more place to protect. And each copy stays after the event is gone. When the customer asks to be forgotten, every copy must be found and deleted.
A published field is also hard to remove. So a field added "just in case" stays for a long time.
OrderPlacedV1 carries nothing about the customer, because the stock service
does not need it. See the section Event contracts: a JSON schema for each
event.
A consumer whose job needs the data, such as a service that emails the customer, may receive it. That consumer then follows the same rules for keeping and deleting it.
Which IDs should every integration event carry?#
Decided by You can fix only what you can see and A remote call fails, waits, repeats or arrives out of order.
The event ID lets a consumer detect a duplicate. A duplicate can arrive, because delivery is at least once.
The business ID lets a person follow one order through every service. Search for the order ID, and you find the request, the event and the consumer's log lines.
The time of the fact is useful too, but it is a weak key for order. Two instances of the producer have clocks that differ a little. A version that the aggregate increases, from one writer, orders events safely.
Every integration event of the order service has eventId,
occurredAt and the order ID. It has no version, because no consumer needs to order
the order's events yet. See the section Keeping event flows
traceable.
A time is safe as a key when one writer's clock sets every value. The payment service
alone sets paidAt, so the order service may compare it. A trace ID can also travel, as
a message header. The order service does not send one, so the order ID connects the two
traces.
Changing a published event#
I publish OrderShippedV2. How long must I keep publishing V1 beside it?#
OrderShippedV2. How long must I keep publishing V1 beside it?Decided by A published contract is hard to take back and Data outlives the code that wrote it.
A consumer of V1 breaks when V1 stops. You do not deploy that consumer, so you cannot move it yourself. So the producer publishes both versions, and each consumer moves in its own time. This order of steps is called expand and contract.
A message can also wait in a queue, or in a dead-letter queue, for days. So a consumer that has moved to V2 keeps reading V1 until the old messages are gone.
The rules for a version are in the comment of the interface that every published event of the order service implements. See the section Versions in names: what may change within V1.
If you own every consumer, and they deploy with you, you can change the event in one release. Old messages in the queue still need a reader, even then.
How do I find out which services consume my event?#
Decided by A published contract is hard to take back.
You cannot change a contract safely if you do not know who depends on it. A wiki page goes out of date.
A list next to the schema is changed in the same pull request as the schema. A consumer test in your build fails when your change breaks that consumer. So you learn about the break before the consumer's on-call engineer does.
The order service keeps consumed-PaymentReceivedV1, its copy of the part of the
payment event that it reads. The payment team compares a planned change with that copy. See the
section Our copy of a consumed contract.
A broker can list the queues that subscribe to a topic. That shows who receives the event, but not which fields each consumer reads. So the list alone is not enough to change a field.
Should a consumer fail when an event contains a field that it does not know?#
Decided by A published contract is hard to take back and Data outlives the code that wrote it.
The producer may add a field at any time. That change is allowed within a version. During a rolling deployment, new producers and old consumers also run at the same time. A consumer that fails on a new field turns an allowed change into an outage.
A consumer that ignores what it does not know is called a tolerant reader. It still checks the fields it uses, so a missing field fails loudly.
The order service's PaymentReceivedV1 ignores unknown fields, and its compact
constructor refuses a missing one. See the section Tolerant
readers.
A strict reader makes sense when an unknown field could change the meaning of the message. That should not happen within one version, so it is rare.
Publishing reliably#
Do I need an outbox if the message broker almost never fails?#
Decided by A transaction protects one place.
Your database and the broker cannot commit together. So the save and the send are two steps. A deployment or a crash can stop the process between them. Then the change is saved, and the event is never sent.
The transactional outbox writes the event into your own database, in the same transaction as the change. A separate step sends it later. So the change and the event are stored together, or not at all.
See the section The dual-write problem. The order service uses the event publication registry of Spring Modulith as its outbox.
The transactions of a broker such as Kafka do not include your database. Only a transaction that the broker and the database share, through a protocol called XA, would close the gap. Amazon Simple Notification Service (SNS) has no transaction that a database can join, so the order service needs the outbox. If losing an event is harmless, such as an event that only feeds a dashboard, you may also accept the loss. Write that decision down.
Should the outbox be read by a polling job, or by change data capture?#
Decided by A transaction protects one place and A published contract is hard to take back.
Both methods keep the important part. The change and the event are written in one transaction. They differ only in how the stored event reaches the broker.
Change data capture is a tool that reads the database's transaction log. Point it at the outbox, never at your business tables. Otherwise your table shape becomes everyone's contract.
The order service uses a sender that runs after the commit, and a job that resends what failed. See the section Resending what was not sent. For change data capture, see the Atlas chapter The message that never left.
A team that already runs a change data capture platform may prefer it, because it adds no polling load. A team without one should not build it for one outbox.
Can a scheduled job publish events without a transaction?#
Decided by A transaction protects one place.
The outbox sends an event after the transaction commits. Without a transaction, there is no commit, so the sender does not run. The event is stored, but it is not sent when the change is saved. Only a log line at shutdown shows it.
So the platform throws an exception instead. A loud error is better than a silent delay.
See the section Publishing outside a transaction.
Put the job's work into a use case with @Transactional, or run it in a
TransactionTemplate. A job that raises no events needs no transaction.
A reaction inside my own service listens to a domain event. Should it run before or after the
commit?#
Decided by A transaction protects one place.
A listener that runs at once runs inside the publisher's transaction. If that transaction rolls back later, the listener has already acted. A counter has counted an order that does not exist.
A listener that runs after the commit sees only changes that were stored. In the order service, it also gets an outbox row, so a failed run is repeated.
The order service counts shipped orders with @ApplicationModuleListener, which
runs after the commit. See the section Reacting inside the
service.
If the reaction must succeed or fail together with the change, it must run inside the transaction. Then a failure of the reaction also rolls back the change.
Consuming events#
Can I get exactly-once delivery of messages?#
Decided by A remote call fails, waits, repeats or arrives out of order.
Suppose a consumer handles a message, and then crashes before it deletes the message. No delete arrived before the visibility timeout ended, so Amazon Simple Queue Service (SQS) delivers the message again.
This is called at-least-once delivery. The consumer must be idempotent. Handling a message twice must have the same effect as handling it once.
A standard queue of SQS delivers at least once. The order service's inbox skips an event whose ID it has seen. See the section Why every consumer needs an inbox.
A first-in, first-out queue of SQS removes duplicates within five minutes. A resend from an outbox can arrive later than that, so the consumer still needs its own defence. See the section FIFO queues, the other choice.
Can a consumer rely on events arriving in the order they were sent?#
Decided by A remote call fails, waits, repeats or arrives out of order and Now, or a little later.
A standard queue orders messages on a best-effort basis only. An outbox that resends a failed event also delivers it late. So an older event can arrive after a newer one.
A rule such as "the latest payment counts" may be true a little later. The consumer keeps the newest fact that it has seen, and it ignores an older one. "Newer" must come from one writer, because the clocks of two machines are never exactly the same.
The order aggregate ignores a payment whose paidAt is not later than the last
one. The payment service alone sets that time. See the section
The domain is idempotent too.
A first-in, first-out queue keeps the order within one message group, such as one order ID. Its throughput is limited, and a resend can still arrive late.
What happens to a message that fails every time?#
Decided by A remote call fails, waits, repeats or arrives out of order and You can fix only what you can see.
A message that always fails is called a poison message. Without a limit, the consumer receives it again and again, and it uses capacity for ever.
A dead-letter queue takes it out of the way. But a message in that queue is work that was not done. If nobody looks, a customer finds out first.
The order service's local queues move a message to the dead-letter queue after 5 receives. SQS does the move, and the service has no code for it. See the section Failures, redrive and the dead-letter queue.
A failure that will pass by itself, such as a database that restarts, should not reach the dead-letter queue. Allow enough receives, with enough time between them, to wait it out.
Messages are in the dead-letter queue. What do I do with them?#
Decided by You can fix only what you can see.
Each message is a business fact that your service did not apply. A payment that is not recorded is an order that never ships.
So an alert on the dead-letter queue is part of the design. The message's event ID and order ID let you find its story in the logs.
In the order service, SQS moves a failing message to the dead-letter queue after 5 receives. Each message keeps its event ID. See the section Failures, redrive and the dead-letter queue.
A message that can never be valid, such as one for an order that does not exist, may be removed. Write down why, so that the decision can be checked later.
My service keeps a copy of another service's data, fed by its events. May my service change the
copy?#
Decided by Every fact has one writer.
The other service owns the data and its rules. A change to your copy is a change that the owner never checked. The next event from the owner also overwrites it.
A copy is safe only because it is never the truth. When the copy and the owner disagree, the owner wins.
The Atlas shows a table with many writers in the chapter Nobody owns the product table.
Your service may add its own data next to the copy, such as a local note on a product. That data has a different owner, your service, and you may change it.
Where does the logic of a message handler go?#
Decided by Depend on your own rules, not on the details and The root guards the rule.
A message is one more way into the service, like an HTTP request. If the listener holds a rule, the controller needs a copy of the same rule. The two copies will drift apart.
A listener is an adapter. It depends on the application layer, and the rules stay where every caller meets them.
The payment listener turns the event into value objects and calls
recordPayment. See the section A thin listener.
A handler with no business rule, such as one that copies a reference list, may write directly through a repository. A feature with no rules needs no aggregate.
Calls between services#
Should services talk with calls or with messages by default?#
Decided by A synchronous call borrows the other side's availability and Now, or a little later.
A call works only while the other service works. An event does not need the receiver to be up when it is sent. So the receiver fails on its own, and the failure does not spread.
Chris Richardson (2018) leans toward messages for anything that is not a real request for an answer. Sam Newman (2015) also prefers event-based collaboration between services. He finds heavily orchestrated designs brittle, and he calls REST a good starting point when a request needs a response.
A call is still simpler to follow and to test. Messages bring duplicates, ordering and delay. So use a call where the answer is needed now, and protect it with a timeout and a circuit breaker.
The order service calls the stock service, because it must not sell products that it does
not have. It publishes OrderPlacedV1 as a fact. See the section
Why the order service calls the stock service.
If a rule may be true a little later, a message is enough. Then the order no longer waits for the other service.
One request needs four other services to answer at once. What is wrong with that?#
Decided by A synchronous call borrows the other side's availability and Now, or a little later.
The chances multiply. Four services at 99.9 percent each give about 99.6 percent together. And the slowest of the four sets the time of the request.
Many of the four answers do not need to be fresh. A product name may be a few minutes old. A copy that events keep up to date removes that call.
The Atlas tells this story in the chapter The night everything deployed together.
If each answer must be correct at this moment, the calls stay. Then give each call a timeout and a circuit breaker, and decide what the user sees when one fails.
How long should a timeout on a call be?#
Decided by A remote call fails, waits, repeats or arrives out of order and Capacity is finite, so refuse early.
A call without a timeout can wait for ever, and it holds a thread while it waits. A few slow answers then use every thread of your service.
A retry multiplies the timeout. If your caller has already given up, your service keeps waiting for nothing.
The order service waits 3 seconds for the stock service's answer. Three attempts can keep the agent waiting for about 9 seconds, plus the waits between them. See the section How many times a retry calls.
When a call is not retried, its timeout can be longer. When it is retried, keep each timeout short, so that the total still fits the caller's time.
Which failures should a client retry?#
Decided by Capacity is finite, so refuse early and A remote call fails, waits, repeats or arrives out of order.
A retry helps only when the next attempt may work. A timeout or a 503 Service Unavailable may pass. A 400 Bad Request fails the same way every time, because the request itself is wrong. So does a 501 Not Implemented, or a 500 that comes from a bug.
A failure that may pass by itself is called a transient failure. Every retry is more work for a service that is already in trouble. So each wait is longer than the one before, with a random spread, and the retries stop after a few attempts. Without the random spread, many clients retry at the same moment.
The platform retries timeouts, refused connections and every 5xx answer, up to 3 attempts. It waits 200 ms, then about twice as long, with a random spread. It never retries a 4xx answer. See the sections Which failures are worth a retry and How many times a retry calls.
Two 4xx answers can pass by themselves. A 408 Request Timeout can, and so can a 429 Too
Many Requests. A 429 means that the caller sent too many requests in a given time. It may carry a
Retry-After header, and so may a 503. Wait at least that long. Retrying at once only
uses more of your quota. The platform does not retry either 4xx answer, so the adapter decides
what each one means.
Do I need a circuit breaker if my client already retries?#
Decided by Capacity is finite, so refuse early and A remote call fails, waits, repeats or arrives out of order.
When the other service is down for ten minutes, every retry waits for a timeout. Your users see slow failures. And your retries add load to a service that is already in trouble, so it takes longer to recover.
A circuit breaker counts the failures. Past a limit, it fails each call at once. After a wait, it lets a few test calls through. So the other service gets time to recover, and your threads are free for other work.
See the section Retry and circuit breaker: what each one is for.
A call that happens rarely, such as once a night, gains little from a breaker. A retry with a timeout is enough there.
Should I have one circuit breaker per service that I call, or one per endpoint?#
Decided by A remote call fails, waits, repeats or arrives out of order.
One breaker per service learns faster that the service is down. Every failed endpoint counts toward the same breaker.
A separate breaker per service also keeps failures apart. When the stock service fails, calls to the payment service still go through.
The platform keeps one breaker for each dependency name, such as stock. See
the section One circuit breaker for each dependency.
Use one breaker per endpoint when the endpoints fail on their own, for example when they run on different servers.
A service that my API depends on is down. What should my API return?#
Decided by A remote call fails, waits, repeats or arrives out of order.
A 500 says that your service has a bug. A 503 says that the service cannot answer now. The caller can then retry later, or tell its user to try again.
The name of the failed dependency belongs in your log, not in the response. A caller does not need it.
The platform turns an open breaker, or a transient failure after the last retry, into a 503. See the section A failed call becomes a 503.
If your service can answer without the dependency, answer. A product page without its reviews is better than no page. This is called graceful degradation.
Should I send a request over a message queue and wait for the reply on another queue?#
Decided by A synchronous call borrows the other side's availability.
The caller still waits for the other service, so it still fails when that service is down. It also needs a timeout for a reply that may be lost, and it must match each reply to its request.
If you need an answer now, a direct call is simpler. If you do not, do not wait.
See the Atlas chapter The payment taken twice.
A long job, such as a report that takes an hour, may send its result as a later message. The caller does not wait. It reacts when the result arrives.
Long flows and sagas#
Can I use one distributed transaction across two services?#
Decided by A transaction protects one place and A remote call fails, waits, repeats or arrives out of order.
A transaction protects one database. A protocol that joins two databases holds locks while the network talks. So one slow service blocks the other.
And SNS offers no transaction that a database can join. So each service commits alone, and the services agree after a while.
The order service holds stock with a call before its own transaction. If its transaction fails, the stock service releases the hold after a timeout. See the section Between services: no shared transaction.
Two services that need a shared transaction often should be one service. Ask whether the border is in the right place before you build the machinery.
When do I need a saga?#
Decided by Now, or a little later and A transaction protects one place.
No transaction covers the services, so a failure in step three leaves steps one and two done. The business must decide what happens to them.
A saga is a flow of local transactions. Each step has an undo step, which is called a compensation.
The order service has no saga. Its one step across services is a stock hold that ends by itself. See the section Between services: no shared transaction.
If a failure can be repaired later by a person, a saga may be too much. An alert and a manual fix can cost less than code for every compensation.
Is a compensation the same as a rollback?#
Decided by A transaction protects one place.
The first step committed in its own transaction, and its events went out. A committed step cannot be undone. Other services may already have acted on it.
So the undo is a step of its own, with its own business meaning. A refund is not a payment that never happened. It is a second fact, and the customer sees both.
The Atlas tells the story of a flow with undo steps in the chapter Who says the order is done?.
Some steps are not yet seen by anyone outside, such as a hold that no event announced. Such a step can be released with no further action. That is the closest thing to a rollback across services.
Still arguedShould a saga use choreography or orchestration?#
Decided by What changes together stays together and You can fix only what you can see.
Choreography means that each service reacts to the events of the others. James Lewis and Martin Fowler (2014) argued for "smart endpoints and dumb pipes". People who follow that line see a central coordinator as a bottleneck.
Orchestration means that one coordinator tells each service what to do next. Chris Richardson (2018) argues that a flow with more than a few steps needs it. Otherwise nobody can see, test or run it.
Many teams use choreography for two or three steps, and orchestration beyond that.
See the section Long flows: a workflow engine or a process manager, and the Atlas chapter Who says the order is done?.
If people often ask "where is this order?", the flow needs one place that knows. That points to orchestration.
A step of a saga never answers. What happens?#
Decided by A remote call fails, waits, repeats or arrives out of order and You can fix only what you can see.
The other service may be down, or its answer may be lost. A flow that waits only for the answer then holds stock or money for days.
A timeout turns silence into an event that the flow can handle. The flow can retry, compensate or alert a person.
The stock service releases a hold that no OrderPlacedV1 confirms, after a
timeout. See the section Between services: no shared
transaction.
Some steps take days by design, such as a parcel that is in transit. Their timeout is then long, and it is a business number, not a technical one.
May the coordinator of a long flow check the business rules of the other services?#
Decided by Every fact has one writer and The root guards the rule.
The stock service owns the rules about stock, and the payment service owns the rules about payments. A coordinator that checks them has a second copy of those rules. The copies will disagree.
A coordinator that knows every rule becomes a service that every team must change. It asks, waits and decides the next step. That is all.
A process manager keeps the state of a sequence of steps. See the section Long flows: a workflow engine or a process manager.
The coordinator may hold rules about the flow itself, such as "refund if the parcel is not delivered in 14 days". Those rules belong to the flow, not to another service.
Should I run long flows with a workflow engine, or with a process manager in my own code?#
Decided by Every boundary has a price and Prefer the decision you can undo.
A workflow engine stores the state of each flow, and it retries and times out for you. It is also one more product to run and to learn.
A process manager is an aggregate in one of your services. It costs less to start, and you write the retries and timeouts yourself.
The order service has no process manager and no workflow engine, because it has no long flow. The team chooses one approach when the first long flow appears, and records it in an architecture decision record (ADR). See the section Long flows: a workflow engine or a process manager.
A flow that lasts days, with many steps and many failures, gains most from an engine. Writing durable timers and retries yourself for it is the larger cost.
Should I lock data so that users never see a flow halfway through?#
Decided by Now, or a little later.
A lock across services holds data while the network talks. One slow service then blocks everyone.
The business already works with these states. A shop says "awaiting payment" and "out for delivery". A state with a name can be shown, counted and handled.
See the Atlas chapter Who says the order is done?.
A rule that must be true at every moment cannot have a halfway state. Its data must then be in one aggregate, in one service.
Eventual consistency and what users see#
How do I know that eventual consistency is acceptable for a rule?#
Decided by Now, or a little later.
Eventual consistency means that each service is correct on its own, and the services agree after a while. In that while, a rule across them can be false.
The question is not technical. It is about the cost of that short window. Only the people who own the rule know that cost.
The stock service holds products for an order. If the order is not saved, the stock service releases the hold after a timeout. For a while, stock is held for an order that does not exist. See the section Between services: no shared transaction.
If a broken rule means money that cannot be recovered, or a legal fine, the rule must be true now. Then its data belongs in one aggregate.
A user saves a change, and the list on the next screen still shows the old data. What do I
do?#
Decided by Reads and writes have different shapes and Now, or a little later.
The list is built from a model for reading, and that model is updated after the write. So for a moment, the list is behind.
The write already returned the new state. The screen can show that state at once, or show the item as "updating".
The Atlas shows this delay in the chapter Two models are cheaper than one.
The order service reads its lists from the same database as its writes. Its connection
string sets readPreference=primary, so every read sees the write before it. A read from
a replica can be behind. See the section The connection string and the Boot 4
key.
Read models, reports and queries across services#
Still arguedShould every service separate its read model from its write model?#
Decided by Reads and writes have different shapes and Every boundary has a price.
The split is called command query responsibility segregation (CQRS). Greg Young (2010) described it as a small, local pattern. A feature may use it for one screen.
Sam Newman (2021) is more careful. He notes that even a small split adds eventual consistency. And teams often underestimate the work of keeping a read model correct.
Both agree that the split is wrong when reads have the same shape as writes.
The order service reads an aggregate and turns it into a view. See the section One model for reads and writes: CQRS or not.
A screen that joins many aggregates, or many services, needs a model of its own. Loading every aggregate for it is too slow.
Does CQRS require event sourcing?#
Decided by Reads and writes have different shapes.
A read model needs a way to learn about changes. Integration events give that. So does a query on the write side's own tables.
Greg Young (2010) said that CQRS is not event sourcing. He later argued that bundling the two made CQRS look harder than it is.
See the Atlas chapter Two models are cheaper than one.
With event sourcing, a read model is the usual way to answer queries. So CQRS and event sourcing often appear together. But each one works without the other.
Can a command check a read model before it decides?#
Decided by Reads and writes have different shapes and Now, or a little later.
A read model is updated after the write, so it is a little late. A decision based on it can be wrong in a busy moment, which is when it matters most.
A rule that must be true now needs the data that the transaction protects. That data is in the aggregate.
See the Atlas chapter Two models are cheaper than one.
A check that may be a little late can use the read model. A warning such as "this customer ordered the same item yesterday" is an example.
What must a read model be able to do that an ordinary table need not?#
Decided by Reads and writes have different shapes and Prefer the decision you can undo.
A read model is a copy. Its shape changes when a screen changes, and a bug can corrupt it.
If it can be rebuilt, both problems are a rebuild. If it cannot, the copy has become the truth, and nobody owns it.
See the Atlas chapter Two models are cheaper than one.
A read model fed by events needs those events again for a rebuild. If the events are gone, build it from the owners' current data instead, through their APIs.
Can a read model call other services to fill in its rows when an event arrives?#
Decided by A remote call fails, waits, repeats or arrives out of order and Reads and writes have different shapes.
A read model that calls three services fails when any of them is down. It brings the call chain back into the place that should be fast and independent.
The calls also return today's data for yesterday's event. So the row may mix two moments.
See the Atlas chapter Two models are cheaper than one.
Data that the owner will not put in its events, such as large documents, may be fetched. Then the fetch needs a retry, and the row needs a state for "not complete yet".
A screen shows a list that joins data from three services. How do I build it?#
Decided by Reads and writes have different shapes and A remote call fails, waits, repeats or arrives out of order.
Calling several services and joining their answers is called API composition. It works for one item. For a list, it cannot sort or filter across the services without fetching everything.
A read model shaped for the list answers with one query. It may be a little behind, and a list can usually accept that.
See the Atlas chapter The report nobody could run.
A screen that shows one order, from three services, can use composition. Give each call a timeout and a fallback.
Should a mobile app call many services, or one backend made for it?#
Decided by What changes together stays together and A remote call fails, waits, repeats or arrives out of order.
A phone on a slow network pays for every call. One call to a backend near the services costs less than four calls from the phone.
The screen and its backend change together, so one team owns both. This backend is called a backend for frontend. It holds no business rules. The services keep those.
See the Atlas chapter The report nobody could run.
A single web front end with one team may call the services directly. A separate backend then adds a deployment and buys little.
How does a report get data from every service?#
Decided by Every fact has one writer and A published contract is hard to take back.
A report that reads every database depends on every table. The first migration in any service breaks it, and nobody knew that the report existed.
Published facts are contracts with versions. The reporting store builds its own shape from them, for joining and counting.
See the Atlas chapter The report nobody could run.
A small system with one database and one team can report from that database directly. The report then breaks only on changes that the same team makes and tests.
Event sourcing#
Still arguedShould my service store its events instead of its current state?#
Decided by Spend design effort where the business is different and Data outlives the code that wrote it.
Storing the events as the only truth is called event sourcing. Greg Young (2010) and those who follow him argue that it prevents a whole class of "we did not keep that" failures.
Sam Newman (2021) is more careful. He notes that rebuilds, old event shapes and the deletion of personal data cost more than teams expect. A good audit table answers most questions for less. Stored events also stay for ever, so every old shape of an event must still be read.
Both agree that event sourcing is wrong where nobody asks about the past. So spend it where the history is part of what makes the business different.
The order service writes an audit log in the same transaction as each change. See the section The audit log: a business record, not a log line.
A ledger, where every change matters and the history is the business, is the strong case. The choice of an event store is also hard to change later, so decide early.
Can other services subscribe to my event store?#
Decided by A published contract is hard to take back and Every fact has one writer.
Stored events follow the shape of your model. If another service reads them, every change to your model breaks that service.
And stored events cannot be edited. So you could never fix their shape for a consumer. A mapper to integration events keeps the history private.
See the Atlas chapter The ledger never lies.
Modules of one service that deploy together may read the store directly. Nothing is published between them.
Is an audit log the same as event sourcing?#
Decided by Spend design effort where the business is different.
With an audit log, the current state is the truth. The log explains who changed it, and when. With event sourcing, the events are the truth, and the state is computed from them.
An audit log is cheap, and it answers "who did this?". Event sourcing costs much more. So use it only where the business needs every past state, and that need sets it apart.
The order service's audit entry names the action, the resource, the actor and the time. See the section The audit log: a business record, not a log line.
If you need to rebuild the state as it was on a past date, an audit log is not enough. It keeps actions, not every field. That need points to event sourcing.
Field questions: services and teams#
These questions are about the system around one service. They ask when to split a service, how big it is and what services may share. They also ask about contracts, tests, tracing and teams. Each answer starts with what you would say in a meeting. Then it names the principle that decides it.
A monolith or services#
Still arguedShould a new system start as one deployable or as several services?#
Decided by Prefer the decision you can undo and Every boundary has a price.
At the start, the team knows least about where the borders are. A wrong border between modules costs a refactoring. A wrong border between services costs a data migration, two contracts and two pipelines. So the cheaper mistake comes first.
Martin Fowler argued for this in 2015, in "MonolithFirst". He saw most successful service systems start as a monolith, and most systems that started as services get into trouble. Stefan Tilkov answered the same year, in "Don't start with a monolith". He argued that a team that knows its domain should start with separate services. Then the network enforces the borders from the first day. A monolith teaches habits, such as reading across modules, that make a later split as hard as a rewrite.
The two positions agree on one premise. The borders must be known before they are enforced. They disagree about whether a team can know them on the first day.
The Atlas tells this decision as a story in the chapter The consultant who said microservices. The order service shows the module side inside one service. Its features talk only through their application services. See the section Calls between features.
Tilkov's answer is right when the team has built this domain before and a platform for many services already runs. Then the borders are known, and the habits of a monolith are the larger risk.
What makes a modular monolith more than a monolith with tidy folders?#
Decided by Every fact has one writer and What changes together stays together.
Folders keep code apart, but shared data joins it again. If two modules write the same table, each one can break the rules of the other. Then no folder can change that table alone.
A module that owns its data can change it, and can leave the deployable later. So data ownership is what makes the modules real.
The order and note features of the order service store their own documents. Each feature reaches the other only through its application service, never through its repository. See the section Calls between features. For how the build enforces module borders, see the questions fq-b-module-enforce and fq-b-contexts-one-service.
Two modules sometimes save together, because the user sees one action. The order service saves an order and its first notes in one transaction for this reason. Write that decision down, because it blocks a later split of the two modules. See the section One request that saves the order and its first notes.
What is a good reason to move a module into a service of its own?#
Decided by What changes together stays together and Every boundary has a price.
A module that moves to a service adds the premium to every change that crosses its border. So the move must give something that the module could not. Independent release, separate scaling and a separate failure are the three things a module cannot give.
Write the triggers down before you need them. Then the decision is made on evidence, not on fashion.
The Atlas writes such triggers down with the decision, in the chapter The consultant who said microservices.
A border that must exist for legal or security reasons may justify a service before any measurement. Payment data is a common example. Then the premium is the price of that rule.
The code of our monolith is a mess. Will splitting it into services fix that?#
Decided by What changes together stays together and A word that changes meaning marks a border.
A service border helps only where changes do not cross it. In a tangled program, every change crosses everything. A network between the parts keeps the tangle and adds failures.
The fix for the mess is borders that follow the business language. Once they exist inside the program, a split along them is possible. Often it is then no longer needed.
See the Atlas chapters Let's do microservices and The night everything deployed together.
One part of the mess may already have a clear border, a stable contract and its own data. That part can move out first. It is the exception, so check the border before you trust it.
When should two services become one service again?#
Decided by What changes together stays together and Prefer the decision you can undo.
A border is worth its cost only when changes stay on one side of it. If every change touches both services, the team has the cost of two services and no independence.
A merge is a correction, not a failure. A team that cannot merge services has stopped learning from its own design. In 2018, Alexandra Noonan described how Segment merged its many services back into one deployable.
The Atlas merges two services in the chapter The consultant who said microservices.
Keep the services apart when one of them has a real need for its own scaling, security or release. Then make the contract between them smaller, so that fewer changes cross it.
How big a service is#
How big should a microservice be?#
Decided by What changes together stays together and Systems copy the teams that build them.
The size of a service is right when changes rarely cross its border. A service that is too small makes most changes cross into a neighbour. A service that is too large needs two teams, and two teams soon build two models in it.
So the limits come from changes and from people, not from the amount of code.
The order service holds one bounded context with
three features. OrderService has seven use cases,
and one check, requireExists, for the note feature. See the section
One application service per feature.
A part with a very different load, such as a search index, may need its own service inside the same context. Then size follows the runtime need, and the contract between the two parts must stay small.
Is one service per noun, such as a customer service and a product service, a good split?#
Decided by What changes together stays together and A synchronous call borrows the other side's availability.
A business operation, such as placing an order, needs a customer, products and prices. If each noun is a service, every operation crosses three borders. Every change to the operation touches three services, and every call can fail.
A split by business capability keeps one operation inside one service. Services named after layers, such as "web", "logic" and "data", fail in the same way.
See the Atlas chapter Let's do microservices. The order service places an order with one call to another service, the stock reservation. See the section Placing an order: the stock call before the transaction.
A noun can be a capability of its own. A product catalogue that sellers edit, with its own rules and team, is a real context. The test is whether the service does work, not only storage.
How do I keep a feature ready to move into its own service later?#
Decided by What changes together stays together and Every fact has one writer.
A feature that shares nothing but calls can leave without its neighbours noticing much. Each caller changes one call into a remote call.
A feature whose data another feature writes cannot leave alone. The writer would have to move with it, or call it.
The order and note features call each other only through their application services, and pass IDs as text. See the section Calls between features. The shared package holds no business logic, only a few helpers that a moving feature would copy. See the section The shared package, and what stays out of it.
A call that joins the caller's transaction cannot become a remote call. Placing an order calls the note feature inside the same transaction. If notes move to another service, the order is saved first, and the notes follow from an event. See the section How each decision is recorded.
The distributed monolith#
How do I know that our services are a distributed monolith?#
Decided by What changes together stays together and A synchronous call borrows the other side's availability.
A distributed monolith has borders that changes do not respect. A change crosses them, so the services ship together. A shared schema crosses them, so a migration is a joint release.
Its requests also wait on each other. A chain of synchronous calls works only while every service in it works. So the services fail together, as one program would.
The services still pay for the network, the pipelines and the on-call rotas. So the system has the costs of many services and the coupling of one.
See the Atlas chapter The night everything deployed together.
A release plan that lists several services is sometimes honest. Two services can ship one feature together without depending on each other's release. The sign is a change that cannot ship unless the other ships too.
Still arguedWe have a distributed monolith. Do we move forward to real borders, or merge the services?#
Decided by What changes together stays together and Prefer the decision you can undo.
Sam Newman, in 2019, treats a first split along imperfect seams as a stage. The pain gives the evidence for better borders. Alexandra Noonan described in 2018 how Segment merged its services back into one deployable. That view treats the state as a mistake to reverse.
Both views agree that staying in this state is the worst choice. The principle What changes together stays together decides each pair of services on its own. For the pairs that stay apart, the coupling must go.
See the Atlas chapter The night everything deployed together.
If the business borders are still unknown, merge first. Borders are cheaper to find inside one deployable. If they are known and the platform runs well, move forward to the real borders.
Data between services#
Does each service need a database of its own?#
Decided by Every fact has one writer.
The rule that matters is one writer for each fact. Chris Richardson's pattern "Database per service", in his book of 2018, makes each service's data private. Other services reach it only through the service's API.
The pattern accepts private tables, a private schema or a separate database server. It says that private tables and a private schema cost the least. Kirsten Westeinde described in 2019 how Shopify keeps its borders inside one large program, enforced by tools.
What keeps the data private is the access rule, with credentials that enforce it. A separate server alone does not do that.
See the Atlas chapter Nobody owns the product table. The order service never reads the data of another service. It asks the stock service over HTTP, and it receives payments as events. See the section Its events and the services it talks to.
A separate server becomes necessary when a service needs its own scaling, backup or failure limits. It also helps when credentials cannot keep teams out of each other's schemas. Then pay for the second server, its backups and its failover.
Is a service allowed to keep a copy of data that another service owns?#
Decided by Every fact has one writer and Data outlives the code that wrote it.
The rule is one writer for each fact, not one place for each fact. Across services, one service owns the fact and the others may keep copies. When a copy and the owner disagree, the owner wins.
For how a copy stays current, see the question fq-change-copied-data.
See the Atlas chapter Nobody owns the product table.
Personal data changes the answer. A law may require that a person's data is deleted everywhere on request. Then every copy is a place that must be found and cleaned, so keep fewer copies.
Shared code#
What may a library that every service uses contain?#
Decided by Depend on your own rules, not on the details and What changes together stays together.
A business concept changes for the reasons of one service. In a shared library, that change forces every other service to take a new version, or to disagree.
Technical code changes for reasons that are the same for every service. Shared, it is written once and fixed once.
The platform library holds base classes, a guard
and technical defaults, but no business code. Even Money is written by each service.
See the section What never goes in the platform library.
The exception is a rule that must never differ between services, such as a legal rounding rule. Then put that one rule in a small library of its own, with no other business types, and version it carefully. See the section What never goes in the platform library.
How can a shared library change without forcing every service to ship at once?#
Decided by A published contract is hard to take back and What changes together stays together.
A shared library is a published contract between its team and every service. A semantic version tells each service what a new version may break. So each service can upgrade when it is ready, and no release plan joins them.
For a library of defaults, "breaking" covers more than a method signature. A renamed property or a changed default value is also breaking.
The platform's rules say which changes wait for a major version. A service stays at most one minor version behind. See the section Versions, and the rules for upgrading.
A security fix, or the end of support for a major version, can force every service to upgrade before a deadline. Then the services still ship one by one, but the date is no longer theirs to choose. Services that stayed close to the latest version have the least work.
When should a company build its shared platform library?#
Decided by Prefer the decision you can undo and Every boundary has a price.
A library written before any service has to guess what services need. A guess is hard to undo, because services start to depend on it.
A library built from the first two services contains what they really used. The border between service code and library code then has a proven reason.
Before the library exists, the first services keep such code in their shared package, under the package names that the library will use. See the section When to build the library, and what to do before it exists.
A company that already runs many services in another stack knows what it needs. It can build the library first, from the needs it has already measured.
One shared library for everything, or one library for each concern?#
Decided by Every boundary has a price and What changes together stays together.
Each library is a border with its own versions. Several libraries need versions that match. A bug report then names four versions, not one.
When nearly every service uses every area, the extra borders protect nothing.
The shop keeps one library, with each area in its own package, so a later split moves packages and not code. See the section One library, not one per concern.
Split when several services do not use an area and it slows them. Also split when services in another language need part of the library, because that part is then not a Spring Boot library at all.
Contracts and versions#
When does an HTTP API between services need a new version?#
Decided by A published contract is hard to take back.
Adding a field breaks no consumer that ignores unknown fields. Removing or renaming one breaks every consumer that reads it, at a time that you do not choose.
So the producer adds the new form, waits until every consumer has moved, and then removes the old
form. This is called expand and contract. A path such as /v2/ that replaces
/v1/ in one deploy is a breaking change with a number on it.
For HTTP interfaces, see the Atlas chapter Can you see it?. For events, see the question fq-v1-v2-how-long.
If you deploy every consumer yourself, together with the producer, the API is not published. A rename is then a refactoring. Check that this is really true before you rely on it.
Deploying and releasing#
What is the test that a service can be deployed on its own?#
Decided by What changes together stays together.
Independent deployment is the main thing that a service gives over a module. Sam Newman made it the test of a service in 2015.
A border that changes respect lets the service ship alone. A border that changes cross makes the service wait for its neighbours.
The order service may add fields to its events within V1, so it ships without its consumers. See the section Versions in names: what may change within V1. The Atlas asks this question in the chapter Let's do microservices.
A regulator or a large customer may require one release date for all changes. Then the services ship together on that date. Keep each one able to ship alone, so the shared date stays a choice and not a need.
What is the difference between deploying and releasing?#
Decided by Prefer the decision you can undo and Data outlives the code that wrote it.
A change that is deployed but not released can be undone in seconds. A flag sends one user, then a few, then everyone down the new path.
Each flag doubles the paths that the tests must cover. So delete a flag as soon as the old path is gone.
See the Atlas chapter Strangling the monolith.
A change of stored data cannot hide behind a flag. During a rolling deploy, old code and new code read the same documents. So change the data in steps that both versions of the code can read.
Testing across services#
Still arguedDo we need a suite of end-to-end tests that runs all the services together?#
Decided by A published contract is hard to take back and What changes together stays together.
A test that needs every service deployed together needs every service at one version. That is a joint release, written as a test. It is slow, it fails for reasons outside the change, and teams learn to ignore it. Both sides of the argument agree on this.
Sam Newman, in 2021, wants as close to zero tests across services as possible, with contract tests at each border instead. Jez Humble and David Farley, in their book Continuous Delivery from 2010, keep a few journeys on the critical path. Such a journey catches wrong settings that no contract can see.
The order service's journey test runs one business story through the order service alone, with real MongoDB and an emulator for messages. The QA team's Playwright tests run across services, in their own repository. See the section The kinds of test, who owns each, and what each costs.
If you keep journeys across services, keep them few, owned and fast. A journey that fails often and is ignored is worse than none, because it trains people to ship on red.
What does a contract test check, and where does it run?#
Decided by A published contract is hard to take back.
The change that breaks a consumer starts in the producer. So the check belongs in the producer's build, where the change is made.
When consumers write their expectations, the producer's build can name the consumer that a change breaks. These are called consumer-driven contracts.
The order service checks its published events against committed JSON schemas, and its API against a committed OpenAPI file. A removed field fails its build. See the section The OpenAPI drift test.
A check of the producer's own schema finds every removed required field. But it cannot tell which change matters to whom. For that, the producer needs the consumers' own expectations.
How do I test my service without running the services that it calls?#
Decided by Depend on your own rules, not on the details and A remote call fails, waits, repeats or arrives out of order.
The service depends on a port, an interface in its own words. So a test can replace the other service at that port, and nothing else changes.
The client still needs its own test against real HTTP. Timeouts, retries and the translation of answers are exactly what fails between services.
The stock client is tested against an HTTP server from the Java Development Kit (JDK). The journey test replaces the stock port. See the section The HTTP client against a real server from the JDK. Also see the section The journey test: real MongoDB and a real SNS and SQS emulator.
A stub that answers what the real service never answers proves the wrong thing. So copy a real response when you write a stub, and keep its field names.
Seeing across services#
What must every service do so that one request can be followed through all of them?#
Decided by You can fix only what you can see.
Each service logs only its own part of the work. A shared ID is the only thing that joins those parts into one story. The edge of the system accepts the caller's ID, or creates one.
A message is sent later, on another thread, and often without the trace ID. A business ID in the message, such as the order ID, joins the two traces.
The order service gets the trace ID from Spring Boot's tracing, with no code, and sends it on its HTTP calls. Its events carry no trace ID. A consumer starts a new trace, and the order ID joins the two traces. See the section Keeping event flows traceable.
A team that needs one trace across a message must add the trace context to the message as a header. That is extra work for each kind of broker, so do it only where support really needs it.
What should wake an engineer at night, a busy processor or failed orders?#
Decided by You can fix only what you can see.
A busy machine may serve every customer well. A quiet machine may fail every order. So an alert on the machine wakes people for nothing, and stays silent when it matters.
An alert on a business number, such as the share of failed checkouts, fires when customers are hurt. Google's book on site reliability, from 2016, made this common practice.
The order service counts placed, shipped and cancelled orders in a metric named
shop.orders. That metric counts business events, not failures. And nothing is exported
until a deployment adds a registry. See the section Business metrics.
For the full idea, see the Atlas chapter
Can you see it?.
A cause that always comes before harm, such as a full disk, may deserve a warning. It is a warning for the day, not a call at night.
Trust between services#
Other features, listeners and jobs also call our use cases. Where do we check a user's
permission?#
Decided by Check permissions where a user's request enters.
Only the entry point knows who the user is. A listener, a job and another feature have no user, because the service itself decided to do the work.
A check on the use case would refuse that work. A developer then writes a helper that pretends to be a user, and the check protects nothing.
Each controller method of the order service names its scope, and the application services carry no permission annotation. An architecture rule fails the build for an endpoint without a scope. See the sections Choosing the place by the shape of the service and Why an identity-swapping helper is a warning sign.
When users reach the same use cases through several doors, such as REST and GraphQL, check on the use case instead. Then listeners and jobs need use cases of their own, with their own business names and no check.
Our service calls another service during a user's request. Should it forward the user's token,
or send a token of its own?#
Decided by Check permissions where a user's request enters.
The user's permission was checked where the request entered our service. The next service has its own entry point, and its caller there is our service.
A user's token is also made for one audience. A service that checks the audience refuses a token made for another service. And work started by a message has no user token to forward.
The order service gets its own token for the stock service with the client-credentials grant,
and asks only for the scope stock.reserve. See the section
The service's own token for the stock service.
Sometimes the next service must check a rule about the user's own data. Then the user's identity must reach it as a signed token that it can verify. A user ID in a request body is not enough, because any caller could write one.
Another service puts a user ID in a message to us. May we treat that ID as the permission of
that user?#
Decided by Check permissions where a user's request enters.
A message is not an entry point for a user. The receiver trusts the queue, so it trusts the sending service, and not a user. A user ID in the message can be written by any code that can send to the queue.
So the receiver may store the ID, for example to show who paid. It does not check the user's permissions with it.
The order service trusts its payment queue, not the payment message. Only the payment service may send to that queue, and that is a setting in AWS. See the section Messages and jobs: the system actor. For the end user's identity across services, see the Atlas chapter Can you see it?.
A queue may carry commands from users, each with the user's token. Then the listener is an entry point for users. It checks the token and the permission, like a controller does.
A message or a scheduled job changes an order. Who is named as the actor in the audit?#
Decided by Check permissions where a user's request enters and You can fix only what you can see.
No user started the work, so no user was checked at an entry point. Naming a user would record something that did not happen.
A fixed name for the system keeps the audit honest. A person reading it later can then see which changes a person made and which the service made.
The order service's audit log asks CurrentActor for the actor. That is the token's
subject for a request, and system for a message or a job. The journey test checks the
actors of a whole order. See the section The current actor, and the audit
record.
A job that a person starts, for example with a button in an admin page, is a user's request. Then the actor is that person, checked where the request entered.
Teams and ownership#
Does the shape of the teams really decide the shape of the system?#
Decided by Systems copy the teams that build them.
Melvin Conway described this in 1968. A change inside one team is a conversation. A change across two teams is a meeting and a wait. People choose the cheap path, so the code follows the teams.
See the Atlas chapter You ship your org chart.
The law describes a pressure, not a rule. A team can work against it, for example with build rules that keep modules apart. But it pays for that every day, in meetings and reviews.
How many services can one team own?#
Decided by Systems copy the teams that build them and Every boundary has a price.
Each service brings a pipeline, an on-call rota, dashboards and contracts. A team pays for each one, every week, whether or not the service changes.
A team with more services than it can follow owns them only on paper. In practice, whoever is on call owns them.
See the Atlas chapters You ship your org chart and The consultant who said microservices.
A good platform lowers the cost of each service, so a team can own more. It never makes the cost zero, because the on-call rota and the contracts stay with the team.
Should we change the teams to fit the architecture, or the architecture to fit the teams?#
Decided by Systems copy the teams that build them.
Matthew Skelton and Manuel Pais, in 2019, treat team design as the first act of architecture. They design the teams on purpose, before the code. This is called the inverse Conway manoeuvre.
Critics on the other side note how rarely architects may change the org chart. They say that a reorganisation is expensive, and that an architecture which needs one org chart is fragile. Both sides accept Conway's law as a fact.
See the Atlas chapter You ship your org chart.
A reorganisation that moves all teams at once, with the old code, breaks every border on the same day. Move one context at a time, with its code, its on-call rota and its backlog.
Do we need a platform team?#
Decided by Spend design effort where the business is different and Systems copy the teams that build them.
The technical part of the premium is the same in every company. Pipelines, messaging setup and dashboards are not why customers choose you. So spend the least effort on them, once, in one team.
Skelton and Pais named the platform team as one of four team types in 2019. Its users are the other teams, which then spend their effort on the business.
The shop's platform team owns the platform library, its upgrade notes and the architecture rules. See the sections Versions, and the rules for upgrading and The kinds of test, who owns each, and what each costs.
With one team and two services, a platform team is a border with nothing to protect. Keep the shared code in a library that the one team owns.
Where do we write down why a border is where it is?#
Decided by Prefer the decision you can undo.
A decision that nobody wrote down is made again by the next person. A decision with no sign for review is never revisited, even when the evidence changes.
The sign is what keeps a decision reversible. It names the evidence that would change it.
The order service keeps short decisions in its README, and longer ones as architecture decision records (ADRs) in the format of Michael Nygard. See the section How each decision is recorded.
A decision that is easy to undo, such as a name inside one class, needs no note. Write notes for decisions that cost a migration or a meeting to change.
Moving away from a monolith#
Should we rewrite the old system in one project, and switch over on one day?#
Decided by Prefer the decision you can undo.
A rewrite in one project delivers nothing until it delivers everything. Its estimate is made by people who have not yet found the old system's hidden rules. And its switch-over cannot be undone in one minute.
Martin Fowler named the step-by-step way the strangler application in 2004. A facade in front of the old system decides, for each capability, whether the old code or the new code answers.
See the Atlas chapter Strangling the monolith.
A small system with few users and no hidden rules may be cheaper to replace in one step. The test is whether you could switch back if the new system fails on the first day.
Which part of a monolith should move out first?#
Decided by What changes together stays together and Prefer the decision you can undo.
A part that touches little else can move without changing its neighbours. A part that changes often gives value soon after the move.
The most tangled part takes months and shows nothing to users. A migration that starts there often stops before anything easy has shipped.
See the Atlas chapter Strangling the monolith.
A part with an urgent need, such as a legal deadline, may have to move first however tangled it is. Then plan for a longer first step, and say so to its sponsor.
When is the move away from a monolith finished?#
Decided by Data outlives the code that wrote it and Every fact has one writer.
Each capability that moves takes its data with it. Until the last reader of an old table has moved, the old database is still in use, and the old system cannot go.
So every moved capability needs a plan for its data. The plan names how long the old and new copies stay in step, and which one writes.
See the Atlas chapter Strangling the monolith.
A law may require that old records are kept for years. Then the old tables may stay as a read-only archive. The move is finished when nothing writes to them and only the archive reads them.
How do we know that the new code gives the same answers as the old code?#
Decided by Prefer the decision you can undo and You can fix only what you can see.
A parallel run turns a risky switch into a measurement. The old answer still wins, so users see nothing while the team compares.
The log of differences is the test. Without someone who reads it, the parallel run only doubles the cost.
Sam Newman describes the parallel run in his book of 2019. See the Atlas chapter Strangling the monolith.
A capability with side effects, such as sending an email or taking a payment, cannot run twice. Run the new code without its side effects, and compare only its decisions.
Still arguedWhen we move away from a monolith, do we move toward services or toward modules?#
Decided by Prefer the decision you can undo and Every boundary has a price.
Sam Newman, in 2019, writes about moving parts into services. He also says that the same techniques move a part into a module, and that the destination should follow the need.
Vaughn Vernon and Tomasz Jaskuła, in 2021, and Kirsten Westeinde, in 2019, argue for modules inside one deployable. That gets most of the benefit with none of the network's cost. Services can be cut out later along the module borders.
See the Atlas chapter Strangling the monolith.
A capability that needs its own scaling or its own team already has the reason. Move it straight to a service, and skip the module step.
Pragmatic DDD Guide#
This is a team guide for designing the inside of a microservice, with a light form of Domain-Driven Design (DDD): 38 rules, each with the situation, the code that goes wrong, the code that works, and why, on Spring Boot with Spring Data MongoDB and Amazon DocumentDB, with the Spring feature named under each example. It has its own example, a claim service, not the order service of this book. You can download it as a PDF, as Markdown, or as a page to paste into Confluence.
Download this guide: PDF · Markdown (.md) · Confluence page (.html). Short version for the team, the same rules in five pages with no code: PDF · Markdown (.md) · Confluence page (.html). To use a Confluence page, open it in a browser, press its Copy button, and paste into the Confluence editor.
Oct 31, 2026 · @kodebot
This guide says how we design the inside of a microservice. We use a light form of Domain-Driven Design (DDD). DDD means two things here. Write the code in the business's own words. Put each business rule inside the object it is about.
The guide has two aims. Every business rule lives in exactly one place. A design mistake can be fixed inside one feature of one microservice, without changing the others.
Our stack is Spring Boot 4 (Spring Framework 7, Spring Data MongoDB 5, Spring Security 7) on Amazon DocumentDB. Every example uses it, and an italic line under each names the Spring feature doing the work. Besides Spring, only our test tools are named: Testcontainers, WireMock and Floci. Every rule has the same shape: the situation, the code most people write first, what goes wrong, the code we want, and why. Read the walkthrough and the rules in order once. After that, the list below is your checklist.
Your first two days. Day 1, morning: the walkthrough and Parts A and B (rules 1 to 13), about two hours; then open the claim microservice and find each class of the walkthrough in it. Day 1, afternoon: the API Design Developer Guide's walkthrough and Parts A and B. Day 2: Parts C and D of both guides, then your first ticket with a senior engineer. Parts E and F are not for reading through: they are the answers for the situations you will meet, one at a time, when you meet them.
The rules#
One line per rule. The section with the same number has the situation, the code and the reasons.
Part A. Where a rule lives
- Put a business rule in the entity method that makes the change, not in a service.
- Change an entity's fields only through its own methods; no setters.
- A service method only runs the steps: load, check the version, get facts, call the entity, save, audit.
- Name classes, methods and errors with the business's own words.
- Put a calculation that spans several objects in a domain service, and only when it has worked examples.
- Give every ID and business value its own type; check its format once, where the value is created.
Part B. Aggregates
- Keep a child inside the aggregate only if the parent's rules need it, it has a maximum count, it changes with the parent and it means nothing alone.
- Store another aggregate's ID, never the object itself.
- Save one aggregate per transaction.
- Give every list inside an aggregate a maximum size.
- Refuse a save that was made from an old version of the record.
- Make every change safe to repeat: a repeated request adds or counts nothing twice.
- Store money as an exact decimal and time in UTC, and count days in UK dates.
Part C. Features, microservices and events
- Use another feature only through its application service, passing IDs and simple values.
- Check permissions in the controller, never in the service or the entity.
- Talk to another system only through one adapter class that translates its data into our types.
- Call another microservice before you open the transaction, never inside it.
- Retry a read; never retry a change.
- Save an event in the same transaction as the change it reports, and send it afterwards.
- Only add fields to a published event; to remove or rename one, publish a new version.
- Write every listener so a message that arrives late, twice or out of order does no harm.
- Change several microservices as a chain of steps joined by events, with a named step for failure.
Part D. Process
- Get the design note approved before you code a feature with business rules.
- Mark a feature Simple (data with no rules) or Rich (states and rules) in its design note.
- Fix a mistake with a safe step today and the proper fix by a date.
- Keep personal data out of logs, error messages and events.
Part E. Situations you will meet
- When one button must save two aggregates, ask whether they are one; else react to an event; only then save both in one approved transaction.
- Keep a rule across all records of a kind true with a unique index, and turn the index error into a business error.
- Create an entity through a named factory method that checks the rules and raises the event; keep the constructor private.
- Read for a screen through a query class on the collections; never build a screen out of aggregates.
- Search and reports are queries too; give a slow one its own read collection filled by events.
- Do not store on a parent what a child aggregate knows; read it when needed, or copy it by event and accept a short delay.
- Never delete a business record; add a business action that marks it, with a reason.
- Run a time-based rule from a scheduled job that calls the same entity method, one record per transaction.
- When a process has its own steps and deadlines, give it its own aggregate.
- Change an aggregate's shape behind a flag, with a migration that uses the entity's own methods.
- Test the entity with no database, the service with a fake repository, and the repository alone against a real database.
- Show two aggregates on one screen with two save buttons; one button that saves two things is a design smell to raise.
Terms#
| Term | Meaning |
|---|---|
| Microservice | A Spring Boot application deployed on its own, with its own DocumentDB database |
| Feature | One business area inside a microservice, in its own package. Example: claims, notes |
| Business action | One change the user asks for with one button, such as "close claim" |
| Entity | A business object with its own ID. Its details change; its identity does not. Example: Claim |
| Value object | A value with no ID, defined by its contents, never changed after it is created. Example: Money |
| Aggregate | A group of objects always loaded and saved together, as one unit. The top object is the aggregate root. In DocumentDB an aggregate is one document in one collection. Example: a Claim with its EvidenceItems |
| Repository | The one class that loads and saves one kind of aggregate. Example: ClaimRepository, a Spring Data MongoRepository |
| Application service | A class with one method per business action. Each method runs that action's steps: load, check the version, get facts, call the entity, save, audit. Example: ClaimService with open, addEvidence, close. A class per action (CloseClaim with one method) is also fine; pick one shape per microservice |
| Domain service | A rare class for a calculation that belongs to no single entity. Example: EntitlementCalculator |
| Adapter | The one class that talks to another microservice or system. Example: EligibilityClient |
| Domain event | A record that something happened inside our microservice, named in the past tense. Example: ClaimClosed |
| Integration event | A versioned event that other microservices receive. Example: ClaimClosedV1 |
| Outbox | A collection where an event is stored in the same transaction as the change it reports; a sender publishes it to the message broker afterwards |
| Inbox | A collection of processed message IDs, so a message delivered twice is handled once |
| Audit record | A line kept for ever after every change: who changed which record, how, and when. Written by the application service as its last step, never by the entity |
| Design group | The lead engineer, two or three senior engineers and the business experts |
| Design note | A one-page description of a feature that the design group approves before coding starts |
The example#
A caseworker records a claim for a claimant, adds evidence (documents such as payslips) and marks each item verified. The caseworker writes notes; a note can be marked "needs action". At the end, the caseworker closes the claim. The eligibility microservice says whether a person may claim. The payment microservice pays claimants and tells us when it has paid.
The business rules: a claim is created only if the claimant is eligible and the claim type is active. A claim has at most 10 evidence items, each verified once. A claim is closed only when all evidence is verified and no note needs action. A closed claim cannot be changed. A payment replaces the claim's last payment only if its payment date is later.
A claim and its evidence are one aggregate, so one document in the claims collection. Each note is its own aggregate in the notes collection and holds the claim's ID. Rule 7 explains why.
Closing a claim, end to end#
This is the whole path of one business action, first when it succeeds and then when a rule refuses it. Every rule in the guide points back at one of these classes.
The controller receives the request and checks the permission. It does nothing else.
@RestController
public class ClaimController {
@PostMapping("/v1/claims/{id}/close")
@PreAuthorize("hasAuthority('CLOSE_CLAIM')")
public ResponseEntity<Void> close(@PathVariable ClaimId id, @RequestBody @Valid CloseRequest body) {
claimService.close(id, body.version());
return ResponseEntity.noContent().build(); // 204
}
}
public record CloseRequest(@NotNull Long version) {}Spring: @PreAuthorize with @EnableMethodSecurity refuses the call with 403 before the method body runs. @PathVariable ClaimId binds on its own, because ClaimId is a record with one String field (rule 6). @Valid turns a missing version into 400.
The application service runs the steps. It holds no business rule.
@Service
public class ClaimService {
@Transactional
public void close(ClaimId id, long expectedVersion) {
Claim claim = claims.findById(id).orElseThrow(() -> new ClaimNotFound(id)); // 1. load
claim.requireVersion(expectedVersion); // 2. the version the user saw
int open = noteService.countNeedingAction(id); // 3. a fact the claim does not hold
claim.close(open, LocalDate.now(clock)); // 4. the entity decides
claims.save(claim); // 5. save; events go out with it
audit.record("claim closed", id); // 6. audit
}
}Spring: @Transactional opens one DocumentDB transaction for the method, through the MongoTransactionManager bean in the setup below; without that bean the annotation does nothing.
The entity decides. All the rules for closing are in this one method.
@Document("claims")
public class Claim {
@Id private ClaimId id;
@Version private long version;
private ClaimantId claimantId;
private ClaimStatus status;
private List<EvidenceItem> evidence = new ArrayList<>(); // stored inside the claim document
@Field(targetType = FieldType.STRING) private LocalDate closedOn; // a date, stored as "2026-10-31" (rule 13)
@Transient private final List<DomainEvent> events = new ArrayList<>();
public void requireVersion(long expected) { // step 2 of every change (rule 11)
if (version != expected) throw new VersionConflict(id, expected, version);
}
public void close(int notesNeedingAction, LocalDate today) {
if (status == CLOSED) throw new ClaimAlreadyClosed(id);
if (evidence.stream().anyMatch(e -> !e.isVerified())) throw new EvidenceNotVerified(id);
if (notesNeedingAction > 0) throw new ClaimHasOpenActions(id, notesNeedingAction);
status = CLOSED;
closedOn = today;
events.add(new ClaimClosed(id, today));
}
@DomainEvents Collection<DomainEvent> events() { return List.copyOf(events); }
@AfterDomainEventPublication void clearEvents() { events.clear(); }
}Spring: @Document names the collection; @Version makes save fail with OptimisticLockingFailureException when the stored version is not the loaded one; @DomainEvents publishes the entity's events when the repository saves it; @Transient keeps the event list out of the document.
The repository loads and saves the whole aggregate. It is an interface; Spring Data writes the implementation.
public interface ClaimRepository extends MongoRepository<Claim, ClaimId> {}The outbox listener stores each event beside the claim, in the same transaction. A sender publishes the stored events after the commit.
@Component
public class Outbox {
@EventListener // same thread, same transaction as the save
public void store(DomainEvent event) {
if (!event.hasPublishedShape()) return; // internal-only events stay inside
mongo.insert(OutboxEntry.of(event), "outbox"); // ClaimClosed becomes a ClaimClosedV1 entry
}
@Scheduled(fixedDelay = 1000) // on more than one instance, under the job lock (rule 34)
public void send() {
for (OutboxEntry entry : mongo.find(query(where("sentAt").isNull()), OutboxEntry.class, "outbox")) {
broker.publish(entry.topic(), entry.payload());
mongo.updateFirst(query(where("_id").is(entry.id())), update("sentAt", Instant.now(clock)), "outbox");
}
}
}Spring: @EventListener receives the events @DomainEvents published, synchronously, so the insert runs inside the service's transaction; @Scheduled with @EnableScheduling runs the sender; MongoTemplate does the writes.
The published event carries IDs, not personal data.
public record ClaimClosedV1(String eventId, String claimId, LocalDate closedOn) {}When a rule refuses#
Now the claim has two notes that need action. Claim.close throws ClaimHasOpenActions at its third line. Nothing catches it on the way out: not the service, not the controller. @Transactional rolls the transaction back on the unchecked exception, so nothing is saved, no outbox entry is stored and no audit record is written. The exception reaches one shared error handler at the HTTP edge, which turns it into the answer:
public abstract class DomainError extends RuntimeException { // unchecked, so the transaction rolls back
protected DomainError(String message) { super(message); }
public abstract String code();
}
public abstract class RuleRefused extends DomainError { ... } // a rule said no: 422
public abstract class NotFound extends DomainError { ... } // the record does not exist: 404
public final class VersionConflict extends DomainError { ... } // the version is old: 409
public final class ClaimHasOpenActions extends RuleRefused {
public ClaimHasOpenActions(ClaimId id, int notes) { super("Claim " + id.value() + " has " + notes + " notes that need action"); }
public String code() { return "CLAIM_HAS_OPEN_ACTIONS"; }
}
@RestControllerAdvice
public class ApiErrors extends ResponseEntityExceptionHandler { // the one error handler of the microservice
@ExceptionHandler(RuleRefused.class)
ProblemDetail ruleRefused(RuleRefused e) {
return problem(HttpStatus.UNPROCESSABLE_CONTENT, "Business rule violated", e.code(), e.getMessage());
}
@ExceptionHandler({VersionConflict.class, OptimisticLockingFailureException.class})
ProblemDetail conflict(Exception e) {
return problem(HttpStatus.CONFLICT, "Record changed", "CONCURRENT_UPDATE", e.getMessage());
}
@ExceptionHandler(NotFound.class)
ProblemDetail notFound(NotFound e) {
return problem(HttpStatus.NOT_FOUND, "Not found", e.code(), e.getMessage());
}
private ProblemDetail problem(HttpStatus status, String title, String code, String detail) {
ProblemDetail p = ProblemDetail.forStatusAndDetail(status, detail);
p.setTitle(title);
p.setProperty("code", code);
p.setProperty("traceId", Trace.current()); // the trace ID a filter took from the traceparent header
return p;
}
}422 Unprocessable Content
Content-Type: application/problem+json
{ "status": 422, "title": "Business rule violated", "code": "CLAIM_HAS_OPEN_ACTIONS",
"detail": "Claim 9f1c has 2 notes that need action", "traceId": "4bf92f35..." }Spring: @RestControllerAdvice with @ExceptionHandler runs for every controller; ProblemDetail is Spring MVC's RFC 9457 body and setProperty adds our code and traceId. @Transactional rolls back on any RuntimeException, so every domain error is one.
Three kinds of error are thrown on purpose: not found, old version, rule refused. Anything else is a bug and answers 500 with the trace ID, never the stack trace. The same class also answers 400, 403 and 503; the API Design Developer Guide, rule 26, shows those handlers.
When the same exception happens in a message listener, there is no HTTP answer. The transaction rolls back, the message is not marked processed, and the broker delivers it again. A rule that keeps refusing sends the message to the dead-letter queue (rule 21).
One-time setup for the stack#
@Configuration
@EnableScheduling @EnableMethodSecurity
public class AppConfig {
@Bean MongoTransactionManager transactionManager(MongoDatabaseFactory db) { return new MongoTransactionManager(db); }
@Bean Clock clock() { return Clock.systemUTC(); }
@Bean MongoCustomConversions conversions() {
return new MongoCustomConversions(List.of(new ClaimIdToString(), new StringToClaimId(), /* one pair per ID type */));
}
@Bean JwtAuthenticationConverter jwtAuthorities() { // the gateway's token carries "permissions": ["CLOSE_CLAIM", ...]
JwtGrantedAuthoritiesConverter claims = new JwtGrantedAuthoritiesConverter();
claims.setAuthoritiesClaimName("permissions");
claims.setAuthorityPrefix(""); // so hasAuthority('CLOSE_CLAIM') matches as written
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(claims);
return converter;
}
}spring.mongodb.uri=mongodb://...docdb.amazonaws.com:27017/claims?tls=true&replicaSet=rs0&retryWrites=false
spring.data.mongodb.auto-index-creation=false # indexes are created by a migration, never at startup
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://gateway.example.gov.uk # Boot builds the JWT decoder from itSpring: MongoTransactionManager turns @Transactional on, and Boot then adds the TransactionTemplate bean of rule 9. MongoCustomConversions stores each ID value object as a plain string. With issuer-uri set, Boot's default SecurityFilterChain demands a valid token on every request, and JwtAuthenticationConverter makes hasAuthority('CLOSE_CLAIM') see the token's permissions claim; without it Spring reads only scope, as SCOPE_..., and every check fails. DocumentDB before engine 8.0.2 rejects retryable writes, hence retryWrites=false. Index creation stays off: a DocumentDB index build blocks the collection, so a migration does it at a planned time.
Part A. Where a rule lives#
1. Put a business rule in the entity method that makes the change, not in a service#
You are adding "close claim". The rule: a claim closes only when every evidence item is verified and no note needs action.
Bad — the rule sits in the service:
@Service
public class ClaimService {
@Transactional
public void close(ClaimId id) {
Claim claim = claims.findById(id).orElseThrow();
if (claim.getStatus() == CLOSED) throw new IllegalStateException();
if (!claim.getEvidence().stream().allMatch(EvidenceItem::isVerified)) throw new IllegalStateException();
if (noteService.countNeedingAction(id) > 0) throw new IllegalStateException();
claim.setStatus(CLOSED);
claims.save(claim);
}
}Next month someone adds "close claims from the nightly job" and copies these three ifs. Then one copy changes and the other does not.
Good — the rule sits in Claim.close. The service fetches the one fact the claim does not hold (the note count) and passes it in:
public class Claim {
public void close(int notesNeedingAction, LocalDate today) {
if (status == CLOSED) throw new ClaimAlreadyClosed(id);
if (evidence.stream().anyMatch(e -> !e.isVerified())) throw new EvidenceNotVerified(id);
if (notesNeedingAction > 0) throw new ClaimHasOpenActions(id, notesNeedingAction);
status = CLOSED;
closedOn = today;
events.add(new ClaimClosed(id, today));
}
}
@Service
public class ClaimService {
@Transactional
public void close(ClaimId id, long expectedVersion) {
Claim claim = claims.findById(id).orElseThrow(() -> new ClaimNotFound(id));
claim.requireVersion(expectedVersion);
int open = noteService.countNeedingAction(id);
claim.close(open, LocalDate.now(clock));
claims.save(claim);
audit.record("claim closed", id);
}
}Nobody can call close without supplying the note count, so the check cannot be forgotten. The nightly job calls the same Claim.close.
2. Change an entity's fields only through its own methods; no setters#
You need to mark an evidence item verified.
Bad — a setter:
item.setVerified(true);
item.setVerifiedOn(today);Nothing checks that the item was not verified already, and no EvidenceVerified event is recorded. Any class anywhere can do this.
Good — a method with the business name, on the entity:
public class Claim {
public void verifyEvidence(EvidenceId evidenceId, LocalDate today) {
if (status == CLOSED) throw new ClaimAlreadyClosed(id);
EvidenceItem item = find(evidenceId);
item.markVerified(today); // refuses a second time
events.add(new EvidenceVerified(id, evidenceId));
}
}All fields are private, and a build check fails on any public setter in an entity. A method checks the rules and records the event; a setter skips both.
Spring: Spring Data MongoDB reads and writes private fields directly, so an entity needs no getters or setters for persistence.
3. A service method only runs the steps: load, check the version, get facts, call the entity, save, audit#
You are writing ClaimService.verifyEvidence.
Bad — the service makes decisions:
@Transactional
public void verifyEvidence(ClaimId id, EvidenceId evidenceId) {
Claim claim = claims.findById(id).orElseThrow();
if (claim.getStatus() == CLOSED) throw new IllegalStateException();
EvidenceItem item = claim.getEvidence().stream().filter(e -> e.id().equals(evidenceId)).findFirst().orElseThrow();
if (item.isVerified()) throw new IllegalStateException();
item.setVerified(true);
claims.save(claim);
}"Verified once" sits in the service, which is rule 1's problem. The shape is the second problem: this method has no version check and no audit, and nobody notices, because every service method in the codebase looks different.
Good — the same six steps as close, and nothing else:
@Transactional
public void verifyEvidence(ClaimId id, long expectedVersion, EvidenceId evidenceId) {
Claim claim = claims.findById(id).orElseThrow(() -> new ClaimNotFound(id)); // 1
claim.requireVersion(expectedVersion); // 2
// 3: no outside facts needed
claim.verifyEvidence(evidenceId, LocalDate.now(clock)); // 4: the entity checks the rules (rule 2)
claims.save(claim); // 5
audit.record("evidence verified", id); // 6
}A service method contains no if about status, amounts, dates or counts; such an if belongs in the entity (rule 1) or a value object (rule 6). The transaction covers steps 1 to 6; a call to another system happens before it opens (rule 17). Take the time from the Clock bean, so tests can set the date. The fixed order is a convention we chose: when every action has the same six steps in the same order, a missing version check or audit stands out in review.
Spring: @Transactional on a public method of a @Service bean. A call from another method of the same class bypasses it, so keep each business action in its own public method, or start the transaction with TransactionTemplate (rule 9).
4. Name classes, methods and errors with the business's own words#
The business says "close a claim" and "a claim has open actions".
Bad:
claim.updateStatus(3);
throw new ValidationException("invalid state");A caseworker reading the log cannot tell what happened, and a developer must look up what 3 means.
Good:
claim.close(openActions, today);
throw new ClaimHasOpenActions(id, openActions);The design note, the code, the error message and the conversation with the business then use the same words. The shared @RestControllerAdvice turns ClaimHasOpenActions into 422 with the code CLAIM_HAS_OPEN_ACTIONS ("When a rule refuses", in the walkthrough). The entity never knows about HTTP.
5. Put a calculation that spans several objects in a domain service, and only when it has worked examples#
You must calculate a claimant's weekly entitlement from the claim type, the rate table and the number of children.
Bad — a "policy" class that holds one if:
@Component
public class ClaimClosurePolicy {
public boolean canClose(Claim claim) {
return claim.evidenceAllVerified();
}
}This is one if about closing, so it belongs in Claim.close (rule 1); a helper bean is one more place to look. A domain service is for the opposite case: a calculation over several objects that would swamp the entity.
Good — a stateless calculation with a table of worked examples in the design note:
public class EntitlementCalculator { // a plain class; not a bean, nothing injected
public Money weeklyEntitlement(ClaimType type, RateTable rates, int children) {
Money base = rates.baseFor(type);
return base.plus(rates.perChild().times(children));
}
}| Claim type | Children | Weekly entitlement |
|---|---|---|
| Standard | 0 | £84.80 |
| Standard | 2 | £146.20 |
A domain service never loads or saves. If you cannot write a table like this, the logic is one or two ifs and belongs in the entity (rule 1).
6. Give every ID and every business value its own type, and check its format once, where the value is created#
An amount must not be negative. A file name must not be blank. A claimant ID must never be passed where a claim ID is expected.
Bad — checks sprinkled along the way, and bare values:
// in the controller
if (body.amount() < 0) return ResponseEntity.badRequest().build();
// in the service
if (amount < 0) throw new IllegalArgumentException();
// in the entity
if (amount < 0) throw new IllegalArgumentException();
public void close(String claimId, String claimantId, BigDecimal amount) { ... } // three bare valuesThree copies of the check, and the import job has none. And close(claimantId, claimId, ...) compiles, with the two IDs swapped.
Good — every ID and every business value is its own small type, and it refuses to exist with a bad value:
public record ClaimId(String value) {
public ClaimId { if (!value.matches("[0-9a-f]{4,32}")) throw new InvalidClaimId(value); }
}
public record Money(BigDecimal amount) {
public Money {
if (amount.signum() < 0) throw new NegativeAmount(amount);
if (amount.scale() > 2) throw new TooManyDecimalPlaces(amount);
amount = amount.setScale(2);
}
}
public void close(ClaimId id, int notesNeedingAction, LocalDate today) { ... } // the compiler checks the types@WritingConverter public class ClaimIdToString implements Converter<ClaimId, String> { public String convert(ClaimId id) { return id.value(); } }
@ReadingConverter public class StringToClaimId implements Converter<String, ClaimId> { public ClaimId convert(String s) { return new ClaimId(s); } }ClaimId, ClaimantId, NoteId, Money, NoteText, FileName, Nino: never a bare String or BigDecimal inside the microservice. A bare string arrives only at the edges (a request body, a message, a document) and becomes a type there. Once a Money exists, it is valid, everywhere, and two IDs cannot be swapped by mistake.
Spring: a @WritingConverter/@ReadingConverter pair, registered in MongoCustomConversions, stores an ID as a plain string in the document. Spring MVC needs no converter to bind @PathVariable ClaimId: a record with one String field has a constructor Spring's conversion calls itself. Money is stored as a nested object with a Decimal128, exact.
Where does this code go? Stop at the first yes#
- Does it decide whether the entity may change, from its current state? Entity method (rule 1).
- Is it a calculation across several things, with worked examples? Domain service (rule 5).
- Does it load, save, call another microservice, start a transaction or write audit? Application service (rule 3).
- Does it check the format of one value? Value object (rule 6), or the factory method that creates the entity (rule 29).
- Does it check a code from another feature? That feature's application service (rule 14).
Part B. Aggregates#
7. Keep a child inside the aggregate only when the parent's rules need its details, it has a maximum count, it changes with the parent and it means nothing alone; otherwise give it its own aggregate#
You are deciding where evidence items and notes live.
Bad — everything a claim has, inside the claim document:
@Document("claims")
public class Claim {
private List<EvidenceItem> evidence = new ArrayList<>();
private List<Note> notes = new ArrayList<>(); // grows for years
private List<Payment> payments = new ArrayList<>(); // grows for years
}Two caseworkers add a note to the same claim at the same time. The whole claim is one document, so the second save fails on the version (rule 11). After three years the document holds 4,000 notes and the save fails on DocumentDB's 16 MiB document limit.
Good — ask the heading's four questions about each child. Four times yes: inside the document. Any no: its own collection, holding the parent's ID.
| Child | Rule needs details? | Maximum? | Changes with parent? | Meaningless alone? | Decision |
|---|---|---|---|---|---|
| Evidence item | Yes: closing needs every item verified | 10 | Yes | Yes | Inside the claim document |
| Note | No: closing needs only a count | None | No: two caseworkers at once | No | Own collection notes |
| Payment | No: only the latest | None | No: the payment microservice sends them | No | Own collection payments |
@Document("claims")
public class Claim {
private List<EvidenceItem> evidence = new ArrayList<>(); // inside: an array in the claim document
}
@Document("notes")
public class Note {
@Id private NoteId id;
@Version private long version;
@Indexed private ClaimId claimId; // points at the parent
private NoteText text;
private boolean needsAction;
}Inside one document a rule is always true, because the document is written atomically. Between collections a rule is true only at the moment it is checked, which is why close asks for the note count at that moment. Copying the old system's tables one to one as aggregates is the most common way to get this wrong.
Spring: a nested object or list in an entity is stored inside the same document; a separate @Document class is a separate collection. @Indexed on claimId names the index the migration creates.
8. Store another aggregate's ID, never the object itself#
A note belongs to a claim.
Bad:
@Document("notes")
public class Note {
@DocumentReference private Claim claim; // or @DBRef
}Loading a note now runs a second query for the claim and its evidence; a list of 20 notes runs 21 queries. @DBRef is worse: DocumentDB does not support DBRefs at all.
Good:
@Document("notes")
public class Note {
private final ClaimId claimId;
}When the note feature needs something about the claim, it asks ClaimService with the ID (rule 14).
Spring: @DocumentReference stores the ID but loads the object on read, one query per note; a plain ClaimId field stores the ID and loads nothing.
9. Save one aggregate per transaction#
A nightly job must mark 10,000 claims as "review due".
Bad:
@Transactional
public void markReviewDue(LocalDate today) {
for (Claim claim : claims.findByReviewDueOnBefore(today)) {
claim.markReviewDue(today);
claims.save(claim);
}
}One DocumentDB transaction has a one-minute limit and a 32 MB log; this one hits both, and if claim 9,999 fails nothing is saved.
Good — one save per claim, each atomic on its own, and skip claims already done:
public void markReviewDue(LocalDate today) {
for (ClaimId id : claims.findIdsByReviewDueOnBefore(today)) { // a query that returns only IDs (rule 30)
tx.executeWithoutResult(status -> markOne(id, today)); // one transaction per claim
}
}
private void markOne(ClaimId id, LocalDate today) {
Claim claim = claims.findById(id).orElseThrow(() -> new ClaimNotFound(id));
if (claim.isReviewDue()) return; // already done; safe to rerun
claim.markReviewDue(today);
claims.save(claim);
audit.record("review due", id);
}Saving two aggregates in one transaction needs an approved exception in the design note. Example: creating a claim together with its first note (rule 27).
Spring: a single-document save is atomic in DocumentDB without any transaction; the transaction here covers the outbox entry saved beside the claim. tx is Boot's TransactionTemplate bean. It starts a transaction in the middle of a method, which @Transactional cannot: a call from inside the same class never passes through the proxy that starts one.
10. Give every list inside an aggregate a maximum size#
A claim holds evidence items.
Bad:
public void addEvidence(FileName fileName, LocalDate today) {
evidence.add(new EvidenceItem(fileName, today));
}Nothing stops a claim with 600 items. Its page times out, and the document heads for DocumentDB's 16 MiB limit, in production, for one user.
Good — the design note says "at most 10", and the entity refuses the eleventh:
private static final int MAX_EVIDENCE = 10;
public EvidenceId addEvidence(FileName fileName, FileReference file, LocalDate today) {
if (status == CLOSED) throw new ClaimAlreadyClosed(id);
if (evidence.size() >= MAX_EVIDENCE) throw new TooMuchEvidence(id, MAX_EVIDENCE);
EvidenceItem item = EvidenceItem.create(fileName, file, today);
evidence.add(item);
events.add(new EvidenceAdded(id, item.id())); // the ID, not the file name (rule 26)
return item.id();
}A list that cannot have a maximum is not a child; it is its own collection (rule 7). The file itself is never in the document: the service stores it in the object store first, and the evidence item holds its reference (API guide, rule 34).
11. Refuse a save that was made from an old version of the record#
Two caseworkers open the same claim. One adds evidence and saves. The other, still looking at the old screen, closes the claim.
Bad — the second save wins:
Claim claim = claims.findById(id).orElseThrow();
claim.close(open, today);
mongo.save(claim); // no version field: overwrites the new evidenceThe first caseworker's evidence is gone and nobody is told.
Good — every change carries the version the user saw, the entity refuses an old one, and the document's @Version refuses it again in the database:
@Document("claims")
public class Claim {
@Version private long version;
public void requireVersion(long expected) {
if (version != expected) throw new VersionConflict(id, expected, version);
}
}requireVersion, step 2 of every change, gives the friendly error; @Version repeats the check in the database, because two saves in the same millisecond can both pass the entity's. The error handler maps both to 409, and the screen says "changed by someone else; reload". Never remove either check, or update one field with MongoTemplate to get around them.
Spring: @Version makes save send {_id, version: 7} as the filter and version: 8 in the update; no match means OptimisticLockingFailureException.
12. Make every change safe to repeat: a repeated request must not add or count anything twice#
The payment microservice sends PaymentMadeV1. The broker delivers it twice.
Bad:
public void recordPayment(Money amount) {
totalPaid = totalPaid.plus(amount); // twice is wrong
}The broker delivers the event twice and the total is doubled. The inbox (rule 21) stops the second delivery; this rule adds the entity's own guard, for the day a replay or a migration bypasses the inbox.
Good — the entity keeps what it already has, and ignores a repeat:
public void recordPayment(PaymentId paymentId, Money amount, LocalDate paidOn) {
if (lastPayment != null && !paidOn.isAfter(lastPayment.paidOn())) return; // older or same: ignore
lastPayment = new Payment(paymentId, amount, paidOn);
events.add(new PaymentRecorded(id, paymentId));
}A request is repeated after a timeout, because the caller cannot tell whether the first one landed. Three things together make a repeat harmless: the version check for changes from a screen (rule 11), an idempotency key for create requests (API guide, rule 4), and the inbox for messages (rule 21). The entity's own check is the fourth, and the only one that holds on every path.
13. Store money as an exact decimal and time in UTC, and count days in UK dates#
A claim can be reopened within 28 days of closing.
Bad:
private double amount; // 0.1 + 0.2 = 0.30000000000000004
private LocalDate closedOn; // Spring Data stores it as a Date at local midnight
Instant deadline = closedAt.plus(28 * 24, ChronoUnit.HOURS); // an hour out after the clocks changeA LocalDate of 1 June written from a machine on UK time is stored as 2026-05-31T23:00:00Z and reads back as 31 May on a server set to UTC.
Good:
public record Money(BigDecimal amount) { ... } // stored as Decimal128, exact (rule 6)
private Instant closedAt; // stored as a UTC date
@Field(targetType = FieldType.STRING) private LocalDate closedOn; // stored as "2026-06-01"
static final ZoneId UK = ZoneId.of("Europe/London");
LocalDate closedOn = closedAt.atZone(UK).toLocalDate();
LocalDate deadline = closedOn.plusDays(28); // a date, in UK timeFloating point gives penny errors. Local timestamps are an hour wrong twice a year. A rule that counts days works in dates, not hours.
Spring: on Boot 4 a BigDecimal is stored as Decimal128 by default (spring.data.mongodb.representation.big-decimal); @Field(targetType = FieldType.STRING) stores a LocalDate as an ISO string instead of a shifted Date; Instant is always UTC.
Part C. Features, microservices and events#
14. Use another feature only through its application service, passing IDs and simple values#
The claim feature needs to know how many notes need action.
Bad — reaching into the other feature's storage:
@Service
public class ClaimService {
private final NoteRepository noteRepository; // another feature's repository
...
long open = noteRepository.findByClaimId(id).stream().filter(Note::needsAction).count();
}When the notes team changes how notes are stored, the claim feature breaks. The "needs action" rule now lives in two features.
Good — ask the other feature's application service for the answer:
@Service
public class ClaimService {
private final NoteService noteService; // the other feature's front door
...
int open = noteService.countNeedingAction(id);
}Pass IDs, numbers and small records, never entities or repositories. Calls go one way: claims may call notes, so notes must not call claims; notes use events for the other direction (rule 19). A build check enforces this.
Spring: both are @Service beans injected by constructor; a repository bean is package-private to its feature, so the other feature cannot inject it.
15. Check permissions in the controller, never in the service or the entity#
Only a caseworker with CLOSE_CLAIM may close a claim.
Bad:
public class Claim {
public void close(Authentication user, int open, LocalDate today) {
if (!user.getAuthorities().contains("CASEWORKER")) throw new AccessDeniedException("no");
...
}
}A message listener also closes claims (when the payment microservice says the last payment was made). There is no user there, so someone passes a fake system user, and the check means nothing.
Good — the controller knows the user, so the controller checks:
@PostMapping("/v1/claims/{id}/close")
@PreAuthorize("hasAuthority('CLOSE_CLAIM')")
public ResponseEntity<Void> close(@PathVariable ClaimId id, @RequestBody @Valid CloseRequest body) {
claimService.close(id, body.version());
return ResponseEntity.noContent().build();
}
@WebMvcTest(ClaimController.class)
class ClaimControllerTest {
@Test @WithMockUser(authorities = "VIEW_CLAIM")
void refusesWithoutPermission() throws Exception { // csrf(): a MockMvc call carries no bearer token, so the CSRF filter applies
mvc.perform(post("/v1/claims/9f1c/close").with(csrf()).contentType(APPLICATION_JSON).content("{\"version\":7}"))
.andExpect(status().isForbidden());
}
}A test proves that a user without the permission gets 403. Application services and entities never see a user.
Spring: @PreAuthorize on the controller method, enabled by @EnableMethodSecurity; the authority comes from the JWT the gateway sends. @WebMvcTest with @WithMockUser tests the check without a server.
16. Talk to another system only through one adapter class that translates its data into our types#
Creating a claim needs the eligibility microservice's answer.
Bad — their data leaks into our entity:
Map<String, Object> response = restClient.get().uri("/eligibility/" + nino).retrieve().body(Map.class);
if ("ELIGIBLE".equals(response.get("status"))) { ... } // their word, inside our codeWhen the eligibility team renames status to outcome, we hunt through our whole codebase.
Good — one adapter class, returning our own type and our own errors:
@Component
public class EligibilityClient {
private final RestClient http; // built once, with time limits, in a @Configuration
public Eligibility check(ClaimantId claimantId) {
EligibilityResponse theirs;
try {
theirs = http.get().uri("/v1/eligibility/{id}", claimantId.value()).retrieve().body(EligibilityResponse.class);
} catch (ResourceAccessException e) { // no connection or no answer: our error, not theirs (rule 18)
throw new EligibilityUnavailable(claimantId);
}
return switch (theirs.status()) {
case "ELIGIBLE" -> Eligibility.ELIGIBLE;
case "NOT_ELIGIBLE" -> Eligibility.NOT_ELIGIBLE;
default -> throw new EligibilityUnknown(claimantId);
};
}
}Nothing from the other system appears outside its adapter. Nobody reads another microservice's database.
Spring: RestClient is Spring's HTTP client; EligibilityResponse is a record Spring deserialises from their JSON and that only this class sees. The adapter is tested against WireMock playing the eligibility microservice (rule 37).
17. Call another microservice before you open the transaction, never inside it#
Creating a claim asks eligibility, then saves.
Bad:
@Transactional
public ClaimId open(ClaimantId claimantId, ClaimType type) {
Eligibility eligibility = eligibilityClient.check(claimantId); // a network call, inside the transaction
Claim claim = Claim.open(claimantId, type, eligibility, LocalDate.now(clock));
claims.save(claim);
return claim.id();
}Eligibility takes four seconds today. For four seconds the transaction holds its session and its locks, and it now fails whenever the network does. Had the outside call made a change, a failed commit could not undo it.
Good — the call first, then the transaction:
public ClaimId open(ClaimantId claimantId, ClaimType type) {
Eligibility eligibility = eligibilityClient.check(claimantId); // outside
return tx.execute(status -> { // the transaction starts here (rule 9)
Claim claim = Claim.open(claimantId, type, eligibility, LocalDate.now(clock));
claims.save(claim);
audit.record("claim opened", claim.id());
return claim.id();
});
}Get every outside fact first, then open the transaction and save. The object store is another system too: addEvidence stores the file, then opens the transaction to save its reference (rule 10).
Spring: @Transactional would start at the method call, network call included; TransactionTemplate starts where the lambda does.
18. Retry a read; never retry a change#
The eligibility check timed out.
Bad — retry everything the same way:
@Retryable // on the adapter's change method
public void changeAddress(ClaimantId claimantId, Address newAddress) { ... }The first call reached the other system and changed the address; only the reply was lost. The retry changes it again, and the audit trail there now shows two changes we cannot undo.
Good — reads may retry; a change is sent once:
@Retryable(maxRetries = 2, includes = ResourceAccessException.class) // on the adapter's read method (rule 16): safe
public Eligibility check(ClaimantId claimantId) { ... }
try {
addressClient.changeAddress(claimantId, newAddress); // a change: once
} catch (ResourceAccessException e) {
if (e.getCause() instanceof ConnectException) throw new AddressNotChanged(claimantId); // never reached them
throw new AddressChangeUnknown(claimantId); // reached them, reply lost; tell the user to check
}Stop calling a system that keeps failing, for a short time (a circuit breaker). A business action that sends a change to another system makes exactly one call and saves nothing else of its own.
Spring: @Retryable, from Spring Framework 7 and turned on by @EnableResilientMethods, retries the method; maxRetries counts retries, not calls. RestClient throws ResourceAccessException when the connection fails or times out, and the cause says which. Spring has no circuit breaker of its own.
19. Save an event in the same transaction as the change it reports, and send it afterwards#
Closing a claim must tell the payment microservice.
Bad — publish from the service:
claims.save(claim);
broker.publish("claims", new ClaimClosedV1(...)); // the broker is down: saved, never sentOr the other way round: published first, then the save fails. Payments stop for a claim that is still open.
Good — the entity records the event; an outbox entry is stored in the same transaction; a scheduled sender publishes it (the Outbox class in the walkthrough):
// in Claim.close
events.add(new ClaimClosed(id, today));
// Claim: Spring Data publishes these when claims.save(claim) runs
@DomainEvents Collection<DomainEvent> events() { return List.copyOf(events); }
@AfterDomainEventPublication void clearEvents() { events.clear(); }
// Outbox: same thread, same transaction
@EventListener
public void store(DomainEvent event) { mongo.insert(OutboxEntry.of(event), "outbox"); }
// Outbox: after the commit, every second, retried until the broker takes it
@Scheduled(fixedDelay = 1000)
public void send() { ... }A saved change always sends its event, and an unsaved change never does. Nobody publishes any other way.
Spring: @DomainEvents and @AfterDomainEventPublication on the aggregate root, @EventListener on the outbox (synchronous, so inside the transaction), @Scheduled for the sender. Spring Data publishes the events only on a repository save, never on a MongoTemplate write. Only the outbox and a read collection (rule 31) listen to the entity's events directly; every other listener, ours included, gets them from the broker (rule 27).
20. Only add fields to a published event; to remove or rename one, publish a new version#
The payment team reads ClaimClosedV1.closedOn. You want to call it closureDate.
Bad:
public record ClaimClosedV1(String eventId, String claimId, LocalDate closureDate) {} // renamed in placeThe payment microservice's listener reads closedOn, gets null, and stops payments on the wrong date.
Good — add freely; for a rename, add a version and send both until every receiver has moved:
public record ClaimClosedV1(String eventId, String claimId, LocalDate closedOn, String closedByUserId) {} // a new field: fine
public record ClaimClosedV2(String eventId, String claimId, LocalDate closureDate, String closedByUserId) {} // sent beside V1Every integration event has a written schema. Tell the receiving teams before the release. A domain event, used only inside our microservice, can change at any time.
21. Write every message listener so a message that arrives late, twice or out of order does no harm#
The payment microservice sends PaymentMadeV1. The broker may deliver it twice, or deliver March's payment after April's.
Bad:
// called by the message listener for each PaymentMadeV1
public void on(PaymentMadeV1 event) {
Claim claim = claims.findById(new ClaimId(event.claimId())).orElseThrow();
claim.setLastPayment(event.amount(), event.paidOn()); // the last one to arrive wins
claims.save(claim);
}The second copy of March's payment is saved again, and when April's arrives first, March's overwrites it.
Good — an inbox skips repeats, and the entity ignores anything older than what it holds:
@Component
public class Inbox { // one per microservice
public boolean seen(String eventId) { return mongo.exists(query(where("_id").is(eventId)), "inbox"); }
public void record(String eventId) { mongo.insert(new InboxEntry(eventId, Instant.now(clock)), "inbox"); }
}
public record InboxEntry(@Id String eventId, Instant processedAt) {}
// called by the message listener for each PaymentMadeV1
@Transactional
public void on(PaymentMadeV1 event) {
if (inbox.seen(event.eventId())) return; // already processed: skip
inbox.record(event.eventId()); // _id is unique: a race loses here
claimService.recordPayment( // translate their strings into our types
new ClaimId(event.claimId()), new PaymentId(event.paymentId()),
new Money(event.amount()), event.paidOn());
} // the inbox entry and the change commit together
// in Claim.recordPayment (rule 12)
if (lastPayment != null && !paidOn.isAfter(lastPayment.paidOn())) return;The listener only translates the message and calls an application service. A message that fails repeatedly goes to a dead-letter queue, where someone finds the cause before it is re-sent. No message broker delivers exactly once and in order.
Spring: @Transactional makes the inbox entry and the claim change one commit. The inbox's @Id is the event ID: if two copies run at once, the second insert fails and aborts its whole transaction (any write error inside a MongoDB transaction does), the message comes back, and seen skips it. That is why the check is a find, not a caught DuplicateKeyException.
22. Change several microservices as a chain of steps joined by events, with a named step for failure#
Closing a claim must also stop its payments, which live in the payment microservice.
Bad — one transaction across two microservices:
@Transactional
public void close(ClaimId id, long version) {
...
claims.save(claim);
paymentClient.stopPayments(id); // another microservice, inside our transaction
}A transaction cannot cover two databases. If stopPayments succeeds and our commit fails, payments are stopped for an open claim.
Good — each step changes one microservice and sends an event that starts the next:
1. Claim microservice: Claim.close → saves → sends ClaimClosedV1
2. Payment microservice: on ClaimClosedV1 → stops payments → sends PaymentsStoppedV1
3. Claim microservice: on PaymentsStoppedV1 → Claim.markPaymentsStopped
If step 2 fails for good: payment microservice sends PaymentsStopFailedV1,
and we create a task for a caseworker.A failed step cannot be rolled back, so the design names what happens instead, and that is a new business action. Each such process needs design group approval and an architecture decision record (ADR). Do not use events to keep a rule true across two aggregates; that is a sign the two should be one (rule 7).
Part D. Process#
23. Get the design note approved before you code a feature with business rules#
You are about to build the notes feature.
Bad: start coding, and discover in review that notes should have been inside the claim, or the other way round. Moving children across an aggregate boundary means a data migration.
Good: a one-page design note, approved by the design group first:
Purpose: caseworkers record notes against a claim; a note can need action
Aggregates: Note (collection "notes", holds claimId); no maximum
States: OPEN -> ACTIONED (markActioned)
Rules: a claim cannot close while any note needs action
enforced in Claim.close, with the count passed in
example: claim C-1 has 2 notes needing action -> close refused
Events: NoteNeedsActionV1, NoteActionedV1; used by the claim feature
Indexes: notes.claimId; notes.(claimId, needsAction)
Talks to: nothing outside the microservice
Exceptions: none
Open questions: can a closed claim get a note?The design group decides what each aggregate contains, the rules with worked examples, and any transaction that saves two aggregates. Engineers decide endpoints, classes and tests. Which microservices exist is an ADR. A wrong aggregate boundary is the most expensive mistake to fix later.
24. Mark a feature Simple (data with no rules) or Rich (states and rules) in its design note#
You are adding a list of claim types that the business maintains.
Bad: a ClaimType aggregate with version checks, events and an outbox, for a list of 12 codes that changes twice a year.
Good: mark it Simple in the design note. A Simple feature is data with no business rules: lookup lists, user preferences. Its entity has fields and getters, and checks may sit in the application service.
@Document("claim_types")
public record ClaimType(@Id ClaimTypeCode code, String label, boolean active) {}
public interface ClaimTypeRepository extends MongoRepository<ClaimType, ClaimTypeCode> {}Claims, payments and awards are Rich: states and rules. Everything in this guide applies to them. The moment a Simple feature gains a rule ("a claim type cannot be deactivated while claims use it"), it becomes Rich and gets an entity method for that rule.
Spring: a record with @Document and @Id is enough for a lookup list; Spring Data uses the canonical constructor.
25. Fix a mistake with a safe step today and the proper fix by a date#
You find that ClaimService holds an if on status that should be in Claim.
Do the safe step now. Tell the design group the same day. Create a ticket with a date. Then do the proper fix and add a test, so the same mistake fails the build next time. Rules 14 and 20 are what make this possible: a feature can change inside without the others noticing.
| You find | Today | Proper fix | Never |
|---|---|---|---|
| Two aggregates share a rule that must always be true | Save both in one transaction; add the exception to the design note | Merge them; a migration moves the children inside the parent | Keep the rule true with events |
| An aggregate gives frequent conflicts or keeps growing | Keep the version check; make the screen say "reload" | Move the part that changes often into its own collection | Turn off the version check |
| A rule sits in a service, controller or listener | Ticket for this sprint | Move it into the entity method; add a test | Copy the if elsewhere |
| A feature uses another feature's repository | Record today's violations so only new ones fail the build | Add a method on the other feature's application service | Turn the build check off |
| A change sent no event | Add the missing event to the entity method | A one-off job sends the missed events, agreed with receivers | Let other teams read our database |
| Messages are in the dead-letter queue | Find the cause with the trace ID and fix it | Re-send them; the inbox skips duplicates | Delete them before you understand them |
| Our data and another microservice's disagree | Use both audit logs to find which side is right | A job that reports differences; fix through entity methods | A job that silently overwrites one side |
| Data was repaired by writing to the database | Find the affected records | Repair through entity methods or a reviewed migration | Repair by hand again |
26. Keep personal data out of logs, error messages and events#
A claim is refused because the National Insurance number is wrong.
Bad:
throw new IllegalArgumentException("Invalid NINO: " + nino); // lands in the logs
public record ClaimOpenedV1(String eventId, String claimId, String nino, String fullName) {}Logs and events are copied to monitoring tools and other teams' systems, which the data protection rules for our database do not cover.
Good:
public record Nino(String value) {
@Override public String toString() { return "Nino(****" + value.substring(5) + ")"; }
}
throw new InvalidNino(claimantId); // the ID, not the number
public record ClaimOpenedV1(String eventId, String claimId, String claimantId, LocalDate openedOn) {}Value objects hide personal data when printed. Events carry IDs, not personal details. If personal data reached production logs, report it through the data protection process.
Part E. Situations you will meet#
27. When one button must save two aggregates, first ask whether they are one; then react to an event; only then save both in one approved transaction#
The Check answers page of Create claim creates the claim with its first note, and every new claim needs a review task 14 days out.
Bad — two saves in a service method, with no decision made:
public ClaimId open(ClaimantId claimantId, ClaimType type, NoteText firstNote) {
Claim claim = Claim.open(claimantId, type, eligibility, LocalDate.now(clock));
claims.save(claim); // write 1, atomic on its own
Note note = Note.create(claim.id(), firstNote, LocalDate.now(clock));
noteRepository.save(note); // write 2; the process dies here: claim, no note
return claim.id();
}Half the user's work is saved and the screen shows an error.
Good — three answers, in this order. First, is the second thing really a child? If yes, it goes inside the document (rule 7) and there is one write. Second, can the second thing follow a moment later? The review task can: the claim's own event goes through the outbox, and a listener in our own microservice receives it from the broker like any other message (rule 21) and creates the task:
// in Claim.open
events.add(new ClaimOpened(id, today)); // stored in the outbox with the claim (rule 19)
// our own listener, fed by the broker: its own transaction, after the claim's commit
@Transactional
public void on(ClaimOpenedV1 event) {
if (inbox.seen(event.eventId())) return;
inbox.record(event.eventId());
reviewTaskService.open(new ClaimId(event.claimId()), event.openedOn().plusDays(14)); // the other feature's front door (rule 14)
}Third, when the user must see both at once and neither answer fits, save both in one DocumentDB transaction, with the exception written in the design note. The first note is this case:
// approved exception "create claim with first note" in the design note
public ClaimId open(ClaimantId claimantId, ClaimType type, NoteText firstNote) {
Eligibility eligibility = eligibilityClient.check(claimantId); // before the transaction (rule 17)
return tx.execute(status -> {
Claim claim = Claim.open(claimantId, type, eligibility, LocalDate.now(clock));
claims.save(claim);
noteRepository.save(Note.create(claim.id(), firstNote, LocalDate.now(clock))); // both collections, one commit
audit.record("claim opened with first note", claim.id());
return claim.id();
});
}Any of the three answers prevents the half-saved claim; the order is our choice. The boundary question comes first because it is the only answer with no delay and no exception. The event is the normal answer. The transaction comes last because each one is an exception to rule 9 that the design note must carry; it is allowed only because both collections are in our database, and never across microservices (rule 22).
Spring: the listener is a message listener like rule 21's. Not @TransactionalEventListener(AFTER_COMMIT): a crash between the commit and such a listener loses the event, and the outbox never does. DocumentDB runs a multi-document transaction on engine 4.0 and later, through the MongoTransactionManager bean.
28. Keep a rule that spans all records of a kind true with a unique database index, and turn the index error into a business error#
The rule: a claimant has at most one open claim.
Bad — check, then save:
if (claims.existsByClaimantIdAndStatus(claimantId, OPEN)) throw new ClaimantAlreadyHasOpenClaim(claimantId);
claims.save(Claim.open(claimantId, ...)); // two caseworkers do this at the same secondBoth checks pass, both saves succeed, and the claimant has two open claims.
Good — the database holds the rule; the service translates its error:
@Document("claims")
@CompoundIndex(name = "one_open_claim_per_claimant", def = "{'claimantId': 1}", unique = true,
partialFilter = "{'status': 'OPEN'}")
public class Claim { ... }// in ApiErrors, the walkthrough's error handler
@ExceptionHandler(DuplicateKeyException.class)
ProblemDetail duplicate(DuplicateKeyException e) {
if (e.getMessage().contains("one_open_claim_per_claimant"))
return problem(HttpStatus.UNPROCESSABLE_CONTENT, "Business rule violated", "CLAIMANT_HAS_OPEN_CLAIM", "...");
return bug(e); // any other unique index is a bug: 500 (API guide, rule 26)
}An aggregate guarantees only rules about itself; a rule about all claims is the database's, and the index is the one check two users cannot race past. Keep the exists check for a friendly error in the common case. The error is translated at the edge, because a write error aborts the DocumentDB transaction and the service has nothing left to do.
Spring: @CompoundIndex with partialFilter describes a partial unique index, which DocumentDB supports on engine 5.0 and later; the migration creates it (automatic index creation stays off). A violation surfaces as Spring's DuplicateKeyException, which the shared @RestControllerAdvice maps.
29. Create an entity through a named factory method that checks the creation rules and raises the event; keep the constructor private#
You are writing the code that creates a claim.
Bad:
Claim claim = new Claim();
claim.setClaimantId(claimantId);
claim.setStatus(OPEN);
claims.save(claim);Nothing checks eligibility or the claim type, and no ClaimOpened event is sent.
Good:
@Document("claims")
public class Claim {
@PersistenceCreator
private Claim(ClaimId id, ClaimantId claimantId, ClaimTypeCode type, LocalDate openedOn) { ... }
public static Claim open(ClaimantId claimantId, ClaimType type, Eligibility eligibility, LocalDate today) {
if (eligibility != ELIGIBLE) throw new ClaimantNotEligible(claimantId);
if (!type.active()) throw new ClaimTypeNotActive(type.code());
Claim claim = new Claim(ClaimId.next(), claimantId, type.code(), today); // the type's code, not the type (rule 8)
claim.events.add(new ClaimOpened(claim.id, today));
return claim;
}
}Creation is a business action like any other, so it has a business name and its rules live in one place. The only new Claim(...) is inside Claim and inside Spring Data when it loads a document. We make our own IDs (ClaimId.next()), so the document's _id is never left to the database.
Spring: @PersistenceCreator marks the constructor Spring Data uses when reading a document, so it can stay private and skip the creation rules; the public factory keeps them.
30. Read for a screen through a query class that reads the collections directly; never build a screen out of aggregates#
The claim list shows the claim ID, status, date opened, number of open notes and last payment date, for 50 claims a page.
Bad — load aggregates and pick fields out:
List<ClaimRow> rows = new ArrayList<>();
for (Claim claim : claims.findByStatus(OPEN, page)) { // 50 full documents with evidence
int open = noteService.countNeedingAction(claim.id()); // 50 more queries
rows.add(new ClaimRow(claim.id(), claim.status(), open, claim.lastPayment()));
}Fifty-one database round trips for one page, and the aggregate code now has getters that exist only for screens.
Good — a query class, two queries, a flat record:
@Component
public class ClaimListQuery {
public List<ClaimRow> openClaims(int page) {
Query q = query(where("status").is("OPEN")).with(Sort.by(DESC, "openedOn")).skip(page * 50L).limit(50);
q.fields().include("status", "openedOn", "lastPayment.paidOn");
List<ClaimRow> rows = mongo.find(q, ClaimRow.class, "claims"); // query 1: the page
Map<String, Integer> openNotes = mongo.aggregate(newAggregation( // query 2: counts for those 50
match(where("claimId").in(ids(rows)).and("needsAction").is(true)),
group("claimId").count().as("open")),
"notes", OpenNoteCount.class).getMappedResults().stream()
.collect(toMap(OpenNoteCount::claimId, OpenNoteCount::open));
return rows.stream().map(r -> r.withOpenNotes(openNotes.getOrDefault(r.claimId(), 0))).toList();
}
}
public record ClaimRow(@Id String claimId, String status, LocalDate openedOn, Payment lastPayment, int openNotes) { // @Id: filled from _id
public record Payment(LocalDate paidOn) {}
}
public record OpenNoteCount(@Id String claimId, int open) {} // $group puts its key in _idAggregates and repositories exist to change data safely. Reads have no rules to protect, so they go straight to the collections, read across aggregates freely, and return exactly what the screen needs. A query class never changes anything, and a rule never lives in a query.
Spring: MongoTemplate.find with fields().include(...) reads only the fields the row needs into a record that is not the entity; mongo.aggregate runs a $match and $group pipeline. A list query may use secondaryPreferred reads; an application service never does, because DocumentDB replicas are eventually consistent.
31. Search and reports are queries too; give a slow one its own read collection filled by events#
The business wants a report of claims closed per month with total payments.
Bad — compute it from aggregates at request time:
for (Claim claim : claims.findAll()) { ... } // 2 million documentsThe report reads two million documents on every request and takes minutes.
Good — a query over the collections (rule 30). When that is too slow, keep a separate read collection that the domain events fill:
@EventListener
public void on(ClaimClosed e) {
mongo.upsert(query(where("month").is(YearMonth.from(e.closedOn()).toString())),
new Update().inc("closedCount", 1), "monthly_closure_report");
}mongo.find(new Query().with(Sort.by("month")), MonthlyClosure.class, "monthly_closure_report");The read collection is the second step, taken only when the query has been measured too slow, because it is one more thing to keep right. It is a copy, built from events, and can be rebuilt by replaying the outbox. Nothing is ever changed through it.
Spring: MongoTemplate.upsert with Update.inc adds to the month's counter in one atomic write.
32. Do not store on a parent what a child aggregate knows; ask for it when reading, or copy it through an event and accept a short delay#
The claim page shows "has notes needing action".
Bad — a flag on the claim, set by the note feature:
// in NoteService.markNeedsAction
Claim claim = claims.findById(note.claimId()).orElseThrow();
claim.setHasOpenNotes(true); // another feature changes our aggregate
claims.save(claim);Two aggregates are saved in one action, the flag drifts when a note is actioned on another path, and the note feature now depends on the claim's internals.
Good — the normal answer: ask when reading (rule 30, query 2). When the flag must be on the claim itself, for a rule or for speed, copy it through an event:
// our own listener for NoteNeedsActionV1, from the broker, with the inbox (rules 21 and 27)
@Transactional
public void on(NoteNeedsActionV1 event) {
if (inbox.seen(event.eventId())) return;
inbox.record(event.eventId());
ClaimId claimId = new ClaimId(event.claimId());
Claim claim = claims.findById(claimId).orElseThrow(() -> new ClaimNotFound(claimId));
claim.noteNeedsAction(new NoteId(event.noteId())); // a method, not a setter; records the note ID
claims.save(claim);
}The copy is a moment behind, and it bumps the claim's version, so a caseworker with the claim open must reload; the design note says both. For the real rule, close still asks for the count at the moment of closing (rule 1), because a copy is never the truth.
33. Never delete a business record; add a business action that marks it, with a reason#
A caseworker created a claim for the wrong claimant.
Bad:
claims.deleteById(claimId);The audit trail, the notes that point at the claim and the events already sent now point at nothing, and the payment microservice may still act on it.
Good:
public void cancel(CancellationReason reason, LocalDate today) {
if (status == CLOSED) throw new ClaimAlreadyClosed(id);
status = CANCELLED;
cancelledOn = today;
events.add(new ClaimCancelled(id, reason, today));
}Lists hide cancelled claims by default. A deletion required by the data protection rules is a separate, approved process that removes personal data and keeps the document's skeleton. A build check fails on any call to deleteById, delete or remove outside that process.
34. Run a rule that depends on time from a scheduled job that calls the same entity method, one record per transaction#
A claim with no activity for 90 days is closed automatically.
Bad — a job that updates the collection:
mongo.updateMulti(query(where("lastActivityOn").lt(cutoff)), update("status", "CLOSED"), "claims");No rule check, no event, no audit, no version bump; the payment microservice never hears about it.
Good:
@Scheduled(cron = "0 0 2 * * *") // 02:00 every day
public void closeInactiveClaims() {
LocalDate today = LocalDate.now(clock);
for (ClaimId id : claims.findIdsInactiveSince(today.minusDays(90))) {
tx.executeWithoutResult(status -> closeOne(id, today)); // one transaction each (rule 9)
}
}
private void closeOne(ClaimId id, LocalDate today) {
Claim claim = claims.findById(id).orElseThrow(() -> new ClaimNotFound(id));
int open = noteService.countNeedingAction(id);
claim.closeForInactivity(open, today); // the entity decides; refuses if notes are open
claims.save(claim);
audit.record("claim closed for inactivity", id);
}The job is just another caller of the entity, like the controller and the message listener. The time comes from the Clock bean, so a test can set today to any date.
Spring: @Scheduled(cron = ...) with @EnableScheduling. On more than one instance, the job first takes a lock document (findAndModify on a job_locks collection, with an expiry); an instance that finds it taken skips the run.
35. When a process has its own steps and deadlines, give it its own aggregate#
Closing a claim with payments takes three steps across two microservices (rule 22), and the business wants to see which step each claim is at, and chase one that is stuck for two days.
Bad — flags on the claim:
private boolean closeRequested, paymentsStopped, paymentsStopFailed, letterSent;The claim's methods fill up with process ifs, and nobody can see the whole process in one place.
Good — a ClaimClosure aggregate, its own collection, that holds the state of the process and reacts to the events:
@Document("claim_closures")
public class ClaimClosure {
@Id private ClaimId claimId; // one closure per claim
@Version private long version;
private ClosureStep step; // REQUESTED, PAYMENTS_STOPPED, LETTER_SENT, DONE, STUCK
@Field(targetType = FieldType.STRING) private LocalDate stepStartedOn; // a UK date (rule 13)
@Transient private final List<DomainEvent> events = new ArrayList<>(); // and @DomainEvents, as in Claim
public void paymentsStopped(LocalDate today) {
if (step != REQUESTED) return; // late or repeated event: ignore (rule 21)
step = PAYMENTS_STOPPED;
stepStartedOn = today;
events.add(new ClosureLetterRequested(claimId));
}
public void checkDeadline(LocalDate today) { // called by a daily job (rule 34)
if (step == DONE || step == STUCK) return;
if (today.isAfter(stepStartedOn.plusDays(2))) {
events.add(new ClosureStuck(claimId, step)); // the step it was stuck at
step = STUCK;
}
}
}The claim keeps its own rules; the process keeps its steps. This is the "directing microservice" of the API guide, at the scale of one aggregate.
36. Change an aggregate's shape behind a flag, with a migration that uses the entity's own methods#
Notes were inside the claim; the design group decided they are their own aggregate (rule 7).
Bad — a script on release night:
db.claims.find().forEach(c => c.notes.forEach(n => db.notes.insertOne({ ...n, claimId: c._id })));Half the claims are migrated when the script fails, the old code reads the rest, and the two sets drift.
Good:
- Release 1: the new
Notedocument and repository exist; new notes go tonotes; reads check both places behind a feature flag. - A migration job, one claim per transaction (rule 9), creates the
Noteaggregates throughNote.createand marks the claim as migrated; it can be rerun. - Release 2: the flag is on for everyone; the old array is read by nobody.
- Release 3: a final job unsets the old array (
Update.unset("notes")), and the flag is removed.
The migration goes through the entity's methods so that every rule and event still holds. Rule 25's "proper fix" column is this list.
Spring: a field that no longer exists in the class is simply ignored when a document is read, so release 2 can ship before the array is removed. Create any new index in the migration, not with auto-index-creation.
37. Test an entity with no database, a service with a fake repository, and the repository alone against a real database#
You are writing the tests for "close claim".
Bad — every test starts the whole microservice and a database:
@SpringBootTest
void closeRefusedWhenNotesOpen() {
mongo.insert(claimDocument); mongo.insert(noteDocument); // 40 lines of setup
mvc.perform(post("/v1/claims/9f1c/close")...);
... status().isUnprocessableContent()
}Ten seconds per test, and the rule being tested is buried under HTTP and documents.
Good — three layers, each tested for what it owns:
// the entity: the rule, in memory, in milliseconds; plain JUnit, no Spring
@Test void closeRefusedWhenNotesOpen() {
Claim claim = Claim.open(claimantId, ClaimTypes.standard(), ELIGIBLE, date("2026-10-01"));
assertThrows(ClaimHasOpenActions.class, () -> claim.close(2, date("2026-10-31")));
}
// the service: the steps, with a hand-written fake repository, a mock of the other feature and a fixed Clock; no Spring
@Test void closeSavesAndAudits() {
ClaimService service = new ClaimService(fakeClaims, mock(NoteService.class), fakeAudit, Clock.fixed(...));
service.close(claimId, 7);
assertTrue(fakeClaims.saved(claimId));
assertEquals("claim closed", fakeAudit.last());
}
// the repository: the mapping, against a real MongoDB in a container; one test class
@DataMongoTest @Testcontainers
class ClaimRepositoryTest {
@Container @ServiceConnection static MongoDBContainer mongo = new MongoDBContainer("mongo:8.0").withReplicaSet();
@Test void saveAndLoadRoundTrip() { ... }
}
// the adapter: the translation, against WireMock playing the other microservice; one test class per adapter
@WireMockTest
class EligibilityClientTest {
@Test void translatesTheirAnswer(WireMockRuntimeInfo wm) {
stubFor(get("/v1/eligibility/c88").willReturn(okJson("{\"status\":\"NOT_ELIGIBLE\"}")));
assertEquals(Eligibility.NOT_ELIGIBLE, client(wm).check(new ClaimantId("c88"))); // client(wm): the adapter over WireMock's URL
}
@Test void timeoutBecomesOurError(WireMockRuntimeInfo wm) {
stubFor(get("/v1/eligibility/c88").willReturn(ok().withFixedDelay(3000)));
assertThrows(EligibilityUnavailable.class, () -> client(wm).check(new ClaimantId("c88")));
}
}Most tests are entity tests, because that is where the rules are. One @WebMvcTest per endpoint proves the permission check (rule 15). The adapter tests are the only place the other system's JSON appears. The outbox sender, each message listener and the object-store adapter are tested against Floci, which plays the AWS services locally (like LocalStack): the test publishes a PaymentMadeV1 to the local queue and asserts on the claim. DocumentDB has no local edition, so the repository test runs against MongoDB; a smoke test against a real cluster runs in the pipeline.
Spring: @DataMongoTest loads only the Mongo slice; @ServiceConnection on the Testcontainers container sets the connection for it; withReplicaSet() is needed for transactions. @WireMockTest starts a WireMock server for the adapter test and hands its URL to the test method. Mockito's mock comes with spring-boot-starter-test.
38. Show two aggregates on one screen and save them with two buttons; one button that saves two things is a design smell to raise#
The claim page shows the claim and lets the caseworker edit contact details and add a note.
Bad — one Save button that sends both:
@Transactional
public void saveClaimPage(ClaimId id, long version, ContactDetails contact, NoteText noteText) {
Claim claim = claims.findById(id).orElseThrow(() -> new ClaimNotFound(id));
claim.requireVersion(version);
claim.changeContact(contact);
claims.save(claim);
noteRepository.save(Note.create(id, noteText, LocalDate.now(clock))); // two aggregates, one button (rule 27)
}One button is one request that saves two aggregates, so every claim page becomes an exception to rule 9 that nobody approved, and the note has no version of its own.
Good — each part of the screen saves its own aggregate with its own button and its own version:
[Contact details] Save contact details -> ClaimService.changeContact(claimId, version, contact)
[Add a note] Add note -> NoteService.add(claimId, noteText)When a screen keeps needing one button for two aggregates, say so to the design group: either the boundary is wrong (rule 7) or the action deserves a name of its own (rule 27).
Who to ask#
Ask a senior engineer the same day when you do not know where code belongs. Ask the design group, before you code, for a new or changed aggregate, a two-aggregate transaction or a new link between microservices. Tell the receiving teams before you change a published event. Report a security or personal data problem through the incident process at once.
Still to fill in: design group members, team channel, design session time, incident process link.
API Design Developer Guide#
This is a team guide for designing the HTTP endpoints of microservices, and for calling them from an Express front end that follows the GOV.UK Design System: 45 rules in the same shape as the DDD guide. It uses the same claim service as the Pragmatic DDD Guide. You can download it as a PDF, as Markdown, or as a page to paste into Confluence.
Download this guide: PDF · Markdown (.md) · Confluence page (.html). Short version for the team, the same rules in five pages with no code: PDF · Markdown (.md) · Confluence page (.html). To use a Confluence page, open it in a browser, press its Copy button, and paste into the Confluence editor.
Oct 31, 2026 · @kodebot
This guide says how we design the HTTP endpoints of our microservices, and how our Express front end calls them. The microservice examples are Spring Boot 4 (Spring MVC and Spring Security 7), with the Spring feature named under each one. Each rule starts with a situation you will meet, shows the code that goes wrong, says what happens, and shows the code that works. The examples use the claim microservice, where a caseworker creates a claim for a claimant, adds evidence and notes, and closes the claim. The eligibility microservice says whether a person may claim. The payment microservice pays claimants. Express is the only thing the browser talks to.
Your first two days. Read the walkthrough and Parts A and B (rules 1 to 15) on your first afternoon, after the Pragmatic DDD Guide's Parts A and B; Parts C, D and E on day 2. Part F is not for reading through: it is the answers for the situations you will meet, one at a time, when you meet them.
The rules#
One line per rule. The section with the same number has the situation, the requests and the reasons.
Part A. Endpoints that change data
- A new record is a create, a named business step is an action, anything else saves a group of details.
- Send one request per save; the microservice saves all of it or nothing.
- Save a group of details with
PUTand send every field in the group; never usePATCH. - Send an
Idempotency-Keyheader with every create, so a repeat cannot create a second record. - Send the record's
versionwith every change; the microservice refuses an old version with409. - Never retry a change automatically; retry a read once.
- Return
201with aLocationheader for a create, and204with no body for every other save. - A change that goes to another system is one business action, and it calls that system once.
- Translate the other system's answer into our own status codes.
Part B. Endpoints that read data
- Build read endpoints around records, not around screens.
- Each kind of record has two response formats only: a summary for lists and a detail for one record.
- Never make one call per row of a list.
- Page, sort and index every list.
- Return a lookup list as codes and labels, cached for 5 minutes.
- Check permission on every endpoint, reads included, even when the caller is our own Express.
Part C. Processes across several microservices
- A process across microservices has one owning microservice, and Express calls only that one.
- Change data in another microservice by sending an event, never by calling its API.
- Save each step's change and its event in one transaction, and skip an event you already processed.
- Decide what happens when a step fails before you build the process.
- Link the steps by events each microservice reacts to; a directing microservice only for many steps or deadlines.
- Return
204and a progress field when the user needs only the first step;202with a status URL when they must wait for all steps.
Part D. Conventions
GETreads,POSTcreates or acts,PUTsaves a group; neverPATCH, and noDELETEon business records.- Name collections with plural nouns and actions with lower-case verbs; nest one level at most.
- Return a JSON object with camelCase fields, ISO dates, UTC times and two-decimal money.
- Never put personal data in a URL; search by personal details with
POST /claims/search. - Return the same status codes and the same error body from every microservice.
- Make a breaking API change in three releases, and update
openapi.yamlin the same pull request as the code.
Part E. The Express front end
- The browser never calls a microservice; Express calls it with the user's token.
- Read from several microservices in parallel, but send each save to one microservice.
- Give every call from Express a time limit and a circuit breaker.
- Show the page with the data that loaded; show an error page only when essential data failed.
- Keep answers in the Express session until the save button, then save once and redirect.
- Cache lookup lists only; never cache business records.
Part F. Situations you will meet
- Upload a file as multipart, store the bytes in an object store, keep only a reference on the record, and stream downloads through Express.
- Return field errors as
400with anerrorslist of field, code and message; keep business-rule refusals as422. - A bulk action is one request per record; add a batch endpoint only when the business names the batch as one action, with a result per item.
- Store a "save and return later" draft as a record of its own, with no business rules until the final create.
- On
409, show the user the fresh record and a way to start again; never retry with the new version. - A microservice with no user present calls another with its own client-credentials token and permission, never a shared key or a stored user token.
- An outside callback gets its own endpoint, which checks the signature, stores it with its ID, answers
202and raises our own event; no business logic there. - Export CSV from
GET .../exportwith the list's filters, a row limit and the personal-data permission; a large export returns202and a status URL. - Show another microservice's data by reading it in Express; store a copy only when a rule needs it.
- Remove a record created by mistake with a
cancelaction and a reason; keep it, hide it from lists by default, still return it onGET. - When a system the save depends on is down, answer
503withRetry-After; Express says "try again later" and does not retry the change. - Make
openapi.yamlthe contract: the microservice validates every response against it, and Express's client tests run against its examples.
Terms#
| Term | Meaning |
|---|---|
| Express | Our front-end server. It builds the GOV.UK pages on the server and calls the microservices |
| Gateway | The server in front of Express. It signs the user in and keeps the session |
| Record | One thing a microservice stores, such as a claim or a note |
| Version | A number on every record that goes up by one on every save. Every response with a record includes it |
| Group of details | Fields the screen shows and saves together, such as contact method, phone and email |
| Business action | A change the business names as its own step, such as "close claim" |
| Idempotency key | A random ID sent with a create. A repeat with the same key returns the first result instead of a second record |
| Access token | The gateway's signed token (a JWT) for the user; its permissions claim lists what they may do |
| traceparent | The header that carries the trace ID through every call, so one action can be followed through every log |
| Owner | The one microservice that starts a process across microservices and records its progress |
| Design group | The lead engineer, senior engineers and business experts, who approve design decisions |
Closing a claim, end to end#
A caseworker opens claim 9f1c and presses Close claim. This is what happens, in order. Later rules point back at these six requests.
- When Express built the claim page, it read the claim with the user's token and put the
versionin a hidden field of the form.
GET /v1/claims/9f1c
Authorization: Bearer eyJ...
traceparent: 00-4bf92f35...
200 OK
{ "claimId": "9f1c", "version": 7, "status": "OPEN", "claimType": "STANDARD",
"createdAt": "2026-10-01T09:15:00Z", "paymentsStopped": false }- The browser posts the form to Express. The browser only ever talks to Express, and only carries the gateway's session cookie.
POST /claims/9f1c/close HTTP/1.1
Host: express.example.gov.uk
Cookie: session=...
version=7- Express sends the business action to the claim microservice, with the version the user saw. It does not read the claim again: a fresh version would defeat the check.
POST /v1/claims/9f1c/close
Authorization: Bearer eyJ...
traceparent: 00-4bf92f35...
Content-Type: application/json
{ "version": 7 }- The claim microservice checks permission, loads the claim, checks the version, applies the rules, saves the claim and a
ClaimClosedV1event in one transaction, and answers with no body.
204 No ContentIf a rule refuses, the answer is an error body with a code Express knows:
422 Unprocessable Content
Content-Type: application/problem+json
{ "status": 422, "title": "Business rule violated", "code": "CLAIM_HAS_OPEN_ACTIONS",
"detail": "Claim 9f1c has 2 notes that need action", "traceId": "4bf92f35..." }- Express redirects the browser to a confirmation page. A refresh of that page cannot close the claim twice.
303 See Other
Location: /claims/9f1c/closed- Later, the payment microservice receives
ClaimClosedV1from the message broker, stops the payments and sendsPaymentsStoppedV1. The claim microservice receives that and setspaymentsStopped: true. Express never calls the payment microservice for this.
Every call from Express to a microservice carries the Authorization: Bearer and traceparent headers of step 1, and every microservice path starts with /v1. The rest of this guide leaves all three out, so each example shows only its point.
Part A. Endpoints that change data#
1. Pick the endpoint by what the user saves: a new record is a create, a named business step is an action, anything else saves a group of details#
You are building the Change contact details pages, which end with a Check answers page, and the Close claim button.
Bad — one endpoint shaped like the screens:
PUT /claims/9f1c
{ "version": 7, "status": "CLOSED", "contactMethod": "EMAIL", "emailAddress": "jane@example.com",
"phoneNumber": "", "claimType": "STANDARD", "fullName": "Jane Doe", ... }Any screen can now set status to CLOSED and skip the closing rules, and every change to a screen changes the request body.
Good — the endpoint follows what the user saves. Stop at the first yes:
- Does the save create a new record? Create:
POST /claims. - Is it a business action? Yes if the business names it as a step (close, verify evidence, change address), if it needs more than new values (an effective date, a reason), or if it sets a field only a rule may set (
status,closedAt). Business action:POST /claims/9f1c/close. - Otherwise it changes a group of details. Save details:
PUT /claims/9f1c/contact-preferences.
POST /claims/9f1c/close
{ "version": 7 }
PUT /claims/9f1c/contact-preferences
{ "version": 7, "contactMethod": "EMAIL", "phoneNumber": "", "emailAddress": "jane@example.com" }If you are not sure about step 2, ask the business. A complex record has 10 to 20 such endpoints.
Why: the business decides the endpoints, so the API stays stable while screens change. Also bad: POST /claims/9f1c/update-phone for one question page; the save is the group, not the page.
2. Send one request per save; the microservice saves all of it or nothing#
You are building Create claim: five question pages, then a Check answers page that creates the claim with its first note.
Bad — Express sends one request per thing created:
const { claimId } = await claims.post('/claims', { claimantId, claimType });
await claims.post(`/claims/${claimId}/notes`, { text, category, needsAction: true });The second call times out. The claim exists without its note, Express cannot undo the first call, and the user sees an error for work that was half saved.
Good — one request with everything the user collected; the microservice saves it in one database transaction (an approved exception in its design note: Pragmatic DDD Guide, rule 27):
POST /claims
Idempotency-Key: 7d3e...
{ "claimantId": "c88", "claimType": "STANDARD",
"notes": [ { "text": "Call back Monday", "category": "CONTACT", "needsAction": true } ] }
201 Created
Location: /claims/9f1c
{ "claimId": "9f1c", "noteIds": ["4ab2"] }POST /claims accepts at most 10 notes, so the transaction stays small. Two things the user does at different times are two requests.
3. Save a group of details with PUT and send every field in the group; never use PATCH#
You are saving the contact preferences group: contact method, phone number, email address.
Bad — Express sends only the field the user typed:
PATCH /claims/9f1c
{ "emailAddress": "jane@example.com" }
200 OKExpress must work out which fields changed, so it keeps the old values in the form or the session and compares. An empty phone field now means either "cleared" or "not sent", and each microservice decides that for itself. And because the request patches the whole claim, a later screen patches status and closes a claim without the rules (rule 1).
Good — every field in the group, changed or not, with the record's version:
PUT /claims/9f1c/contact-preferences
{ "version": 7, "contactMethod": "EMAIL", "phoneNumber": "", "emailAddress": "jane@example.com" }
204 No ContentThe microservice compares each field with the stored value, checks the rules, saves all changes together, audits the changed field names (not the values) and publishes one event listing the changed fields. Each field belongs to one group, and a group never holds a field that only a business action may change.
Why: this is a convention. The version check (rule 5) stops a lost update either way. Sending the whole group gives one way to save, "empty means cleared" everywhere, rules checked on the whole group, and no comparing in Express: a server-rendered form posts every field anyway. Once chosen, no microservice accepts a partial body.
4. Send an Idempotency-Key header with every create, so a repeat cannot create a second record#
You are building the Check answers page that calls POST /claims.
Bad — a create with no key:
POST /claims
{ "claimantId": "c88", "claimType": "STANDARD" }The claim is saved, but the response is lost in a timeout. Express shows an error, the user presses the button again, and claimant c88 has two claims.
Good — Express makes a random ID when it builds the page, keeps it in a hidden field, and sends it every time that page is submitted:
<input type="hidden" name="idempotencyKey" value="7d3e...">POST /claims
Idempotency-Key: 7d3e...
{ "claimantId": "c88", "claimType": "STANDARD" }
201 Created
Location: /claims/9f1c
{ "claimId": "9f1c" }The repeat with the same key returns the same 201 and the same claimId. Adding an item stored inside a record (POST /claims/9f1c/evidence) is a change to that record, so it sends the version instead.
Why: a request can time out after the record was saved, and only the key lets the microservice recognise the repeat. A new key generated on every submit is the same as no key.
5. Send the record's version with every change; the microservice refuses an old version with 409#
Two caseworkers open claim 9f1c at version 7. One closes it. The other, a minute later, saves new contact details.
Bad — the second save carries no version:
PUT /claims/9f1c/contact-preferences
{ "contactMethod": "EMAIL", "phoneNumber": "", "emailAddress": "jane@example.com" }
204 No ContentThe microservice cannot know this user saw an old screen. Whatever the second user saw wins, and the first user's change is silently lost.
Good — every request that changes an existing record carries the version Express got when it loaded the record:
PUT /claims/9f1c/contact-preferences
{ "version": 7, "contactMethod": "EMAIL", "phoneNumber": "", "emailAddress": "jane@example.com" }
409 Conflict
Content-Type: application/problem+json
{ "status": 409, "title": "Record changed", "code": "CONCURRENT_UPDATE",
"detail": "Claim 9f1c is at version 8; the request was made from version 7", "traceId": "4bf92f35..." }Express shows the 409 page (rule 38), with the latest version, and the user makes the change again. A repeated request also gets 409, so a change is never made twice.
Why: the version is the only proof that the user saw the current record. "Last save wins" is a lost change waiting to happen.
6. Never retry a change automatically; retry a read once#
You are writing the shared HTTP client Express uses for every microservice call.
Bad — one retry rule for everything:
async function call(req) {
try { return await send(req); }
catch (e) { return await send(req); } // retry everything once
}POST /customers/42/change-address times out after the other system saved it. The retry sends it again. For a system without a version check, the address is changed twice, or the second attempt fails on a record that already moved on.
Good — the method decides:
async function call(req) {
try { return await send(req); }
catch (e) {
const isRead = req.method === 'GET' || req.path.endsWith('/search');
if (isRead && (e.timeout || e.status === 503)) return await send(req);
throw e; // a change is never retried
}
}If a change fails or times out, Express shows the error and the user checks the record before submitting again. A read (GET, or POST /claims/search) is retried once on a timeout or 503.
Why: a repeated change may be made twice, and a repeated read is harmless. Against our own microservices a repeat would be refused by the version check or answered by the idempotency key, so this is also a convention: one behaviour in the shared client, whatever the target.
7. Return 201 with a Location header for a create, and 204 with no body for every other save#
You are writing the response of PUT /claims/9f1c/contact-preferences.
Bad — the whole claim comes back after a save:
PUT /claims/9f1c/contact-preferences
{ "version": 7, ... }
200 OK
{ "claimId": "9f1c", "version": 8, "status": "OPEN", "contactMethod": "EMAIL", ...all fields... }Every save now has its own response shape, and Express has one more place to parse a claim, which drifts from GET /claims/9f1c.
Good — a create returns 201 Created, a Location header with the new record's URL, and the new IDs; every other save returns 204 No Content with an empty body:
POST /claims
201 Created
Location: /claims/9f1c
{ "claimId": "9f1c", "noteIds": ["4ab2"] }
PUT /claims/9f1c/contact-preferences
204 No ContentIf Express needs the record afterwards, it reads it with GET /claims/9f1c.
Why: a convention. After a save Express redirects, and the next page reads the record again (rule 32), so a body in the save response would be parsed in a second place and read by nobody. Once chosen, no save returns a body.
8. When a change goes to another system, make one business action per change and call that system once#
The claimant's name and address live in the central customer system, not in our database. You are building Change name and address.
Bad — one endpoint sends both changes:
public void updateDetails(CustomerId id, UpdateDetails req) {
customers.changeName(id, req.fullName()); // call 1: succeeds
customers.changeAddress(id, req.address()); // call 2: times out
audit.record("details updated", id);
}The name is changed in the other system, the address is not, the endpoint returns an error, and nothing can undo call 1.
Good — each change is its own business action. The microservice validates and checks permission first, then makes exactly one call and changes nothing else:
GET /customers/42/address
200 OK
{ "customerId": "42", "version": "v7", "line1": "2 Low Road", "town": "York", "postcode": "YO1 7HH" }
POST /customers/42/change-address
{ "version": "v7", "line1": "1 High Street", "town": "Leeds", "postcode": "LS1 1AA" }
204 No ContentHere version is the other system's ETag, text not a number, passed on in an If-Match header. A copy we keep is a cache, updated after the other system confirms. The audit record is written after the confirmation. If the other system has no version, the last save wins; write that risk in the ADR.
Why: a single call either happened or did not; two calls can half happen.
9. Translate the other system's answer into our own status codes#
You are writing the adapter that calls the customer system in rule 8.
Bad — the other system's answer goes straight through:
catch (HttpClientErrorException e) {
throw new ResponseStatusException(e.getStatusCode(), e.getResponseBodyAsString()); // 412 reaches Express
}Express gets 412 Precondition Failed, a code it has never seen, and shows a generic error page instead of the 409 page.
Good — one table, applied in the adapter:
| The other system | We return |
|---|---|
| Refuses the change | 422 with our own code, such as ADDRESS_REJECTED |
| Says the version changed (412) | 409 with code CONCURRENT_UPDATE |
| Is unreachable or answers 503 | 503. Nothing was changed |
| Does not answer in time | 504 with code OUTCOME_UNKNOWN. The change may or may not be made. Express tells the user to check before trying again |
@Component
class CustomerClient {
private final RestClient http; // built once, with the base URL, the bearer token and the time limits
void changeAddress(CustomerId id, Address address, String etag) {
try {
http.post().uri("/customers/{id}/change-address", id.value())
.header("If-Match", etag).body(address)
.retrieve().toBodilessEntity();
} catch (HttpClientErrorException e) {
if (e.getStatusCode().value() == 412) throw new ConcurrentUpdate(id); // -> 409 CONCURRENT_UPDATE
if (e.getStatusCode().value() == 422) throw new AddressRejected(id); // -> 422 ADDRESS_REJECTED
throw e;
} catch (HttpServerErrorException e) {
throw new DependencyUnavailable("customers"); // -> 503; nothing was changed
} catch (ResourceAccessException e) { // no connection, or no answer in time
if (e.getCause() instanceof ConnectException) throw new DependencyUnavailable("customers"); // never reached them -> 503
throw new OutcomeUnknown(id); // reached them, reply lost -> 504 OUTCOME_UNKNOWN
}
}
}Spring: RestClient throws HttpClientErrorException, HttpServerErrorException and ResourceAccessException; the adapter turns each into our own error. A @WireMockTest proves each row of the table: WireMock plays the customer system and answers 412, 422, 503 and a delay.
Why: Express handles one set of codes for every microservice.
Part B. Endpoints that read data#
10. Build read endpoints around records, not around screens#
You are building the claim summary page: the claim, its latest three notes, and the payment status.
Bad — one endpoint for the page:
GET /claims/9f1c/summary-page
200 OK
{ "heading": "Claim 9f1c", "notesPanel": [...], "paymentBadge": "Paid", "showCloseButton": true }The design team moves the notes to their own tab. The microservice changes, is retested and is released for a screen change.
Good — the page calls the record endpoints it needs and combines the results. Every read endpoint is one of five kinds:
| Kind | Example | Rule |
|---|---|---|
| One record | GET /claims/9f1c | Detail format; 404 if missing |
| A list with filters | GET /claims?status=OPEN&page=0&size=20 | Rule 13 |
| A record's children | GET /claims/9f1c/notes?page=0&size=3&sort=createdAt,desc | 404 if the claim is missing, not an empty list |
| A lookup list | GET /reference-data/CLAIM_TYPE | Rule 14 |
| A calculated answer | GET /claims/9f1c/entitlement?from=2026-01-01&to=2026-01-31 | The one kind built for a question; the microservice applies the business rules |
const [claim, notes] = await Promise.all([
claims.get(`/claims/${id}`),
claims.get(`/claims/${id}/notes?page=0&size=3&sort=createdAt,desc`),
]);Why: a screen-shaped endpoint changes every time the screen changes; a record-shaped one does not.
11. Give each kind of record two response formats only: a summary for lists and a detail for one record#
The dashboard needs the claim type and the caseworker's name in its list of claims.
Bad — a third format for that one screen:
record ClaimForDashboard(String claimId, String claimType, String caseworkerName, boolean overdue) {}Six months later there are ClaimForDashboard, ClaimForSearch and ClaimForReport, each parsed in its own place in Express, each drifting.
Good — two formats, and the list screen's new field goes in the summary:
record ClaimSummary(String claimId, long version, String claimType, String status,
Instant createdAt, String caseworkerName) {} // for lists
record ClaimDetail(String claimId, long version, String claimType, String status,
Instant createdAt, String caseworkerName, ContactPreferences contactPreferences,
List<EvidenceItem> evidence, boolean paymentsStopped) {} // for one recordSummary is the ID and the few fields a list shows. Detail is every field the caller may see.
Why: a convention. Two formats have a price, the summary grows with every list screen's needs, and we accept it. A third format for one screen ties the microservice to that screen.
12. Never make one call per row of a list#
The list of open claims shows 20 rows, and each row needs the number of notes that need action.
Bad — one call per row:
const list = await claims.get('/claims?status=OPEN&sort=createdAt,desc&page=0&size=20');
for (const row of list.items) {
const notes = await claims.get(`/claims/${row.claimId}/notes?needsAction=true&page=0&size=1`); // 20 more calls
row.openActions = notes.totalItems;
}The page makes 21 calls, takes 20 times as long, and fails whenever any one of them fails.
Good — add the field to the summary format, or fetch the rows in one call:
GET /claims?status=OPEN&sort=createdAt,desc&page=0&size=20
200 OK
{ "items": [ { "claimId": "9f1c", "version": 7, "status": "OPEN", "openActions": 2, ... }, ... ],
"page": 0, "size": 20, "totalItems": 48213 }
GET /claims?ids=9f1c,7a2d,...ids takes 100 IDs at most.
Why: one call fails once and takes one round trip.
13. Page, sort and index every list#
You are building GET /claims with a filter on status.
Bad — the whole table, in whatever order the database returns it:
GET /claims
200 OK
{ "items": [ ...48,000 claims... ] }It works on the first day. A year later it times out, and the rows change order between two calls, so the user misses some.
Good — one page at a time, always sorted, every filter field indexed:
GET /claims?status=OPEN&sort=createdAt,desc&page=0&size=20
200 OK
{ "items": [ ... ], "page": 0, "size": 20, "totalItems": 48213 }size is 100 at most. Every field a caller may filter on has a database index.
Why: an unindexed, unpaged list gets slower as the data grows until it times out.
14. Return a lookup list as codes and labels, cached for 5 minutes#
The claim type dropdown needs the list of claim types.
Bad — the labels live in Express:
const CLAIM_TYPES = { STANDARD: 'Standard claim', URGENT: 'Urgent claim' }; // in ExpressThe business renames "Urgent claim" to "Priority claim". The microservice is updated, Express still shows the old label, and old records with a retired type show a blank.
Good — the microservice returns codes and labels, and Express stores the code:
GET /reference-data/CLAIM_TYPE
200 OK
Cache-Control: max-age=300
{ "items": [ { "code": "STANDARD", "label": "Standard claim", "active": true },
{ "code": "URGENT", "label": "Priority claim", "active": true },
{ "code": "LEGACY", "label": "Legacy claim", "active": false } ] }Express shows the label and stores the code. A value with active: false still shows on old records but is not offered in forms.
Why: labels change and codes never do. Five minutes is a choice, made once: long enough to take the load off the microservice, short enough that a renamed label shows within minutes.
15. Check permission on every endpoint, reads included, even when the caller is our own Express#
You are adding GET /claims/9f1c, a read, called only by Express.
Bad — the endpoint trusts a header and skips the check because it is "only a read":
@GetMapping("/claims/{id}")
public ClaimDetail get(@PathVariable String id, @RequestHeader("X-User-Id") String userId) {
return service.get(id); // no permission check; the header is whatever the caller sent
}Anything on the network can send X-User-Id: admin and read every claimant's details.
Good — the endpoint declares the permission it needs, takes the user from the access token, and a test proves a user without the permission gets 403:
@RestController
@RequestMapping("/claims")
class ClaimController {
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('VIEW_CLAIM')") // the authorities come from the access token
ClaimDetail get(@PathVariable ClaimId id) { // the string becomes our type at the edge (DDD guide, rule 6)
return claimQueries.detail(id);
}
}
@WebMvcTest(ClaimController.class)
class ClaimControllerTest {
@Autowired MockMvc mvc;
@Test
@WithMockUser(authorities = "EDIT_CLAIM") // signed in, but without this permission
void refusesUserWithoutPermission() throws Exception {
mvc.perform(get("/claims/9f1c")).andExpect(status().isForbidden());
}
}Spring: @PreAuthorize with @EnableMethodSecurity checks the authority before the method runs; @WebMvcTest and @WithMockUser prove it. The authorities exist only because a JwtAuthenticationConverter bean maps the token's permissions claim (Pragmatic DDD Guide, "One-time setup"); by default Spring maps only scope, as SCOPE_....
The build checks that no endpoint is missing a @PreAuthorize. The microservice refuses even when the call comes from our own Express app. Hiding a button the user cannot use helps the user but is not security.
Why: reads return personal data, and anything on the network can call the endpoint.
Part C. Processes across several microservices#
A database transaction cannot cover two microservices. Step 6 of the walkthrough is the example for this part: closing a claim must also stop its payments, which live in the payment microservice.
16. When a process spans several microservices, one microservice owns it and Express calls only that one#
You are building Close claim, which must also stop payments.
Bad — Express runs the process:
await claims.post(`/claims/${id}/close`, { version }); // succeeds
await payments.post(`/payments/stop`, { claimId: id }); // times outThe claim is closed, the payments keep going, Express shows an error, and nobody owns the half-done job.
Good — Express makes one call to the owner, and the owner records the progress:
POST /claims/9f1c/close
{ "version": 7 }
204 No Content
GET /claims/9f1c
200 OK
{ "claimId": "9f1c", "status": "CLOSED", "paymentsStopped": false, ... }Express shows "Waiting for payments to stop" while paymentsStopped is false. An alert is raised when a process is stuck. Every such process needs design group approval and an ADR.
17. Change data in another microservice by sending an event, never by calling its API#
Inside the close-claim process, the claim microservice must make the payment microservice stop paying.
Bad — an HTTP call inside the process:
claim.close(openActions, today);
claims.save(claim);
paymentsClient.post("/payments/stop", claim.id()); // times out: stopped or not?The call times out. The claim is closed, and nobody knows whether the payments stopped.
Good — the change is an event, stored with the claim and delivered until the payment microservice processes it:
claim.close(openActions, today); // raises ClaimClosed inside the entity
claims.save(claim); // saves the claim and ClaimClosedV1 together (rule 18){ "eventId": "c1d2...", "type": "ClaimClosedV1", "claimId": "9f1c", "closedOn": "2026-10-31" }Before its first change, a microservice may still call another to read or check something, such as asking eligibility before creating a claim.
Why: an HTTP call that times out leaves you not knowing whether the change happened; an event is delivered until it is processed.
18. Save each step's change and its event in one transaction, and skip an event you already processed#
You are writing the listener in the payment microservice that reacts to ClaimClosedV1.
Bad — the event is sent outside the transaction, and the listener trusts it arrives once:
claims.save(claim); // transaction commits
broker.publish(new ClaimClosedV1(id)); // process crashes here: closed claim, no event
// the broker calls this for each ClaimClosedV1
void on(ClaimClosedV1 e) { payments.stopFor(e.claimId()); } // runs twice on redeliveryEither the event is never sent and the payments never stop, or the broker delivers it twice and the second run creates a second "stopped" record.
Good — an outbox on the sending side, an inbox on the receiving side:
// sender, in the claim microservice: claim and event in one transaction; a background sender publishes afterwards
@Transactional
public void close(ClaimId id, long expectedVersion) {
Claim claim = claims.findById(id).orElseThrow(() -> new ClaimNotFound(id));
claim.requireVersion(expectedVersion);
claim.close(noteService.countNeedingAction(id), LocalDate.now(clock));
claims.save(claim); // @Version: an old version throws OptimisticLockingFailureException;
// @DomainEvents: the Outbox listener stores ClaimClosedV1 in the same transaction (DDD guide, rule 19)
audit.record("claim closed", id);
}
// receiver, in the payment microservice: called by the message listener for each ClaimClosedV1
@Transactional
public void on(ClaimClosedV1 e) {
if (inbox.seen(e.eventId())) return; // seen it: skip (the Inbox class: DDD guide, rule 21)
inbox.record(e.eventId()); // same transaction; its unique _id catches a race
paymentService.stopFor(new ClaimId(e.claimId()), e.closedOn()); // their string, our type
}Spring: @Transactional with a MongoTransactionManager bean wraps both writes (DocumentDB 4.0 or later); @DomainEvents on the aggregate and an @EventListener write the outbox entry; the Inbox class wraps MongoTemplate.
Why: an event can be delivered more than once, and a crash can happen between any two lines.
19. Decide what happens when a step fails before you build the process#
You are designing the close-claim process, and the payment microservice may be unable to stop a payment that is already with the bank.
Bad — the design only names the happy path:
ClaimClosedV1 -> payments stop -> PaymentsStoppedV1 -> paymentsStopped = truePaymentStopFailedV1 arrives. Nobody wrote what to do, so the listener logs it and the claimant is paid for a closed claim.
Good — the failure path is a named business step, agreed with the business and the design group:
ClaimClosedV1 -> payments stop -> PaymentsStoppedV1 -> paymentsStopped = true
\-> PaymentStopFailedV1 -> create a caseworker task "Recover payment"Why: step 2 cannot be undone once step 3 fails, so a new business action, not a rollback, handles the failure.
20. Link the steps by events that each microservice reacts to; use one directing microservice only for many steps or deadlines#
You have two processes: "close claim, then stop payments" (two steps) and "award a claim" (create the award, set up payments, send a letter, each within a deadline).
Bad — a directing owner for the two-step process:
claim microservice: send command StopPayments -> wait for PaymentsStopped -> set paymentsStopped
(plus a timer, a retry policy and a state table, for one step)The simple process gets a state machine nobody needed; or the reverse, the five-step award process is a chain of events nobody can follow.
Good — start with each microservice listening for an event, doing its step and sending its own event (choreography):
ClaimClosedV1 -> payment microservice stops -> PaymentsStoppedV1 -> claim microservice updatesSwitch to one directing owner (orchestration) when there are more than three steps, deadlines, or the business must see each step's progress. The owner then sends each step as a command message, not an HTTP call, and waits for the reply event:
award owner: CreateAward -> AwardCreated; SetUpPayments -> PaymentsSetUp; SendLetter -> LetterSent
status: IN_PROGRESS (step 2 of 3), deadline 5 working daysWhy: a short chain of events is simple; a long one becomes impossible to follow. Where the line sits is a judgement we made once: more than three steps, a deadline, or progress the business must see. Do not move it per process.
21. Return 204 and a progress field when the user needs only the first step; return 202 with a status URL when the user must wait for all steps#
Close claim: the caseworker only needs the claim closed; payments stop later. Award claim: the caseworker must know when the award is complete.
Bad — both answer 204 and say nothing more:
POST /claims/9f1c/award
204 No ContentThe screen says "Awarded" while the letter has not been sent, and the caseworker has no way to see that step 3 failed.
Good — 204 with a progress field when the first step is enough; 202 with a status URL when the user must wait:
POST /claims/9f1c/close
204 No Content
GET /claims/9f1c -> { ..., "paymentsStopped": false }
POST /claims/9f1c/award
202 Accepted
Location: /claims/9f1c/award
GET /claims/9f1c/award
200 OK
{ "status": "IN_PROGRESS", "step": "SET_UP_PAYMENTS" } // later COMPLETED, or FAILED with a reasonExpress shows the status and checks again on refresh.
Why: the user should never wait for work they do not need, and never be told "done" when it is not. Which answer a process gives is decided once, in its design note.
Part D. Conventions#
22. GET reads, POST creates or acts, PUT saves a group; never PATCH, and no DELETE on business records#
A caseworker wants to remove a claim that was opened by mistake.
Bad — the record is deleted, and a read changes data on the way:
DELETE /claims/9f1c
204 No Content
GET /claims/9f1c/mark-as-read // a GET that writesThe audit trail for the claim is gone, and a link prefetch by the browser marks claims as read.
Good — one meaning per method:
GET /claims/9f1c reads; never changes anything
POST /claims creates
POST /claims/9f1c/cancel acts; the record and its history stay (rule 43)
PUT /claims/9f1c/contact-preferences saves a group
POST /claims/search reads by personal details (rule 25)To end a record, use an action such as close or cancel. DELETE only when the design group agrees a record may be removed for ever. "Never PATCH" is rule 3's convention, a group is saved whole, not a failure of its own.
23. Name collections with plural nouns and actions with lower-case verbs; nest one level at most#
You are adding attachments to notes.
Bad — the path mirrors the whole object tree, and the ID carries meaning:
GET /claims/9f1c/notes/4ab2/attachments/CLM-2026-000731-AExpress has to know three IDs to fetch one attachment, and someone parses 2026 out of the ID.
Good — plural nouns, lower-case verbs, one level of nesting, opaque UUIDs:
GET /claims/9f1c/notes/4ab2
POST /claims/9f1c/resolve-action
GET /attachments/0c7e...
GET /claims?status=OPEN&sort=createdAt,desc&page=0&size=20Filters, sorting and paging are query parameters. IDs are opaque strings, UUIDs without dashes (shortened in these examples), that Express passes back unchanged and never reads meaning from.
Why: a predictable URL needs no lookup. Opaque IDs prevent a real failure; plural nouns, lower-case verbs and one level of nesting are our naming choice, made once.
24. Return a JSON object with camelCase fields, ISO dates, UTC times and two-decimal money#
You are writing the response of GET /claims/9f1c/payments.
Bad — a bare array, mixed field styles, local times and floating-point money:
[ { "payment_id": "p1", "PaidOn": "31/10/2026 09:15", "amount": 242.8599999 } ]Express's generated client cannot add a totalItems field to an array later, the date is ambiguous between UK and US order, and the amount prints as £242.86 on one page and £242.85 on another.
Good — an object, camelCase, ISO dates, UTC times ending with Z, money with two decimals:
{ "items": [ { "paymentId": "p1", "paymentDate": "2026-10-31", "paidAt": "2026-10-31T09:15:00Z",
"amount": 242.86 } ] }We use plain JSON, not JSON:API; the GDS standards suggest JSON:API only for an organisation with no standard of its own.
Why: Express's generated client needs one shape. Objects, ISO dates and decimal money prevent real errors; camelCase is a choice, made once.
25. Never put personal data in a URL; search by personal details with POST /claims/search#
The caseworker searches for a claimant by National Insurance number.
Bad — the number is in the query string:
GET /claims?nino=QQ123456CThe gateway, Express, the microservice and the load balancer all write the URL to their access logs, and the number is now in four places the data protection rules do not cover.
Good — personal details go in the body of a POST that only reads:
POST /claims/search
{ "nino": "QQ123456C" }
200 OK
{ "items": [ { "claimId": "9f1c", ... } ] }Names and dates of birth go the same way, never in a path or query string.
Why: URLs are written to logs.
26. Return the same status codes and the same error body from every microservice#
You are handling the case where the claim has notes that need action when the user closes it.
Bad — the feature writes its own error response:
catch (ClaimHasOpenActions e) {
return ResponseEntity.status(400).body(Map.of("error", "Cannot close: " + e.getMessage()));
}Express gets 400 and looks for field errors to show next to fields, finds none, and shows a blank error summary; the notes microservice answers the same situation with 409 and a different body.
Good — the feature throws a domain error; one shared error handler maps each kind of error to its status and body:
if (notesNeedingAction > 0) throw new ClaimHasOpenActions(id, notesNeedingAction); // in Claim.close
@RestControllerAdvice
class ApiErrors extends ResponseEntityExceptionHandler { // the one error handler of the microservice; rules 44 and 9 add 503 and 504
@ExceptionHandler(RuleRefused.class) // every business rule error extends RuleRefused
ProblemDetail ruleRefused(RuleRefused e) { return problem(UNPROCESSABLE_CONTENT, "Business rule violated", e.code(), e.getMessage()); }
@ExceptionHandler({VersionConflict.class, OptimisticLockingFailureException.class}) // the entity's check, and @Version's
ProblemDetail conflict(Exception e) { return problem(CONFLICT, "Record changed", "CONCURRENT_UPDATE", e.getMessage()); }
@ExceptionHandler(NotFound.class)
ProblemDetail notFound(NotFound e) { return problem(NOT_FOUND, "Not found", e.code(), e.getMessage()); }
@ExceptionHandler(AccessDeniedException.class) // @PreAuthorize said no: 403 here, so the catch-all cannot make it a 500
ProblemDetail forbidden(AccessDeniedException e) { return problem(FORBIDDEN, "Forbidden", "FORBIDDEN", "You do not have permission for this action"); }
@ExceptionHandler(Exception.class) // anything else is a bug
ProblemDetail bug(Exception e) {
log.error("unhandled", e); // the stack trace goes to the log, never to the caller
return problem(INTERNAL_SERVER_ERROR, "Unexpected error", "INTERNAL_ERROR", "Quote the traceId to the team");
}
@Override // Spring MVC's own errors (bad JSON, unknown URL, wrong method) get the same two fields
protected ResponseEntity<Object> handleExceptionInternal(Exception e, Object body, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
ResponseEntity<Object> r = super.handleExceptionInternal(e, body, headers, status, request);
if (r != null && r.getBody() instanceof ProblemDetail p) { p.setProperty("code", HttpStatus.valueOf(status.value()).name()); p.setProperty("traceId", Trace.current()); }
return r;
}
private ProblemDetail problem(HttpStatus status, String title, String code, String detail) {
ProblemDetail p = ProblemDetail.forStatusAndDetail(status, detail);
p.setTitle(title);
p.setProperty("code", code);
p.setProperty("traceId", Trace.current());
return p;
}
}422 Unprocessable Content
Content-Type: application/problem+json
{ "status": 422, "title": "Business rule violated", "code": "CLAIM_HAS_OPEN_ACTIONS",
"detail": "Claim 9f1c has 2 notes that need action", "traceId": "4bf92f35..." }Spring: a @RestControllerAdvice returning ProblemDetail is written as application/problem+json. Extending ResponseEntityExceptionHandler brings Spring MVC's own errors through the same class: handleExceptionInternal is their last stop, and rule 35 overrides the 400 for field errors. The status names are static imports of HttpStatus.
| Status | Meaning | What Express does |
|---|---|---|
| 400 | Invalid request; errors names each wrong field | Shows each error next to its field |
| 401 | Not signed in or session ended | Sends the user to sign in through the gateway |
| 403 | Signed in but not allowed | Shows "You do not have permission" |
| 404 | Record does not exist | Shows a not-found page |
| 409 CONCURRENT_UPDATE | Record changed after the screen loaded | Shows the 409 page (rule 38); never retries |
| 409 REQUEST_IN_PROGRESS | The first request with this Idempotency-Key is still running | Asks the user to wait, then check the record |
| 422 | A business rule refused the change; code names the rule | Shows the message for that code |
| 422 IDEMPOTENCY_KEY_REUSED | Same key, different request: an Express bug | Shows an error page |
| 500 | Unexpected error | Shows an error page with the traceId |
| 503 DEPENDENCY_UNAVAILABLE | A needed system is unavailable; nothing changed; Retry-After says when to try (rule 44) | Shows "Try again later"; retries a read once, never a change |
| 504 OUTCOME_UNKNOWN | Another system did not answer in time (rule 9) | "We could not confirm your change. Check before you try again." Never retries |
Every error body is RFC 9457 problem details. Express picks the message from code; detail is for developers and never shown; traceId finds the request in every log. Error bodies never contain personal data.
Why: Express has one error handler for every microservice, and it only works if every microservice answers the same way.
27. Make a breaking API change in three releases, and update openapi.yaml in the same pull request as the code#
The business wants fullName on the eligibility microservice's GET /claimants/c88 split into firstName and lastName.
Bad — one release:
- "fullName": "Jane Doe"
+ "firstName": "Jane", "lastName": "Doe"The microservice is released on Tuesday, Express on Thursday. For two days every claim page shows no claimant name.
Good — three releases, and the contract file in the same pull request as the code:
Release 1 (microservice): returns fullName AND firstName, lastName; accepts either on create
Release 2 (Express): reads and sends firstName, lastName
Release 3 (microservice): removes fullName# contracts/openapi.yaml, same pull request as release 1
fullName: { type: string, deprecated: true }
firstName: { type: string }
lastName: { type: string }Safe at any time: a new endpoint, an optional request field, a new response field (Express ignores unknown fields). Breaking: removing, renaming or retyping a field, making it required, changing a URL. Every path starts with /v1, as in the walkthrough. If three releases are impossible, add /v2 beside /v1 with Deprecation and Sunset headers on /v1. The build compares contracts/openapi.yaml with the released version and fails on a breaking change, and Express generates its client from the file, so a removed field does not compile.
Why: Express and the microservices are released at different times.
Part E. The Express front end#
Every browser request goes to the gateway, which signs the user in and passes the request to Express with the user's ID and access token in headers. Express builds the page on the server and calls the microservices. So Express is already our backend-for-frontend (BFF), a server-side layer built for one front end, and we add no other layer. If a mobile app comes later, the design group decides whether it needs its own BFF.
28. The browser never calls a microservice; Express calls it with the user's token#
A developer wants the notes panel to refresh without reloading the page.
Bad — page JavaScript calls the microservice:
// in the browser
const notes = await fetch('https://claims.internal/v1/claims/9f1c/notes',
{ headers: { Authorization: 'Bearer ' + token } });The access token is now in the browser, where any script on the page can read it, and the microservice address is public.
Good — the browser calls Express, and Express calls the microservice with the token from the gateway:
// in Express
app.get('/claims/:id/notes', async (req, res) => {
const notes = await claims.get(`/claims/${req.params.id}/notes?page=0&size=20`, {
headers: { Authorization: `Bearer ${req.gateway.accessToken}`, traceparent: req.traceparent },
});
res.render('notes', { notes });
});The browser holds only the gateway's session cookie. Express accepts requests only from the gateway, which strips any identity headers the browser sends. Every call carries the standard traceparent header, so one action can be followed through every log.
Why: only Express knows the microservice addresses, and the token never reaches a place we do not control.
29. Read from several microservices in parallel, but send each save to one microservice#
The claim page shows the claim, its notes and the payment status, from two microservices.
Bad — reads one after another, and a form that saves to two microservices:
const claim = await claims.get(`/claims/${id}`); // 400 ms
const notes = await claims.get(`/claims/${id}/notes`); // + 300 ms
const pay = await payments.get(`/payments?claimId=${id}`); // + 500 ms, throws if down
await claims.put(`/claims/${id}/contact-preferences`, body);
await payments.post(`/payments/${id}/change-bank-details`, body); // half saved if this failsThe page takes 1.2 seconds, three calls one after another, and the form writes to two microservices with nobody owning the half-saved result (rule 16).
Good — all reads at once, each allowed to fail on its own; each save one request to one microservice:
const [claim, notes, pay] = await Promise.allSettled([
claims.get(`/claims/${id}`),
claims.get(`/claims/${id}/notes`),
payments.get(`/payments?claimId=${id}`),
]);Why: parallel reads keep the page fast; a single save keeps the data consistent (rule 16).
30. Give every call from Express a time limit and a circuit breaker#
The payment microservice is slow this morning.
Bad — a call with no time limit:
const pay = await fetch(`${PAYMENTS}/payments?claimId=${id}`); // waits for everEvery claim page waits for the payment call, Express runs out of connections, and the whole site hangs because one microservice is slow.
Good — all calls go through one shared HTTP client module with time limits and a circuit breaker:
const client = httpClient({
timeouts: { read: 2000, change: 5000 }, // ms
circuitBreaker: { failures: 5, openFor: 30000 },
});Microservices keep a request, with their own retries, under 4 seconds, so they give up first. After several failures the breaker stops calling that microservice for a short time and fails at once.
31. Show the page with the data that loaded; show an error page only when essential data failed#
The payment microservice is down, and a caseworker opens a claim.
Bad — any failed call fails the page:
const [claim, notes, pay] = await Promise.all([...]); // one rejection throws
res.render('claim', { claim, notes, pay }); // never reachedThe caseworker sees a blank error page and cannot work on the claim, because a panel they did not need failed.
Good — decide which data is essential for the page, and show the rest with a message:
const [claim, notes, pay] = await Promise.allSettled([...]);
if (claim.status === 'rejected') return errorPage(res, claim.reason); // essential
res.render('claim', {
claim: claim.value,
notes: notes.status === 'fulfilled' ? notes.value : null,
payments: pay.status === 'fulfilled' ? pay.value : { unavailable: true }, // "Payment details are not available right now"
});One error handler turns status and code into what the user sees (rule 26).
32. Keep answers in the Express session until the save button, then save once and redirect#
You are building the five question pages of Create claim.
Bad — each page saves a draft to the microservice, and the final page renders the result directly:
app.post('/create-claim/claim-type', async (req, res) => {
await claims.put(`/claims/${req.session.draftId}/claim-type`, req.body); // a draft claim per page
res.render('next-question');
});The microservice fills with half-finished claims; the user presses F5 on the last page and the form posts again.
Good — answers stay in the server-side session; the save button sends one request; then redirect:
app.post('/create-claim/claim-type', (req, res) => {
req.session.claim = { ...req.session.claim, claimType: req.body.claimType };
res.redirect(303, '/create-claim/first-note');
});
app.post('/create-claim/check-answers', async (req, res) => {
const { claimId } = await claims.post('/claims', req.session.claim,
{ headers: { 'Idempotency-Key': req.body.idempotencyKey } });
delete req.session.claim;
res.redirect(303, `/claims/${claimId}/created`); // confirmation page shows the reference
});Pages work without JavaScript: every form posts to Express, and the Change links on the Check answers page edit the session. Field errors: rule 35. A 409: rule 38. Save and come back later: rule 37.
Why: a redirect after the save means a refresh cannot submit the form twice.
33. Cache lookup lists only; never cache business records#
Express calls GET /reference-data/CLAIM_TYPE on every page, and someone wants to speed up GET /claims/9f1c the same way.
Bad — a cache in front of a business record:
const claim = await cache.get(`/claims/${id}`, { ttl: 60 }); // 60-second cacheAnother caseworker closes the claim; this user sees it as open for a minute, submits a change with version 7, and gets a 409 they could not have avoided.
Good — keep a lookup list for as long as its Cache-Control header says, and read business records fresh every time:
const types = await cache.get('/reference-data/CLAIM_TYPE'); // honours Cache-Control: max-age=300
const claim = await claims.get(`/claims/${id}`); // never cachedWhy: users must always see the latest version of a record.
Part F. Situations you will meet#
34. Send an evidence file as multipart to the claim microservice, store the bytes in an object store, keep only a reference on the record, and stream downloads through Express#
A caseworker uploads a payslip to claim 9f1c.
Bad — the file goes into the record as text, and the download is a public link:
POST /claims/9f1c/evidence
Content-Type: application/json
{ "fileName": "payslip.pdf", "content": "JVBERi0xLjQKJ..." } // 8 MB of base64
200 OK
{ "downloadUrl": "https://files.example.gov.uk/9f1c/payslip.pdf" } // anyone with the linkThe claim record is now 8 MB, every GET /claims/9f1c carries it, and a link in someone's browser history opens a claimant's payslip without a sign-in.
Good — multipart with a limit and the record's version, bytes to the object store, a reference on the record, and the download goes through Express with the user's token:
POST /claims/9f1c/evidence
Content-Type: multipart/form-data; boundary=----x
------x
Content-Disposition: form-data; name="version"
7
------x
Content-Disposition: form-data; name="file"; filename="payslip.pdf"
Content-Type: application/pdf
(bytes, at most 10 MB)
201 Created
Location: /claims/9f1c/evidence/e3a1
{ "evidenceId": "e3a1", "fileName": "payslip.pdf", "size": 812331 }@PostMapping(path = "/{id}/evidence", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('EDIT_CLAIM')")
ResponseEntity<EvidenceCreated> upload(@PathVariable ClaimId id,
@RequestParam long version,
@RequestParam MultipartFile file) throws IOException {
FileName fileName = new FileName(file.getOriginalFilename());
EvidenceId evidenceId = claimService.addEvidence(id, version, fileName, file.getInputStream());
return ResponseEntity.created(URI.create("/claims/" + id.value() + "/evidence/" + evidenceId.value()))
.body(new EvidenceCreated(evidenceId.value(), fileName.value(), file.getSize()));
}
// application.properties
// spring.servlet.multipart.max-file-size=10MB
// spring.servlet.multipart.max-request-size=11MBclaimService.addEvidence sends the bytes to the object store through one adapter, before it opens the transaction (DDD guide, rule 17), and keeps only a FileReference on the evidence item (DDD guide, rule 10); a DocumentDB document holds 16 MiB at most, so the bytes never go into the record. The adapter is tested against Floci, which plays the object store locally.
Spring: MultipartFile receives the part, and spring.servlet.multipart.max-file-size refuses a bigger one before the controller runs.
// Express: GET /claims/:id/evidence/:evidenceId/file
app.get('/claims/:id/evidence/:evidenceId/file', async (req, res) => {
const upstream = await claims.stream(`/claims/${req.params.id}/evidence/${req.params.evidenceId}/file`);
res.set('Content-Disposition', `attachment; filename="${upstream.fileName}"`);
upstream.pipe(res);
});The claim microservice checks VIEW_CLAIM on the file endpoint like any other (rule 15), and the file name in the response comes from the record, never from the URL.
Why: a record stays small and a file is only ever read by a signed-in user with the permission.
35. Return field errors as 400 with an errors list of field, code and message; keep business-rule refusals as 422#
On the contact preferences page the caseworker picks EMAIL, leaves the email address blank and types letters in the phone number.
Bad — one message for everything:
400 Bad Request
{ "error": "Validation failed: emailAddress, phoneNumber" }Express cannot tell which field has which problem, so it shows one vague error at the top and the user hunts for the mistake.
Good — one entry per field, with a stable code Express can turn into GOV.UK text. The constraint's message is the code; the text comes from one message table:
record ContactPreferencesRequest(
@NotNull(message = "REQUIRED") Long version,
@NotNull(message = "REQUIRED") ContactMethod contactMethod,
@Pattern(regexp = "[0-9 ]*", message = "FORMAT") String phoneNumber,
@NotBlank(message = "REQUIRED") String emailAddress) {}
@PutMapping("/{id}/contact-preferences")
@PreAuthorize("hasAuthority('EDIT_CLAIM')")
ResponseEntity<Void> saveContactPreferences(@PathVariable ClaimId id, @Valid @RequestBody ContactPreferencesRequest body) {
claimService.saveContactPreferences(id, body.version(), body.toContactPreferences());
return ResponseEntity.noContent().build();
}
// in ApiErrors (rule 26): the override that shapes every 400
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
ProblemDetail p = ProblemDetail.forStatusAndDetail(status, e.getFieldErrorCount() + " fields are invalid");
p.setTitle("Request is invalid");
p.setProperty("code", "INVALID_REQUEST");
p.setProperty("traceId", Trace.current());
p.setProperty("errors", e.getFieldErrors().stream()
.map(f -> Map.of("field", f.getField(), "code", f.getDefaultMessage(), "message", messages.text(f)))
.toList());
return ResponseEntity.status(status).body(p);
}PUT /claims/9f1c/contact-preferences
{ "version": 7, "contactMethod": "EMAIL", "phoneNumber": "abc", "emailAddress": "" }
400 Bad Request
Content-Type: application/problem+json
{ "status": 400, "title": "Request is invalid", "code": "INVALID_REQUEST",
"detail": "2 fields are invalid", "traceId": "4bf92f35...",
"errors": [
{ "field": "emailAddress", "code": "REQUIRED", "message": "Enter an email address" },
{ "field": "phoneNumber", "code": "FORMAT", "message": "Enter a phone number, like 01632 960 001" }
] }// Express: one summary at the top, and the same message next to each field
const errorList = body.errors.map(e => ({ text: e.message, href: `#${e.field}` }));Spring: @Valid runs Bean Validation on the request record; overriding handleMethodArgumentNotValid in the ResponseEntityExceptionHandler subclass shapes every 400.
Why: a field error is something the user can fix on the page; a business-rule refusal (422, rule 26) is not, and Express shows the two differently. Using two status codes for that is a choice, made once; Express relies on it.
36. Run a bulk action as one request per record from Express; add a batch endpoint only when the business names the batch as one action, and then return a result per item#
A team lead ticks 40 claims and presses Close selected.
Bad — one request, one transaction, all or nothing:
POST /claims/close-many
{ "claimIds": ["9f1c", "a201", ...38 more] }
422 Unprocessable Content
{ "code": "CLAIM_HAS_OPEN_ACTIONS", "detail": "Claim a201 has 1 note that needs action" }One claim with an open note stops all 40, nobody can tell which 39 were fine, and the 40 version checks are gone.
Good — Express sends one close per claim, with that claim's version, in small batches, and shows a result per row:
const results = [];
for (const batch of chunk(selected, 5)) {
await Promise.all(batch.map(async ({ id, version }) => {
try { await claims.post(`/claims/${id}/close`, { version }); results.push({ id, ok: true }); }
catch (e) { results.push({ id, ok: false, code: e.body?.code }); }
}));
}If the business names "close the month's batch" as one action of its own, the batch becomes a record: POST /claim-batches/b7e2/close with the batch's version answers 202 with Location: /claim-batches/b7e2 (rule 21), the microservice still closes one claim per transaction, and GET /claim-batches/b7e2 returns status and a list of { claimId, status, code }.
Why: each record keeps its own rules and its own version check, and the user sees exactly which ones did not close. A batch endpoint with a result per item would do the same; starting from Express is our choice, because it needs no new endpoint.
37. Store a "save and return later" draft as a record of its own, with no business rules until the final create#
A claimant fills in five pages of a new claim and presses Save and come back later.
Bad — the answers stay in the Express session:
req.session.newClaim = { ...req.session.newClaim, ...req.body }; // gone when the session endsThe session expires overnight, the claimant comes back, and every answer is lost. The Express session (rule 32) is for one sitting only.
Good — a draft record in the claim microservice, saved with PUT like any group of details, and no rule checked until the real create:
POST /claim-drafts
Idempotency-Key: 7d1e...
{ "claimantId": "c88" }
201 Created
Location: /claim-drafts/d420
{ "draftId": "d420" }
PUT /claim-drafts/d420/contact-preferences
{ "version": 2, "contactMethod": "EMAIL", "phoneNumber": "", "emailAddress": "jane@example.com" }
204 No Content
POST /claims
Idempotency-Key: 9ab0...
{ "fromDraftId": "d420" } // the rules run here, once
201 Created
Location: /claims/9f1c
{ "claimId": "9f1c" }Why: a draft is allowed to be incomplete and wrong; a claim is not, and the one place that turns one into the other is where the rules live.
38. On 409, show the user the fresh record and a way to start again; never retry with the new version#
Two caseworkers edit the contact details of claim 9f1c. The second save gets 409.
Bad — Express fixes it quietly:
catch (e) {
if (e.status === 409) {
const fresh = await claims.get(`/claims/${id}`);
await claims.put(`/claims/${id}/contact-preferences`, { ...body, version: fresh.version }); // overwrites
}
}The first caseworker's new phone number is overwritten and nobody knows.
Good — a conflict page that says what happened and shows the current values:
catch (e) {
if (e.status === 409) {
const fresh = await claims.get(`/claims/${id}`);
return res.render('conflict', {
message: 'Someone else changed this claim while you were editing it.',
current: fresh, yours: body,
startAgain: `/claims/${id}/contact-details`,
});
}
throw e;
}Why: only the user can decide whose change should win, and 409 exists to ask them.
39. When a microservice calls another with no user present, use its own client-credentials token with its own permission; never a shared key or a stored user token#
The payment microservice's listener for ClaimClosedV1 needs the claim's payment schedule from GET /claims/9f1c/payment-schedule.
Bad:
http.get().uri("/claims/{id}/payment-schedule", claimId.value())
.header("X-Api-Key", "shared-secret-2019") // one key for every service, since 2019
.retrieve().body(PaymentSchedule.class);The key is in three repositories and a chat message, cannot be rotated without a coordinated release, and gives the holder every endpoint.
Good — the microservice signs in as itself and gets a short-lived token with only the permission it needs:
@Bean RestClient claimsClient(RestClient.Builder builder, ClientRegistrationRepository registrations, OAuth2AuthorizedClientService clients) {
var oauth = new OAuth2ClientHttpRequestInterceptor(new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, clients));
oauth.setClientRegistrationIdResolver(request -> "claims"); // fetches, caches and renews the token
return builder.baseUrl(claimsUrl).requestInterceptor(oauth).build();
}
// application.properties: the service's own credentials
// spring.security.oauth2.client.registration.claims.client-id=payment-service
// spring.security.oauth2.client.registration.claims.client-secret=${CLAIMS_CLIENT_SECRET}
// spring.security.oauth2.client.registration.claims.authorization-grant-type=client_credentials
// spring.security.oauth2.client.provider.claims.token-uri=https://gateway.example.gov.uk/oauth2/tokenThe gateway gives payment-service a token whose permissions holds only VIEW_PAYMENT_SCHEDULE, and the claim microservice checks it on that endpoint like any other permission (rule 15). A user's token is never stored for later use: it belongs to that user's request.
Spring: OAuth2ClientHttpRequestInterceptor with AuthorizedClientServiceOAuth2AuthorizedClientManager adds Authorization: Bearer to every request from any thread, a listener's included; Boot creates no manager bean, so the @Bean makes one. Only a RestClient from Boot's injected RestClient.Builder sends traceparent on.
Why: every caller is named, every call is limited to what that caller needs, and a leaked token dies on its own.
40. Receive an outside system's callback on its own endpoint: check the signature, store it with its ID, answer 202, and raise our own event; no business logic in the handler#
The document-check provider posts the result of a payslip check.
Bad — the callback handler does the work:
@PostMapping("/document-checks/callback")
public void callback(@RequestBody DocumentCheckCallback body) {
Claim claim = claims.findById(body.claimReference()).orElseThrow();
claim.verifyEvidence(body.documentId(), body.result()); // their IDs and words, straight in
claims.save(claim);
}Anyone who finds the URL can verify evidence; a slow save makes the provider time out and resend, and the second call fails on the already-verified item.
Good:
@RestController
class DocumentCheckCallbackController {
@PostMapping("/document-checks/callback") // permitAll in the security config: the signature is the check
ResponseEntity<Void> callback(@RequestBody String rawBody, @RequestHeader("X-Signature") String signature) {
signatures.require(rawBody, signature); // 401 if wrong
callbackRecorder.record(parse(rawBody), rawBody);
return ResponseEntity.accepted().build(); // 202: the work happens from the event
}
}
@Service
class CallbackRecorder {
@Transactional
public void record(DocumentCheckCallback body, String rawBody) {
if (mongo.exists(query(where("_id").is(body.callbackId())), "callbacks")) return; // a resend: fine
mongo.insert(new StoredCallback(body.callbackId(), rawBody), "callbacks"); // unique _id
mongo.insert(OutboxEntry.of(new DocumentCheckedV1(body.callbackId(), body.documentId(), body.result())), "outbox");
}
}Spring: @Transactional with MongoTransactionManager stores the callback and its outbox entry together; ResponseEntity.accepted() is the 202. A SecurityFilterChain bean with requestMatchers("/document-checks/callback").permitAll() opens this one path; every other path keeps Boot's default, a valid token.
A listener on DocumentCheckedV1 translates their document ID into our evidence ID and calls claimService.verifyEvidence.
Why: the provider gets a fast, repeatable answer, and the business rule runs where it always runs.
41. Export a list as CSV from a GET .../export with the list's filters, a row limit and the personal-data permission; for large exports return 202 and a status URL#
A team lead wants the open claims list as a spreadsheet.
Bad — Express builds the CSV from the list pages:
let rows = [];
for (let page = 1; page < 500; page++) rows.push(...(await claims.get(`/claims?status=OPEN&page=${page}`)).items);
res.send(toCsv(rows)); // 500 calls, every column, no limitThe export takes minutes, pulls every field including the National Insurance number, and times out at the gateway.
Good — one endpoint next to the list, same filters, streamed, limited:
GET /claims/export?status=OPEN&sort=createdAt,desc
Accept: text/csv
200 OK
Content-Type: text/csv
Content-Disposition: attachment; filename="claims-open-2026-10-31.csv"
claimId,status,claimType,createdAt
9f1c,OPEN,STANDARD,2026-10-01T09:15:00Z
... (at most 10,000 rows; 422 EXPORT_TOO_LARGE above that)Columns with personal data appear only when the token has EXPORT_CLAIM_PERSONAL_DATA. An export the user cannot wait for answers 202 with a status URL (rule 21), and the file arrives at a download link later.
Why: an export is a read with the same rules as the list it comes from, plus a size the server can promise.
42. Show another microservice's data by reading it from that microservice in Express; store a copy only when a rule needs it#
The claim page shows the claimant's name, which the eligibility microservice owns.
Bad — the claim microservice keeps a copy for display:
GET /claims/9f1c
{ "claimId": "9f1c", "claimantName": "Jane Doe", ... } // copied when the claim was createdThe claimant changes their name in eligibility; the claim page shows the old one for years, and two teams argue about which is right.
Good — Express reads the claim, then the claimant from its owner, and composes; the name is not essential, so the page still shows without it (rule 31):
const claim = await claims.get(`/claims/${id}`); // essential
const claimant = await eligibility.get(`/claimants/${claim.claimantId}`).catch(() => null);
res.render('claim', { claim, claimantName: claimant?.fullName ?? 'Name not available' });The claim microservice stores the eligibility decision (its rules need it) and the claimant ID, nothing else about the claimant.
Why: one owner per fact means one place to correct it.
43. Remove a record created by mistake with a cancel action and a reason; keep the record, hide it from lists by default, and still return it on GET#
A caseworker created claim 9f1c for the wrong claimant.
Bad:
DELETE /claims/9f1c
204 No ContentThe audit trail now points at a claim that does not exist, the ClaimOpenedV1 event the payment microservice received has no matching close, and nobody can answer "why was this created?".
Good:
POST /claims/9f1c/cancel
{ "version": 3, "reason": "WRONG_CLAIMANT", "note": "Created against the wrong record" }
204 No Content
GET /claims?status=OPEN&sort=createdAt,desc&page=0&size=20 // cancelled claims are not in the default list
GET /claims?status=CANCELLED&sort=createdAt,desc&page=0&size=20 // but can be listed
GET /claims/9f1c // and still read
{ "claimId": "9f1c", "version": 4, "status": "CANCELLED", "cancelledOn": "2026-10-31", "cancellationReason": "WRONG_CLAIMANT" }Why: business records are history; cancel records the mistake, DELETE erases it (rule 22).
44. When a system the save depends on is down, answer 503 with Retry-After; Express says "try again later" and does not retry the change#
Creating a claim needs the eligibility microservice, and it is down.
Bad — the microservice hides it:
POST /claims
Idempotency-Key: 7d3e...
{ "claimantId": "c88", "claimType": "STANDARD" }
500 Internal Server Error
{ "error": "Connection refused" }Express shows a generic error page, the alert counts a bug, and nobody can tell the caseworker that nothing was saved.
Good:
POST /claims
Idempotency-Key: 7d3e...
{ "claimantId": "c88", "claimType": "STANDARD" }
503 Service Unavailable
Retry-After: 30
Content-Type: application/problem+json
{ "status": 503, "title": "A service we depend on is unavailable", "code": "DEPENDENCY_UNAVAILABLE",
"detail": "Eligibility check is unavailable; nothing was saved", "traceId": "4bf92f35..." }// in ApiErrors (rule 26)
@ExceptionHandler(DependencyUnavailable.class) // thrown by an adapter when the other system is down (rule 9)
ResponseEntity<ProblemDetail> unavailable(DependencyUnavailable e) {
ProblemDetail p = problem(503, "A service we depend on is unavailable", "DEPENDENCY_UNAVAILABLE",
e.system() + " is unavailable; nothing was saved");
return ResponseEntity.status(503).header("Retry-After", "30").body(p);
}if (e.status === 503) return res.render('try-again-later', { seconds: e.headers['retry-after'] });Spring: an @ExceptionHandler in the shared advice returns a ResponseEntity, so it can set the Retry-After header.
Express never retries a change on its own (rule 6). For a read, Express shows the page with what loaded (rule 31).
Why: a convention. 503 tells the user the truth, "nothing was saved, try later", 500 tells them nothing, and alerts can tell an outage next door from a bug of ours. The circuit breaker (rule 30) is what protects eligibility, whichever code we return.
45. Make openapi.yaml the contract: the microservice validates every response against it, and Express's client tests run against its examples#
The claim team renames createdAt to openedAt and updates openapi.yaml.
Bad — the contract is documentation nobody runs:
# openapi.yaml, updated by hand, checked by nobody
ClaimSummary:
properties:
openedAt: { type: string, format: date-time }// Express, a month later, in production
const opened = formatDate(claim.createdAt); // undefined → "Invalid Date" on every rowThe file says openedAt, the microservice still sends createdAt, and nobody finds out until every row shows "Invalid Date".
Good — two tests, one on each side:
// claim microservice: every endpoint test validates the real response against openapi.yaml
@SpringBootTest
@AutoConfigureMockMvc
class ClaimContractTest {
@Autowired MockMvc mvc;
@Test
@WithMockUser(authorities = "VIEW_CLAIM")
void listMatchesContract() throws Exception {
MvcResult r = mvc.perform(get("/claims?status=OPEN&sort=createdAt,desc&page=0&size=20")).andReturn();
contract.assertValid("GET /claims", r.getResponse()); // fails the build on an unlisted or renamed field
}
}Spring: @SpringBootTest with @AutoConfigureMockMvc runs the real endpoint; the check against openapi.yaml is our own code over the response.
// Express: the client is tested against the examples recorded in openapi.yaml
test('renders a claim summary', async () => {
const claim = examples.get('ClaimSummary'); // { claimId: "9f1c", openedAt: "2026-10-01T09:15:00Z", ... }
expect(renderRow(claim)).toContain('1 October 2026');
});For an Express test that needs a whole microservice rather than one example, WireMock plays the microservice, with stubs built from the same openapi.yaml examples, so the two sides never test against different data.
A rename now fails the Express build on the day the contract changes, and rule 27's three-release path becomes the fix.
Why: a contract that is tested on both sides breaks a build, not a page.
Sources: GOV.UK Design System: Check answers, GDS API technical and data standards, GOV.UK Service Manual: progressive enhancement.
Appendix H: Java words in plain English#
This appendix explains each Java word the book uses, in plain words, and links to the section that teaches it. You do not need to read it in order. Search for a word, or come here when a word in a chapter is new to you.
Every entry gives the word, what it means in a few short sentences, and where the book explains it in full. This glossary is in both books, the handbook and the in-depth book, so the link in the last column may open the other book. If Java uses a word differently from .NET, the meaning says so. Abbreviations are listed under their short form, such as JVM (Java Virtual Machine), with the full name first in the meaning.
| Term | What it means | Where it is explained |
|---|---|---|
| acknowledgement | The signal of the listener container that a message is done. In Amazon Simple Queue Service (SQS), it deletes the message from the queue. | Acknowledged on success, deleted in batches |
| Actuator | The Spring Boot module that adds health, metrics and information endpoints to a service, such as /actuator/health. | Actuator endpoints |
| adapter | A class that implements a port with a real technology, such as MongoDB or HTTP. OrderPersistenceAdapter is one. | The direction of the dependencies |
| ADR | Short for architecture decision record. It is a short file that states one decision, its reason and its consequences. | How each decision is recorded |
| aggregate | A group of objects that one business rule must see at once. The service loads, changes and saves the group together, as one unit. | What an aggregate is for |
| aggregate root | The object of an aggregate that outside code holds. Every change to the aggregate goes through it, such as order.pickLine(...). | What an aggregate is for |
| aggregator POM | A Project Object Model (POM) with the packaging pom that only lists the modules to build. The modules inherit nothing from it. | Five build files, five jobs |
| AMQP | Short for Advanced Message Queuing Protocol. It is the wire protocol that RabbitMQ uses. | RabbitMQ |
| anaemic domain model | A model whose entities hold data, with public setters, but no rules. Service classes hold the rules instead. | An anaemic model with an outbox, compared fairly |
| annotation | A marker written with @ before a class, method, field or parameter, such as @Override. It does the same as an attribute in C#. | Syntax |
| annotation processor | A plug-in that the Java compiler runs during a build. It reads annotations and generates extra source files. It does the same as a source generator in C#. | Annotation processing vs source generators |
| annotation retention | How long an annotation is kept. An annotation is kept only in the source, or in the class file, or at runtime. Reflection can read an annotation that is kept at runtime. C# attributes are always there at runtime. | Retention: the concept C# lacks |
| anti-corruption layer | The code that translates the model of another service into the words of this service, in one place. In the order service, the stock adapter does it. | The adapter: the anti-corruption layer |
| AOP | Short for aspect-oriented programming. It is a way of adding behaviour, such as logging or transactions, around many methods without editing each one. Spring does it with proxies. | AOP: writing your own cross-cutting behaviour |
| AOT | Short for ahead-of-time. It is work that is done when the application is built, not when the application runs. Two examples are compiling to native code and preparing a startup cache. | GraalVM native-image |
| AOT cache | A file that the JVM writes during a training run and reads on later starts, so classes load and link faster. It shortens startup without a native image. | Startup without going native |
| API contract | The shape of an API or an event that other teams build against. It is kept as a file, such as an OpenAPI file or a JSON Schema. | Why the service publishes contracts |
| application context | The Spring container. It is the object that creates your beans, connects them to each other and gives them to the code that asks for them. It does the same as IServiceProvider in .NET. | The bean lifecycle |
| application event | A message that one part of a Spring application publishes and other parts receive, in the same process. It is like a MediatR notification in .NET. | Application events |
| application server | A large server, such as WildFly or WebSphere, that runs many deployed applications. The applications are deployed as web application archive (WAR) or enterprise archive (EAR) files. Spring Boot services usually embed their own server instead. | EJB and the application server |
| application service | The class that holds the use cases of one feature, such as OrderService. It runs the steps of each use case, and holds no business rule. | One application service per feature |
| ArchCondition | The interface of ArchUnit for a custom check that runs on each selected class. | A custom ArchCondition |
| ArchUnit | A Java library that checks architecture rules in a unit test, such as a rule that the domain does not use Spring. ArchUnitNET is its C# fork. For the order service, see the section The architecture rules in the platform's test package. | ArchUnit: architecture rules as tests |
| aspect | A class marked @Aspect. It holds behaviour that Spring applies around many methods, such as timing every service call. | AOP: writing your own cross-cutting behaviour |
| AssertJ | The assertion library that most Java tests use. An assertion is written assertThat(actual).isEqualTo(expected). AssertJ is like FluentAssertions in .NET. | A test |
| @Async | A Spring annotation that runs a method on another thread, so the caller does not wait for the method. It works only when the call goes through a Spring proxy. | @Async |
| at-least-once delivery | Every message arrives, and some messages arrive more than once. So a consumer must handle a repeat. | What the outbox guarantees |
| atomic variable | A class such as AtomicInteger. Its operations, such as incrementAndGet, are safe to call from many threads at once without a lock. An atomic variable is like Interlocked in .NET. | Atomics |
| audit log | A business record of who changed what, and when. It is stored in the same transaction as the change. | The audit log: a business record, not a log line |
| authentication entry point | The object that Spring Security uses to answer a request that needs a login and has none. For a resource server, it answers 401. | One request through the checks |
| authority (Spring Security) | A plain string that a permission rule asks for, such as SCOPE_orders.read. | From scopes to authorities |
| auto-configuration | Spring Boot creating suitable beans for you, such as a database connection pool. Spring Boot creates a bean because a library is on the classpath and you have not defined that bean yourself. | Auto-configuration |
| auto-proxy creator | The BeanPostProcessor that wraps a bean in a proxy when an advisor matches one of its methods. It runs in the last initialisation callback, so the object other beans receive is the proxy. | Scene 7: the twist |
| autoboxing | Java automatically converting between a primitive, such as int, and its wrapper object, such as Integer. Unboxing a null wrapper throws a NullPointerException. | Wrapper types |
| AutoConfiguration.imports | The file in META-INF/spring that names the auto-configuration classes of a library. Spring Boot reads it at startup. | How Spring Boot finds the library's setup |
| @Bean | An annotation that marks a method in a configuration class. Spring manages the return value of the method as a bean. Use @Bean for types that you cannot annotate yourself, such as a client class from a library. | Beans for types you do not own |
| bean | An object that Spring creates and manages, and can inject into other objects. It is what you would register in a .NET service collection. | Beans and component scanning |
| bean definition | The recipe for one bean, written before the bean exists. It holds the class, the scope, the constructor and the dependencies. Spring writes every definition first, and creates the objects afterwards. | Scene 4: definitions, not objects |
| bean scope | How many instances of a bean Spring makes. The default scope is called singleton, and it means one instance for the whole application. The scope called prototype means a new instance each time the bean is asked for. | Lifetimes |
| Bean Validation | The Java standard for validation annotations such as @NotBlank and @Size. It has the same purpose as DataAnnotations in .NET. | Validation and error responses |
| BeanPostProcessor | An object that Spring passes every bean to while the bean is created. This object can change, wrap or replace the bean. Spring uses one such object to put proxies around beans with annotations such as @Transactional. | The bean lifecycle |
| big ball of mud | A big ball of mud is a system with no clear borders, in which any part may depend on any other part. A change anywhere can then break something anywhere else. | Strategic design |
| BigDecimal | The Java class for exact decimal numbers, used for money. It is a class, so you add with add() and not with +. You compare with compareTo() and not with >. | Money is BigDecimal |
| BOM | Short for bill of materials. It is a Maven file that sets the versions of many related libraries at once, so the libraries stay compatible. In a text file, BOM can also mean byte order mark. | Profiles and BOMs |
| bounded context | An area in which one model applies, and in which each word has one meaning. A bounded context is usually one service. | What DDD is, in this book's words |
| BSON | The format in which MongoDB and Amazon DocumentDB store a document. It has types that JSON does not have, such as ObjectId, Date and Decimal128. The .NET attributes such as [BsonId] are named after it. | What the stored document holds: types, names and _class |
| Buildpacks | A tool that turns an application into a container image without a Dockerfile. The build-image task of Spring Boot uses it. | Containers |
| business-expert test | A test for where a line of code belongs. Read the line to a person who knows the business. If they call it a rule, it belongs in the domain. | The business-expert test |
| bytecode | The instructions that the Java compiler javac produces from your source code. The JVM runs bytecode in the same way that the .NET runtime runs IL. | From source to a running program |
| cache manager | The bean that holds the caches for @Cacheable. By default it is a ConcurrentMapCacheManager. For the order service, see the section Switching caching on, and the default cache manager. | Choosing the cache store |
| @Cacheable | A Spring annotation that stores the result of a method. A second call with the same arguments returns the stored value without running the method. | Caching |
| carrier thread | The platform thread on which the JVM runs a virtual thread. When the virtual thread blocks, the JVM unmounts it and the carrier runs another one. There are as many carriers as CPUs by default. | The colour problem, and how Java sidestepped it |
| CAS | Short for compare-and-set. It is an operation that changes a value only if the value is still what you expected. The check and the change happen as one step that cannot be divided. Atomic classes use compare-and-set instead of a lock. | Atomics |
| CDS | Short for class data sharing. It is a feature of the JVM. The JVM stores already-parsed classes in a file and maps the file into memory at startup, so the program starts faster. | Startup without going native |
| CGLIB | Short for Code Generation Library. It is the library that Spring uses to create a proxy as a subclass of your class at runtime. Spring Boot uses it by default, even for a bean that implements an interface. | Two kinds of proxy |
| characterisation test | A test that records what code does today, right or wrong, before you change the code. | What to test first in an older service |
| charset | The encoding that turns text into bytes and back, such as UTF-8. Java 18 and later use UTF-8 by default. Passing a charset explicitly keeps older code correct too. | Charsets, and why to always pass one |
| checked exception | An exception that the compiler makes you handle. You must catch it, or declare it with throws. C# has no checked exceptions. | Checked exceptions |
| circuit breaker | An object that stops calls to a service that keeps failing. While the breaker is open, calls fail at once. After a wait, it lets test calls through. For the order service, see the section Retry and circuit breaker: what each one is for. | Resilience: the Polly answer |
| class file | A file ending in .class that holds the bytecode of one class. The compiler writes one class file for each class. | From source to a running program |
| class loader | The part of the JVM that finds a class the first time your code uses the class, and reads the bytecode of the class. | Class loading, at the level you need |
| classpath | The ordered list of folders and Java archive (JAR) files that the JVM searches when it needs a class. The JVM uses the first match. | The classpath is a search list |
| classpath resource | A file that is packaged with your classes, such as a configuration file or a template. Code reads it through the classpath, not from a path on disk. | Resources live on the classpath too |
| client credentials | The OAuth 2.0 grant in which a service gets a token for itself, with its own client ID and secret. | The service's own token for the stock service |
| clock skew | How far the clocks of the token issuer and the service may differ when the times in a token are checked. Spring Security allows 60 seconds, and ASP.NET Core allows 5 minutes. | What Spring checks in each token |
| CME | Short for ConcurrentModificationException. It is thrown when a collection is changed while a loop over that collection is still running, even on a single thread. | Modifying while iterating |
| code cache | The memory outside the heap where the JVM keeps the machine code its compilers made. It is 240 MB by default, split into a profiled, a non-profiled and a non-method part. | Scene 3: the interpreter and the two compilers |
| code first (for a contract) | A way to keep a contract. The contract is generated from the code, committed to the repository, and checked by a test. | Code first: springdoc reads the code |
| compact constructor | A record constructor that is written without a parameter list. It is used to check or adjust the values before they are stored. | Checking values: the compact constructor |
| Comparator | An object that decides the order of two values, like IComparer<T> in C#. The natural order of a class itself comes from Comparable, which is like IComparable<T> in C#. | Ordering |
| CompletableFuture | The Java class that is closest to Task<T> in C#. It holds a result that arrives later. It has methods such as thenApply that chain more work onto the result. | Translation |
| component scanning | Spring searching your packages at startup for classes marked @Component, @Service and similar annotations. Spring registers each class that it finds as a bean. | Beans and component scanning |
| compound index | An index on several fields, in a fixed order. | A named index for each query |
| compressed references | A JVM default for heaps below 32 GB. A reference inside an object is stored as a 32-bit offset, so it takes 4 bytes and not 8. | Scene 2: the object |
| configuration metadata | The file META-INF/spring-configuration-metadata.json, which Spring Boot's configuration processor writes. The IDE reads it to complete the names of settings. | Completion in the IDE: the configuration metadata |
| @ConfigurationProperties | An annotation that binds a group of settings from application.yml to the fields of a class or record. In ASP.NET Core, IOptions<T> binds a section in the same way. | IOptions becomes @ConfigurationProperties |
| conformist | A conformist is a downstream context that uses the upstream model as it is, with no translation. It costs little, but every change upstream reaches the downstream model. | Strategic design |
| consistency boundary | The edge of an aggregate. Everything inside it is checked and saved together, in one transaction. | What an aggregate is for |
| constructor injection | Giving a class what it needs through its constructor parameters. Spring provides the values when it creates an object of the class. Constructor injection is the recommended style, and ASP.NET Core uses the same style. | Beans and component scanning |
| context map | A context map is a picture of the bounded contexts and the relations between them. It shows who depends on whom, and how each border is handled. | Strategic design |
| contract first | A way to keep a contract. The contract is written first, and code is generated from it. | Contract first: openapi-generator |
| controller advice | A class of exception handlers that apply to every controller. It is used to turn exceptions into error responses in one place. | Global exception handling |
| core domain chart | A core domain chart places each subdomain by how complex it is and by how much it sets the business apart. It shows where modelling effort pays. | Strategic design |
| core subdomain | A core subdomain is the part of the business that makes customers choose you. It gets your best people and your richest model, because no vendor can build it for you. | Strategic design |
| coroutine | The lightweight form of concurrency in Kotlin. A coroutine is a function marked suspend that can pause without blocking a thread. It is close to async and await in C#. | Coroutines against virtual threads |
| CORS | Short for cross-origin resource sharing. It is the browser rule that decides whether a web page may call an API on another domain. The server allows the call with response headers. | Middleware |
| CQRS | Short for command query responsibility segregation. It is a design with one model for changes and a separate model for reads. | One model for reads and writes: CQRS or not |
| CSRF | Short for cross-site request forgery. It is another site making a user's browser send a request with that user's cookies. Spring Security blocks it by default for requests that change data. | Two chain traps: rule order and CSRF |
| customer-supplier | A customer-supplier relation is one in which the downstream context is a customer of the upstream context. The upstream team plans its changes with the needs of the downstream team. | Strategic design |
| database migration | One versioned change to the database, run once in each environment. In the order service, each migration is a MongoMigration bean. | One migration: an interface and a bean |
| DDD | Short for Domain-Driven Design. It is a way to design software around the business rules. Each rule is written once, in the object that it protects. | The problem DDD solves |
| deoptimisation | The JVM discards compiled code and runs the method in the interpreter again, because a bet the compiler made, such as one class at a call site, was false. It usually recompiles with the new profile. | Scene 4: the compiler's bets |
| dependency mediation | Maven's rule for choosing one version of a library when the tree asks for several. The nearest declaration wins, and at equal depth the first one declared. | Scene 3: the version that got in |
| dependency scope | A setting on a Maven dependency that says when the dependency is available. compile means everywhere, and test means tests only. runtime and provided are for the cases in between. | Coordinates |
| dependencyManagement | The section of a POM that sets versions. It adds no dependency. It only fills in the version of a dependency that is declared without one. | One place for every version: managed versions and BOMs |
| design group | The two or three most experienced engineers, with a business expert. They decide boundaries, aggregates, rules, workflows and levels. | Who decides what |
| detached entity | An entity that was loaded by a persistence context that is now closed. Changes to the entity are no longer tracked, and its lazy fields can no longer load. | Lazy loading and detached entities |
| DI | Short for dependency injection. It means that a class receives the objects it needs, usually through its constructor, and does not create them itself. Spring and ASP.NET Core both do dependency injection. | Dependency injection and configuration |
| direct buffer | A block of memory outside the Java heap, made with ByteBuffer.allocateDirect, which the kernel can fill without a copy. The JVM caps the total at the maximum heap unless MaxDirectMemorySize says otherwise. | Scene 7: the leak outside the heap, and the JVM's second cap |
| dispatch | One pass of a request through the servlet filters. An error page is a second dispatch of the same request. | One request through the checks |
| DLQ | Short for dead-letter queue. It is a queue that receives the messages that failed too many times, so that a person can look at them. | Failures, redrive and the dead-letter queue |
| DMN | Short for Decision Model and Notation. It is a standard of the Object Management Group (OMG) for decision tables, which a rules engine can evaluate. | Rules engines and decision tables |
| document class | A class that holds only the stored shape of an aggregate, such as OrderDocument. It is kept apart from the domain class. | The full style: a document class |
| domain event | A record of something that happened inside the service, named in the past tense, such as OrderShipped. The aggregate raises it, and it stays inside the service. | Domain events and integration events |
| domain service | A class in the domain, with no state, that checks a rule that needs more than one aggregate, such as ShippingPolicy. It receives what it needs and loads nothing. | Worked example: no shipping while a note needs action |
| domain storytelling | Domain storytelling is a workshop in which a business expert tells one real story, and a moderator draws it as pictures and arrows. It finds the words and the steps of one process. | Strategic design |
| drift check | A test that fails when the committed contract differs from the contract that the code generates. | Exporting the contract, and the drift check |
| DSL | Short for domain-specific language. It is a small, focused way of writing one kind of thing, often as a chain of method calls. One example is the configuration of Spring Security. | The filter chain |
| DTO | Short for data transfer object. It is a simple class, often a record, that carries data in or out of an API without exposing your entities. | Records as DTOs |
| dual-write problem | Writing to a database and to a message broker that share no transaction. One of the two writes can then be lost. | The dual-write problem |
| EAR | Short for enterprise archive. It is a file that packages several WAR and JAR files for an application server. You will only see it in older systems. | EJB and the application server |
| effective POM | The one model of a project that Maven builds before any goal runs. It layers the Super POM, each parent, the project's own POM and the active profiles. mvn help:effective-pom prints it. | Scene 2: the POM that Maven really uses |
| EJB | Short for Enterprise JavaBeans. It is the component model of the old Java EE application servers. Spring replaced it in most new code. | EJB and the application server |
| entity | A class marked @Entity that Jakarta Persistence maps to a database table. It is like an entity in Entity Framework. In Domain-Driven Design (DDD), an entity is an object with an identity of its own, equal to another only when the IDs are equal. For this sense, see the section Inside the aggregate: entities and value objects. | An entity |
| enum | A type with a fixed set of named values. In Java each value is a full object, so an enum can have fields, constructors and methods. | The shape of the difference |
| environment post-processor | A class that changes the settings at startup, before the application context exists. It is registered in META-INF/spring.factories. | The platform's defaults, below everything else |
| error label | A tag on a MongoDB error, such as TransientTransactionError, that tells the client what kind of failure it is. | Write conflicts, and what Spring retries |
| event publication registry | The store in Spring Modulith of the events that transactional listeners must receive. In the order service, it is the outbox. | Spring Modulith's event publication registry |
| event sourcing | Storing the list of events of an aggregate instead of its current state. The order service does not use it. | What to use, and what to keep light |
| event storming | A workshop in which developers and business experts write down the events of the business in time order. | What to use, and what to keep light |
| eventual consistency | Two parts of a system may disagree for a while, and agree later, for example after an event arrives. | Between services: no shared transaction |
| exception kind | One of the three domain exception classes. The kind decides the HTTP status of the answer, such as 404 for NotFoundException. | One handler for every problem |
| ExecutorService | An object that runs tasks on threads that it manages. For example, it can use a fixed pool of threads, or one virtual thread for each task. You submit work and receive a Future. | Fixed thread pools |
| externalisation | The step in Spring Modulith that sends @Externalized events to a message broker after the commit. | @Externalized: from the outbox to SNS |
| failsafe | The Maven plugin that runs the integration tests, the *IT classes, in the integration-test and verify phases. | Fast tests and integration tests |
| fat JAR | One JAR file that holds your application and every library that the application needs, so java -jar app.jar runs it. Spring Boot builds a fat JAR by default. | The fat JAR |
| feature design note | One page for each feature, with its aggregates, states, rules with examples, events and partners. | The feature design note |
| feature package | A top-level package for one business capability that a business user recognises, such as order or note. | Features at the top, never technologies |
| FIFO queue | Short for first-in, first-out queue. It is a queue in Amazon Simple Queue Service (SQS) that keeps the order of the messages in a message group. It also removes duplicates for 5 minutes. | FIFO queues, the other choice |
| final field | The keyword final means that a thing cannot change after it is set. A final field or variable is assigned once. A final class cannot be extended, and a final method cannot be overridden. | final is not readonly, quite |
| Flyway | A tool that runs numbered SQL migration scripts against the database at startup. It has the same purpose as EF Core migrations in .NET. Liquibase is the main alternative. | Migrations |
| frozen rule | An ArchUnit rule that records the violations of today, and fails only on new ones. | Adding the rules to an existing service |
| fully qualified name | A class name with its package in front, such as com.acme.billing.Invoice. The JVM uses this name to find the class file. | One program, start to finish |
| functional interface | An interface with exactly one method to implement, such as Runnable or Function<T, R>. You can write a lambda in its place, as you write a lambda for a delegate in C#. | Lambdas and method references |
| G1 | The default garbage collector of the JVM on most machines. It balances short pauses against throughput. | Choosing a collector |
| gatherer | A custom step that is added to a stream with gather(). It is for operations that are not built in, such as sliding windows. It is final since Java 24. | Gatherers |
| GC | Short for garbage collector. It is the part of the JVM that frees memory that no object uses any more. The JVM offers several garbage collectors, such as G1 and ZGC. | Choosing a collector |
| generic subdomain | A generic subdomain is a part that every business needs in the same way, such as sending email. You buy it or use an open-source product, because building it gives you nothing. | Strategic design |
| GraalVM | A Java Development Kit (JDK) that can compile a Java application ahead of time into a native executable. The native executable starts in milliseconds, but it limits reflection. | GraalVM native-image |
| graceful degradation | A service keeps working in part when one of its dependencies fails. | A failed call becomes a 503 |
| Gradle | A Java build tool, and the main alternative to Maven. Its build files are written in a small Groovy or Kotlin language, not in XML. | Gradle against Maven |
| happens-before | The rule in the Java memory model for when one thread is guaranteed to see the writes of another thread. For example, the guarantee holds after a lock is released or after a volatile field is written. | volatile means more in Java |
| health group | A named set of health checks, such as readiness. | The health probes: liveness without the database |
| health indicator | A small class that reports whether one dependency, such as the database, is up. Actuator combines all health indicators into /actuator/health. This is like health checks in ASP.NET Core. | Custom health checks |
| heap | The memory where the JVM keeps objects. Its maximum size is set with -Xmx, or as a share of a container's memory. | The memory model |
| hexagonal architecture | A layout built from ports and adapters, often with adapter/in and adapter/out folders. | Why not adapter/in and adapter/out |
| Hibernate | The most widely used implementation of Jakarta Persistence, and the implementation that Spring Boot uses by default. | The landscape |
| HQL | Short for Hibernate Query Language. It is the query language of Hibernate, and a superset of the Jakarta Persistence Query Language (JPQL). Queries are written against entities, not against tables. | Repositories |
| idempotency key | A value that the caller chooses once and sends with every attempt of a call. The other side uses it to recognise a repeat. The order service sends the order ID. | Retry only what is safe to repeat |
| idempotent | Safe to run twice. Doing it a second time has the same effect as doing it once. | Retry only what is safe to repeat |
| inbox | A collection where a consumer stores the ID of each event it handled, in the same transaction as the change. A repeat of the event is then skipped. | Why every consumer needs an inbox |
| initialiser block | A block of code in a class body. A static initialiser block runs when the class is loaded. An instance initialiser block runs when an object is created. C# has static constructors instead. | Initialiser blocks |
| inner class | A class declared inside another class without static. Each instance holds a hidden reference to the outer object. A nested class in C# holds no such reference. | Inner classes capture the outer instance |
| Integer cache | Java keeps ready-made Integer objects for the values -128 to 127. So == on two small Integers can be true, while the same code on larger numbers is false. | The Integer cache |
| integration event | A versioned message that leaves the service, such as OrderPlacedV1. It is a contract with other services, so it changes carefully. | Domain events and integration events |
| interceptor | An object of Spring MVC, the model-view-controller (MVC) web framework of Spring, whose methods run around a controller method. preHandle runs before the method, postHandle after it, and afterCompletion at the end. An interceptor is like an action filter in ASP.NET Core. For the order service, see the section What an interceptor sees when a request fails. | Middleware |
| invariant (business rule) | A business rule that must hold after every change, such as the rule that an order has at most 50 lines. | Art or science? Questions you can apply |
| IoC | Short for inversion of control. It means that the framework, not your code, creates objects and calls their methods. Dependency injection is the most common form. | Beans and component scanning |
| ISO-8601 duration | A duration written as text, such as PT1M for one minute or PT15M for fifteen minutes. | @Scheduled with durations from settings |
| isolation level | How much one transaction may see of the unfinished changes of another transaction. One level is READ_COMMITTED. Isolation levels are the same database idea as in .NET. | Isolation |
| J2EE | Short for Java 2 Platform, Enterprise Edition. It is the old name for enterprise Java. The name later became Java EE, and it is now Jakarta EE. | Four eras |
| Jackson | The JSON library that Spring Boot uses by default. It does the same as System.Text.Json in .NET. | Jackson against System.Text.Json |
| Jakarta EE | The set of enterprise Java standards, such as Persistence, Validation and Servlet. Its old name is Java EE. Its packages moved from javax to jakarta. | The javax to jakarta rename |
| JAR | Short for Java archive. It is a zip file of class files and resources, usually with a manifest. It does the same as a .dll assembly in .NET. | Running with -cp or -jar |
| Java module | A named group of packages, described in a module-info.java file. The file states what the module exports and what the module needs. Most applications never write a module. | The problem it solves |
| java.time | The package of date and time types added in Java 8, such as LocalDate and Instant. Use it for all new code instead of Date and Calendar. | java.time |
| Javadoc comment | A comment that starts with /**. The javadoc tool turns these comments into web pages. A Javadoc comment is like an XML documentation comment in C#. For a comment on a package, see the section Documenting a package. | Named arguments |
| JAXB | Short for Java Architecture for XML Binding. Its name today is Jakarta XML Binding. It is the standard for turning XML into Java objects and back. It was removed from the JDK in Java 11 and is now a separate library. | What Java removed |
| JDBC | Short for Java Database Connectivity. It is the low-level standard for talking to a database, like ADO.NET. Jakarta Persistence and the JdbcClient of Spring are built on it. | The landscape |
| JDK | Short for Java Development Kit. It is the compiler, the JVM and the tools that you need to build and run Java. It is like the .NET SDK. | JDK or JRE: which to install |
| JEP | Short for JDK Enhancement Proposal. It is the numbered document that describes one change to Java, such as JEP 444 for virtual threads. | The release train |
| JFR | Short for JDK Flight Recorder. It is a low-overhead recorder built into the JVM. It captures what a running program is doing, for later analysis. | JFR: the recorder built into the JDK |
| JIT | Short for just-in-time compiler. It is the part of the JVM that compiles frequently run bytecode to machine code while the program runs. RyuJIT does the same in .NET. | The JIT and warmup |
| jlink | A JDK tool that builds a trimmed Java runtime, which contains only the modules that your application needs. | jlink and jpackage |
| JMH | Short for Java Microbenchmark Harness. It is the standard tool for measuring small pieces of Java code. It does the same as BenchmarkDotNet in .NET. | JMH against BenchmarkDotNet |
| JMS | Short for Jakarta Messaging. It is a standard Java API for sending and receiving messages through a broker such as ActiveMQ. Kafka and RabbitMQ are usually used through their own Spring clients. | JMS and JmsClient |
| JMX | Short for Java Management Extensions. It is built into the JVM, and it exposes the metrics and operations of a running program to monitoring tools. | Actuator endpoints |
| JNDI | Short for Java Naming and Directory Interface. It is a lookup service that old application servers used to provide resources such as database connections. | Single values and precedence |
| JNI | Short for Java Native Interface. It is the way that Java code calls native C or C++ code, like P/Invoke in .NET. | When virtual threads are the wrong tool |
| journey test | A test that runs one business story through the whole service, with a real database and a real message broker. | The journey test: real MongoDB and a real SNS and SQS emulator |
| JPA | Short for Jakarta Persistence. It is the Java standard for mapping objects to database tables. It has the same purpose as Entity Framework in .NET. Hibernate implements Jakarta Persistence. | Data access, JPA and migrations |
| jpackage | A JDK tool that wraps an application and a Java runtime into a native installer, such as an .msi or a .dmg. | jlink and jpackage |
| JPMS | Short for Java Platform Module System. It is the module system that was added in Java 9. It lets a library hide its internal packages. | Modules, JPMS and the missing internal |
| JPQL | Short for Jakarta Persistence Query Language. It is a language for SQL-like queries that are written against entities and their fields, not against tables and columns. | Repositories |
| JRE | Short for Java Runtime Environment. It is a JVM and its libraries without the compiler. Oracle stopped shipping a separate JRE in Java 11, but some vendors, such as Temurin, still offer one. | JDK or JRE: which to install |
| JShell | The Java read-eval-print loop (REPL). You type a line of Java and see the result immediately, without a class or a main method. | jshell: run one line of Java |
| JSP | Short for JavaServer Pages. It is an old way of writing HTML pages with Java code inside them, like classic ASP. Modern Spring uses templates or a separate front end. | Web frameworks, in order of extinction |
| JSpecify | A standard set of annotations, such as @Nullable, that mark which values may be null. With them, tools can warn in the same way as nullable reference types in C#. | JSpecify: the annotation standard |
| JSR | Short for Java Specification Request. It is a numbered proposal for a Java standard. One example is JSR 305, an older attempt at null annotations. | Null-safety in the Spring API |
| JUnit | The standard Java testing framework. It does the same as xUnit in .NET. A test is a method marked @Test. | A test |
| JVM | Short for Java Virtual Machine. It is the program that loads bytecode and runs it, in the same way that the CLR runs IL in .NET. | From source to a running program |
| JWKS | Short for JSON Web Key Set. It is the list of public keys of a token issuer. Each key has a key ID, called kid. | Where the signing keys come from |
| JWT | Short for JSON Web Token. It is a signed token that carries the identity and claims of a user. It is sent in the Authorization header. It works the same way as in ASP.NET Core. | Concept mapping |
| Kotlin | A language from JetBrains that runs on the JVM and uses Java libraries directly. Many C# developers find the syntax of Kotlin closer to C# than the syntax of Java. | What Kotlin gives back that Java took away |
| lambda | A short unnamed function written with an arrow, such as x -> x * 2. C# writes =>. Java writes ->. | Lambdas and method references |
| layered jar | A Spring Boot JAR that is split into four folders. Each folder becomes one Docker layer, so a rebuild reuses the layers that did not change. | The Dockerfile: a layered jar |
| lazy loading | Loading the related data of an entity only when you first use that data. If the persistence context has closed by then, Hibernate throws LazyInitializationException. | Lazy loading and detached entities |
| lazy-resolution proxy | The object that @Lazy on a parameter injects. It finds the real bean at its first method call. | @Lazy: a proxy that breaks the cycle, and the other ways out |
| lifecycle phase | One step of a Maven build, such as compile, test or package. When you run a phase, Maven first runs every phase before it. | The lifecycle |
| light style | One of the three levels of Domain-Driven Design (DDD) in the order service. The full style keeps a separate document class for storage. In the light style, the entity is its own stored document, and the simple level has no domain model at all. | Three levels in one service |
| lite mode | A @Configuration class with proxyBeanMethods = false. Its @Bean methods are plain factory methods, and Spring creates no subclass of the class. | proxyBeanMethods = false |
| liveness probe | Two health checks that a platform such as Kubernetes calls. Liveness asks whether to restart the service. Readiness asks whether to send it traffic, and it includes the database. | The health probes: liveness without the database |
| local environment | The shared repository with local copies of the dependencies of the services, such as MongoDB, HTTP stubs and a token issuer. | The local environment: one repository for every service |
| local repository | The folder ~/.m2/repository, where Maven keeps every library that it downloads. It is like the NuGet global packages folder. | Where your dependencies actually live |
| Logback | The logging library that Spring Boot uses by default. Your code logs through the Simple Logging Facade for Java (SLF4J), and Logback writes the output. | logback-spring.xml and JSON logging |
| Lombok | A library that generates getters, setters, constructors and similar code from annotations when you compile. Records remove much of the need for Lombok. | A word on Lombok |
| lost update | A save that writes old data over a newer change, with no error. | Optimistic locking with @Version |
| LTS | Short for long-term support. An LTS release is a Java release that vendors keep patching for years. Java 21 and Java 25 are LTS releases. | The release train |
| manifest | A small text file inside a JAR, at META-INF/MANIFEST.MF. It can name the class that holds main, and it can list other JARs to load. | Inside a JAR: the manifest |
| MapStruct | A library that generates object-to-object mapping code when you compile. It has the same purpose as AutoMapper in .NET. | MapStruct against AutoMapper |
| Maven | The most common Java build tool. It reads a pom.xml file and downloads the libraries listed there. Then it compiles, tests and packages the project. It is like MSBuild with NuGet in .NET. | pom.xml against csproj |
| Maven coordinates | The three values that identify a library in Maven. They are groupId for the organisation, artifactId for the library, and version. They are like the package id and version of a NuGet package. | Coordinates |
| Maven profile | A named set of settings in pom.xml that you switch on for some builds only, with mvn -P name. | Profiles and BOMs |
| Maven wrapper | The mvnw script in a project. It downloads and runs the right Maven version, so everyone builds with the same version. | The wrapper |
| MDC | Short for mapped diagnostic context. It is a set of key and value pairs, such as a request id, that are attached to the current thread. Every log line then includes them. A mapped diagnostic context is like ILogger scopes in .NET. | Logging |
| meta-annotation | An annotation written on another annotation, such as @PreAuthorize on @OrdersWrite. The new annotation then does what the meta-annotation does. | Permission annotations: each scope string written once |
| metaspace | The memory outside the heap where the JVM keeps class definitions. Loading very many classes can use all of it. | The memory model |
| method reference | A short way to pass an existing method where a lambda is expected. It is written Type::method, such as String::length. | Lambdas and method references |
| method security | The proxy that checks @PreAuthorize and the related annotations when a method is called. | How method security works |
| Micrometer | The metrics library that Spring Boot uses. It does the same as System.Diagnostics.Metrics in .NET. It sends counters and timers to Prometheus and other systems. | Metrics |
| Mockito | The standard Java library for mock objects. It does the same as Moq in .NET. | Mocking |
| MockMvc | A Spring test helper that sends fake HTTP requests to your controllers without starting a server. | Testing a controller |
| module path | The list of modules that the JVM loads when the module system is switched on. It is like the classpath, but for modules. | Classpath vs module path |
| MongoDB collection | A named set of documents in MongoDB or Amazon DocumentDB. It has the same purpose as a table in a relational database. @Document("orders") names the collection that a class is stored in. | A document, not a row |
| MongoDB document | One stored object with its nested objects and lists inside it, such as an order with its lines. It has the same purpose as a row in a relational database. Every document has an _id. | A document, not a row |
| MVC | Short for model-view-controller. It is the pattern behind Spring MVC, which is the web framework of Spring for controllers. Spring MVC does the same as ASP.NET Core MVC. | Controllers, routing and binding |
| N+1 problem | Loading a list with one query, and then running one more query for each item to fetch the related data of that item. It is the same trap as in Entity Framework. | Counting the queries in a loop |
| Native Memory Tracking | The JVM's own account of its memory, region by region, as reserved and committed bytes. It is off by default, and jcmd VM.native_memory reads it. | Scene 2: the rooms of the house |
| natural key | A key that comes from the business, such as a type and a code, instead of a generated ID. | Seed data, upserted by a natural key |
| nested configuration | A static @Configuration class inside another one. Its conditions apply only to its own beans. | The eight auto-configurations |
| NPAPI | Short for Netscape Plugin API. It is the old browser plug-in interface that Java applets needed. Browsers removed it, so the Java plugin could no longer run applets in a browser. | Web frameworks, in order of extinction |
| NPE | Short for NullPointerException. It is thrown when code uses a null reference. It does the same as NullReferenceException in C#. | The gap, stated plainly |
| ObjectId | The kind of id that MongoDB generates for a document. Spring Data MongoDB stores a generated String id as an ObjectId. | A document, not a row |
| ObjectProvider | A parameter type that finds a bean when the code asks for it. The code then decides what to do when there is no such bean, or several. | ObjectProvider: a bean that may be missing, or new each time |
| OIDC | Short for OpenID Connect. It is the standard login protocol built on OAuth 2.0. It is used with identity providers such as Entra ID or Keycloak. | Concept mapping |
| OOM | Short for OutOfMemoryError. It is thrown when the JVM cannot get more memory of some kind, such as heap or metaspace. It is an Error, not an Exception. | Errors, not exceptions |
| OOM killer | The part of the Linux kernel that kills a process when a container reaches its memory limit. It sends SIGKILL, so no Java code runs afterwards, and the exit code is 137. | Scene 1: what memory is to a process |
| open host service | An open host service is an interface that a context offers to every consumer, in one documented form. It saves the upstream team from building one integration for each consumer. | Strategic design |
| OpenTelemetry | The open standard for traces, metrics and logs across services. Spring Boot and .NET both support it. | Tracing |
| optimistic locking | A version number on each document. A save fails when someone else saved the document in between. It is like a concurrency token in EF Core. | Optimistic locking with @Version |
| Optional | A container that holds a value or nothing. It is used as a return type to say that a result may be missing. It is not a replacement for every null. | Optional is for return values |
| optional dependency | A dependency that a library needs, but that the projects using the library do not get. It is like PrivateAssets="all" in .NET. | Optional dependencies: test support without test libraries |
| ORM | Short for object-relational mapper. It is a library that maps classes to database tables, such as Hibernate or Entity Framework. | The landscape |
| @Override | An annotation that says that a method replaces a method from a parent class or interface. The compiler reports an error if nothing is actually overridden. | Always write @Override |
| package | A named group of classes, such as com.acme.billing. It is like a namespace in C#, but its folders are expected to match the name, and it is also an access boundary. | Packages are not namespaces |
| package-info.java | The file that holds the declaration of a package and its documentation comment. A C# compiler warns about a documentation comment on a namespace. | Documenting a package |
| package-private | The default access in Java when you write no modifier. A package-private class or member is visible to every class in the same package, and nowhere else. | The mapping |
| parent POM | A POM that a child names in its <parent> element. The child inherits the settings of the parent. A parent POM is like Directory.Build.props in .NET. For the order service, see the section The parent POM, on top of Spring Boot's parent. | A Spring Boot pom |
| partnership | A partnership is a relation in which two contexts succeed or fail together. So their two teams plan their changes together. | Strategic design |
| PECS | Short for producer extends, consumer super. It is the rule for wildcards. Use ? extends T for a collection you read from, and ? super T for a collection you add to. | Variance is at the use site |
| permits | The clause in a sealed class or interface that lists the only types allowed to extend it. | Sealed types: Java's discriminated unions |
| persistence adapter | The class that implements a domain repository port with a database, such as OrderPersistenceAdapter. | The adapter and the platform's base class |
| persistence context | The set of entities that one EntityManager is tracking. It is like the change tracker of a DbContext. It usually lasts as long as one transaction. | Lazy loading and detached entities |
| @PersistenceCreator | A Spring Data annotation that marks the constructor Spring Data uses to load a stored object. | The light level: the note feature |
| pinning | A virtual thread staying attached to its carrier thread while the virtual thread blocks, so the carrier thread cannot run other work. Java 24 removed the main cause, which was synchronized blocks. | Pinning: the one performance trap |
| platform library | The one shared library that holds the technical code of every service, such as security and logging. It holds no business code. | What the library gives a service |
| platform thread | An ordinary Java thread, which is backed by exactly one operating system thread. Every Java thread was a platform thread before virtual threads. | The colour problem, and how Java sidestepped it |
| pluginManagement | The section of a POM that configures plugins. The configuration applies only where a POM uses the plugin. | Fast tests and integration tests |
| pointcut | An expression that picks which methods an aspect wraps, such as execution(public * *(..)) for every public method. | AOP: writing your own cross-cutting behaviour |
| poison message | A message that fails every time it is handled. A dead-letter queue (DLQ) takes it out of the normal queue. | Failures, redrive and the dead-letter queue |
| POJO | Short for plain old Java object. It is an ordinary class with fields, getters and setters, and no framework base class. | The property gap |
| POM | Short for Project Object Model. It is the pom.xml file that describes a Maven project. It does the same as a .csproj file in .NET. | pom.xml against csproj |
| port (ports and adapters) | An interface that the inner code of a service declares in its own types, and that outer code implements, such as OrderRepository. | Repositories in the domain, other services in the application |
| preview feature | A language or library feature that is finished but not yet final, and may still change. You must switch it on with --enable-preview to use it. | What it targets |
| primitive stream | A stream of int, long or double values. One example is IntStream. A primitive stream avoids boxing each number, and it adds methods such as sum(). | Primitive streams |
| problem space | The problem space is the business as it is, with its needs and its rules. You study it before you decide what to build. | Strategic design |
| ProblemDetail | The Spring class for an RFC 9457 error response body, called a problem. It does the same as ProblemDetails in ASP.NET Core. For one handler that answers every error of a service with a problem, see the section One handler for every problem. | ProblemDetail |
| process manager | An object or a service that keeps the state of a long flow and decides its next step. | Long flows: a workflow engine or a process manager |
| property placeholder | A reference such as ${MONGO_URI} or ${AWS_REGION:eu-west-2} in a setting. Spring replaces it with another property or an environment variable. The text after the colon is the default. For the order service, see the section Placeholders, defaults and the environment. | SpEL |
| property source | One entry in the ordered list of settings in Spring, such as application.yml or the environment variables. The first source that has a key wins. | The platform's defaults, below everything else |
| proxy | An object that Spring puts in front of your bean. The proxy adds behaviour, such as a transaction, before it calls your method. Calls from inside the bean skip the proxy. | What a proxy actually is |
| published language | A published language is a documented format for the data that crosses a border, such as a JSON Schema for an event. It is separate from the internal model of each side. | Strategic design |
| R2DBC | Short for Reactive Relational Database Connectivity. It is a non-blocking database API for reactive applications. It is like Java Database Connectivity (JDBC), but for reactive code. | When it is still the right answer |
| raw message delivery | A setting of a subscription in Amazon Simple Notification Service (SNS). It sends the message as it is, with no JSON envelope around it. | The SNS envelope, and the null trap |
| raw type | A generic type used without its type argument, such as List instead of List<String>. It exists for old code and switches off type checking. | Raw types |
| reactive | A style in which code describes a pipeline of asynchronous events. The code uses the Mono and Flux types of Reactor. Spring WebFlux is built on this style. | The model |
| record | A short class that holds data. Its equals, hashCode, toString and accessors are generated. A Java record is like a record in C#. Its fields cannot be reassigned. | The basics |
| record pattern | A pattern that takes a record apart into its components while matching it, such as case Point(int x, int y). | Record patterns |
| ReentrantLock | A lock object that means the same as synchronized. It also has extras, such as tryLock with a timeout. | ReentrantLock |
| rehydrate | To rebuild an aggregate from stored data, as Order.rehydrate does. It applies no rule and raises no event. | Placing versus rehydrating |
| relaxed binding | Spring Boot matching a setting to a property even when the name is written differently. So rates.timeout-seconds, rates.timeoutSeconds and the environment variable RATES_TIMEOUT_SECONDS all set the same value. | IOptions becomes @ConfigurationProperties |
| release train | The Java schedule of a new version every six months, in March and September. An LTS release comes every two years. | The release train |
| REPL | Short for read-eval-print loop. It is a prompt where you type code and see the result immediately. The REPL of Java is called JShell. | jshell: run one line of Java |
| replica set | A group of database instances that hold the same data. One instance is the primary, and it takes every write. The other instances are replicas, and they can serve reads. An Amazon DocumentDB connection string names the replica set rs0. | Two parameters that change behaviour |
| resident memory | The pages of a process that are in physical memory right now. A container's memory limit counts this, for every process in the container, and not the Java heap alone. | Scene 1: what memory is to a process |
| retryable write | A write that the driver may send a second time when the first attempt fails on a network error. Drivers switch retryable writes on by default. Amazon DocumentDB supports retryable writes from engine version 8.0.2. | Connecting to Amazon DocumentDB |
| RMI | Short for Remote Method Invocation. It is the old Java way of calling methods on objects in another process, like .NET Remoting. It depends on Java serialization. | Java's built-in serialization, and why to avoid it |
| rollback rule | The rule for which exceptions undo a Spring transaction. By default, an unchecked exception undoes the transaction, and a checked exception does not. | The rollback rule |
| rollback-only | The mark that a failed method puts on a transaction that it joined. The whole transaction then rolls back, even if the caller catches the exception. | Joining the caller's transaction |
| safepoint | A point where every Java thread has stopped at a poll and the JVM knows the state of each stack. The collector, a thread dump and deoptimisation need one. -Xlog:safepoint logs each with the time to reach it. | Scene 5: the stop, and the slow request |
| saga | A flow across several services in which each step has its own undo step. If a later step fails, the earlier steps are undone. | Between services: no shared transaction |
| SAM | Short for single abstract method. It is the name for an interface with exactly one method to implement. You can write a lambda in place of such an interface. Such an interface is also called a functional interface. | Lambdas and method references |
| @Scheduled | A Spring annotation that runs a method on a timer or a cron schedule. It is like a hosted background service in .NET. | Scheduling |
| scope (in a token) | A word in a token that names one permission, such as orders.read. Spring Security turns each scope into an authority with the prefix SCOPE_. | Who calls the order service, and what each caller may do |
| ScopedValue | A value that is available to a block of code and to every method that the block calls. It is also available to threads started in a structured scope. It replaces AsyncLocal-style context. | ScopedValue replaces AsyncLocal |
| SDKMAN | A command-line tool that installs Java versions and switches between them. | Installing and switching |
| sealed type | A class or interface that lists every type allowed to extend it, so a switch over it can cover every case. | Sealed types: Java's discriminated unions |
| security context | Where Spring Security keeps the caller of the current thread. It holds one Authentication, or none. | The security context: one per thread |
| seed data | Lookup values that are part of the code of a service, such as the delivery methods. A migration loads them into the database. | Seed data, upserted by a natural key |
| self-invocation | A bean calling one of its own methods. The call skips the Spring proxy, so annotations such as @Transactional on that method do nothing. | Self-invocation: a call from inside the class |
| semantic version | A version written as major.minor.patch. Only a new major version may break the users of a library. | Versions, and the rules for upgrading |
| separate ways | Separate ways is a relation in which two contexts do not integrate at all. Each context meets its own need, because integration would cost more than it gives. | Strategic design |
| sequenced collection | A collection with a defined order, and with methods such as getFirst() and reversed(). Sequenced collections were added in Java 21. | Sequenced collections |
| servlet | The Jakarta standard for handling an HTTP request in Java. Spring MVC runs on top of it. A servlet filter does the same as middleware in ASP.NET Core. | Middleware |
| shared kernel | A shared kernel is a small part of a model that two contexts own together. No team may change it alone, so it must stay small and stable. | Strategic design |
| ShedLock | A library that lets one instance at a time run a scheduled job. It keeps a lock in the database, and the other instances skip the run. For the order service, see the section Several instances, and the lock. | A job that must run once: ShedLock and Quartz |
| SimpleKey | The default cache key that Spring builds when a cached method has several arguments. | Cache keys |
| SLF4J | Short for Simple Logging Facade for Java. It is the logging interface that Java code writes to, like ILogger in .NET. A library such as Logback does the writing. | SLF4J and Logback against ILogger and Serilog |
| SmartInitializingSingleton | A Spring callback interface. Its method runs after every singleton bean exists, and before the web server and the other lifecycle beans start. | Work at startup: migrations before traffic |
| solution space | The solution space is the software that you design to answer the problem space. Its parts are models, contexts and services. | Strategic design |
| span | The part of the work of a trace that one service does, with its own span ID. | Tracing with the OpenTelemetry starter |
| SpEL | Short for Spring Expression Language. It is a language for small expressions inside annotations and configuration, written #{...}. | SpEL |
| SPI | Short for service provider interface. It is an interface that a library defines for others to implement. The implementations are found at runtime from files in META-INF. | Auto-configuration |
| Spring Data repository | An interface that you declare, such as OrderRepository extends JpaRepository<Order, Long>. Spring generates the data-access code for it. | Repositories |
| Spring profile | A named set of configuration, such as dev or prod, that Spring switches on at startup. It is like an environment in ASP.NET Core. | Profiles are environments |
| staleness monitor | A job in Spring Modulith that moves outbox rows that are stuck back to the state FAILED. It is off by default. | Resending what was not sent |
| starter | A Spring Boot dependency that adds a group of libraries for one job. One example is spring-boot-starter-web for a web API. Boot 4 names this starter spring-boot-starter-webmvc. | Starters |
| startup step | One timed step of a Spring Boot start, such as the instantiation of one bean. A BufferingApplicationStartup records them, and the Actuator startup endpoint reports them. | Scene 11: the four reports, in one place |
| static factory method | A static method that creates an object, such as OrderId.of or Order.place. Its name says what it does. | Typed IDs: Identifier and its subclasses |
| static import | An import that brings the static members of a class into scope, so you can write max(a, b) instead of Math.max(a, b). It is like using static in C#. | Imports |
| stereotype | An annotation, such as @Service or @Component, that marks a class as a bean for component scanning. | Where the beans come from: scanning and auto-configuration |
| STOMP | Short for Simple Text Oriented Messaging Protocol. It is a simple messaging protocol that Spring runs over WebSockets for publish and subscribe. | WebSockets and the SignalR gap |
| strategic design | The two levels of Domain-Driven Design (DDD). Strategic design finds the parts of the business and their boundaries. Tactical design gives the building blocks inside one part, such as aggregates and value objects. | What DDD is, in this book's words |
| stream collector | The object that turns the elements of a stream into a final result, such as a list or a map, when you call collect(). Collectors.toList() is one collector. | Collectors |
| stream pipeline | A chain of operations on a sequence, such as filter, map and collect. The chain runs once, when the final operation is called. A stream pipeline does the same as LINQ to Objects in .NET. | The shape |
| structured concurrency | Running related tasks in a scope that ends only when all the tasks finish. If one task fails, the scope cancels the other tasks. Structured concurrency is a preview feature in Java 25. | The problem it solves |
| structured logging | Log lines written as JSON. Spring Boot has three formats built in, ecs, logstash and gelf. For the order service, see the section JSON logs, switched on by the platform. | logback-spring.xml and JSON logging |
| subdomain | A subdomain is one part of the business, such as payments, shipping or the catalogue. It belongs to the problem space, so it exists before any border in the code. | Strategic design |
| supporting subdomain | A supporting subdomain is a part that the business needs, but that does not set it apart. You build it simply, because it is specific to you but not a reason to choose you. | Strategic design |
| surefire | The Maven plugin that runs the *Test classes in the test phase. | Fast tests and integration tests |
| switch expression | A switch that returns a value, written with case X -> result. It is like a switch expression in C#. A Java switch expression must cover every possible case. | The arrow form |
| synchronized | A keyword that lets only one thread at a time run a block or method for a given object. It is like the lock statement in C#. | Mutual exclusion |
| system actor | The actor system, recorded for work that no user started, such as a message or a scheduled job. | Messages and jobs: the system actor |
| system property | A key and value passed to the JVM with -Dname=value, and read with System.getProperty. It is separate from environment variables. | System properties and environment variables |
| TCK | Short for Technology Compatibility Kit. It is the test suite that a JDK must pass to be called Java compatible. This is why the major distributions behave the same. | Which distribution |
| template feature | A feature that a new feature starts as a copy of, with its layout, tests and comments. | Start each feature from a template feature |
| template method pattern | A base class fixes the steps of a method, and a subclass fills in one step. InboxMessageHandler uses this pattern. | The base class of every listener |
| Temurin | A free, widely used JDK distribution from the Eclipse Adoptium project. | Which distribution |
| test slice | A Spring Boot test that starts only one layer of the application, such as the controllers or the repositories, so it runs faster. | Test slices |
| Testcontainers | A library that starts real databases and message brokers in Docker containers for a test run. It exists for .NET too. | Testcontainers |
| text block | A multi-line string written between three double quotes. It is like a raw string literal in C#. | Text blocks |
| ThreadLocal | A variable with a separate value for each thread. Scoped values are the modern way to pass context. | ThreadLocal on virtual threads |
| tiered compilation | The JVM runs a method in the interpreter first, compiles it with the fast compiler C1 after about 200 calls, and with the optimising compiler C2 after about 5,000. The tiers are numbered 0 to 4. | The JIT and warmup |
| TLAB | Short for thread-local allocation buffer. Each thread owns one piece of the young generation, and allocates an object by moving a pointer in it, with no lock. | Scene 3: an allocation |
| tolerant reader | A consumer that ignores the fields it does not know, and requires the fields it uses. The other side may then add fields safely. | Tolerant readers |
| trace ID | The ID that every service shares for one request. It is in the mapped diagnostic context (MDC) as traceId, so every log line of the request carries it. | Tracing with the OpenTelemetry starter |
| trace sampling | The decision whether a trace is recorded and exported. Spring Boot samples 10% of the traces by default. | Tracing with the OpenTelemetry starter |
| transaction manager | The bean that begins, commits and rolls back transactions for Spring. For MongoDB, it is MongoTransactionManager. For the order service, see the section The transaction manager, and what happens without it. | A document database needs a transaction manager |
| transaction propagation | What a transactional method does when a transaction is already running. The method can join the running transaction, which is the default. It can also start a new transaction, or refuse to run. | Propagation |
| @Transactional | A Spring annotation that runs a method inside a database transaction. The transaction is committed when the method returns. It is rolled back when the method throws an unchecked exception. | The default |
| transactional outbox | A collection that stores each event in the same transaction as the change. A separate step sends the stored events after the commit. For the order service, see the section The dual-write problem. | Doing something after the commit |
| TransactionTemplate | A Spring class that runs a lambda in a transaction. Use it to run only part of a method in a transaction. | TransactionTemplate, when only part of a method is transactional |
| transient failure | A failure that may pass by itself, such as a timeout or a 5xx answer. Only a transient failure is retried. | Which failures are worth a retry |
| TransientTransactionError | The MongoDB error label that says the whole transaction may succeed if it runs again. | Write conflicts, and what Spring retries |
| trust store | A file of certificates that a Java program trusts when it opens an encrypted connection. The JDK tool keytool builds one. | A trust store made with keytool: one of AWS's two Java routes |
| try-with-resources | A try statement that opens resources in brackets after try and closes them automatically at the end. It is like the using statement in C#. | try-with-resources |
| TTL index | Short for time-to-live (TTL) index. It is an index on a date field. The database deletes each document a fixed time after that date. | Unique and TTL indexes, and what DocumentDB supports |
| type erasure | Java removing generic type arguments when it compiles, so at runtime a List<String> is just a List. C# keeps the type arguments. | What erasure takes away |
| type pattern | A type check and a cast in one step, such as if (o instanceof String s). It is like is string s in C#. | Type patterns |
| typed ID | An ID with a type of its own, such as OrderId. The compiler then refuses an ID of another kind where an OrderId is expected. | Typed IDs inside the aggregate |
| ubiquitous language | The words that the business experts use, used unchanged in the code. This book calls them the business words. | What DDD is, in this book's words |
| unchecked exception | An exception that the compiler does not make you catch. The unchecked exceptions are RuntimeException and its subclasses. Every C# exception behaves this way. | Checked exceptions |
| unnamed variable | The underscore _, written in place of a variable or a pattern component that you do not need. It is like the discard in C#. It is final since Java 22. | Nested patterns and unnamed variables |
| upsert | An update that inserts the document when no document matches. | Seed data, upserted by a natural key |
| use case | One thing that a user or another system wants to do, such as placing an order. It is one public method of an application service. | One application service per feature |
| value object | An object that is only its values, such as Money. It never changes, and it checks its values when it is created. Two value objects with equal values are equal. | Inside the aggregate: entities and value objects |
| var | A keyword that lets the compiler infer the type of a local variable from its value, exactly as in C#. | var |
| varargs | A last parameter written Type... name, which accepts any number of arguments. It is like params in C#. | Varargs |
| vertical slice | A layout with one folder for each use case, which holds everything that the use case needs. | Why not one folder per use case |
| view (of an aggregate) | A record of plain data that an application service returns, such as OrderView. A caller sees the data of the aggregate, but cannot call a method that changes it. | Views that leave out personal data |
| virtual thread | A lightweight thread that the JVM manages. You can run millions of them, so plain blocking code scales without async and await. | Creating and running them |
| visibility timeout | How long Amazon Simple Queue Service (SQS) hides a received message from other consumers. If the message is not deleted in that time, it can be received again. | The visibility timeout |
| volatile | A field modifier that makes every write visible to other threads immediately, and stops reordering around it. It does not make ++ safe. | volatile means more in Java |
| WAR | Short for web application archive. It is a JAR-like file for deploying a web application to an application server. Spring Boot services usually run as a JAR instead. | EJB and the application server |
| warmup | The first seconds or minutes after startup, while the just-in-time (JIT) compiler is still turning busy code into fast machine code. | The JIT and warmup |
| when guard | A condition added to a pattern with when, such as case Card c when c.last4().equals("0000"). The arm matches only if the pattern matches and the condition is true, as in C#. | Pattern matching in switch |
| wildcard | A ? in a generic type, such as List<? extends Number>. It means some unknown type. It gives Java the flexibility that C# gets from in and out. | Variance is at the use site |
| wiring test | A test that starts the whole service with every setting, while nothing connects to a real dependency. | The wiring test: the whole service, connected to nothing |
| wither | A method such as withQuantity(3) that returns a copy of a record with one component changed. Java has no with expression, so you write withers yourself. | The missing with expression |
| workflow engine | A product, such as Temporal, that stores the state of each flow and runs its steps. | Long flows: a workflow engine or a process manager |
| wrapper type | The object version of a primitive, such as Integer for int. Collections and generics need wrappers, because they cannot hold primitives. | Wrapper types |
| write conflict | MongoDB error 112. Two transactions changed the same document, and the database aborted one of them. | A conflict inside a transaction |
| yield | The keyword that returns a value from a block inside a switch expression. It is not related to yield return in C#. | Multi-statement arms |
| ZGC | A JVM garbage collector designed for very short pauses, even on large heaps. | Choosing a collector |
About this book#
This appendix says what the book targets, how its facts were checked, and how the service behind every example is built and tested. It also says what the book leaves out, and where to read about those things.
What it targets#
| Side | Version | Note |
|---|---|---|
| Java | 25 LTS | the companion service compiles for Java 25, and the text says where Java 21 differs in a way that matters |
| .NET | 10, C# 14 | the C# side of every comparison uses the modern style, such as records and primary constructors |
| Spring Boot | 4.1 | the companion service is built with 4.1.2, and the Boot 3 names are given where they changed |
| Spring Framework | 7 | the container, the web layer, transactions and events |
| Spring Security | 7 | the resource server, the filter chain and method security |
| Spring Data MongoDB | the version Boot 4.1 manages | against MongoDB 8 locally, which takes the place of Amazon DocumentDB in the tests |
| Spring Modulith | 2.1 | the event publication registry and the outbox |
| Spring Cloud AWS | 4.1 | the Amazon SNS topic the outbox publishes to, and the SQS queue the inbox reads |
On accuracy#
Every fact that depends on a version was checked against the reference documentation of the library, or by running the companion service. Nothing was taken from memory, and nothing was taken from a forum answer. Where the documentation and a run disagreed, the book reports the run and says so.
This is the October 2026 edition. The facts were checked 25 and 26 October 2026. After those dates, treat version numbers and default values as claims that you must check against the current documentation. When this book and the reference documentation disagree, the documentation is right.
The companion service#
Every Java example is copied from one Maven project with three modules. The platform library is shared by every service of the shop. The order service is the service this book builds. The local environment starts MongoDB, an AWS emulator, a mock OAuth2 server and WireMock in Docker. When the book was published, the project had 144 unit tests and 24 integration tests, and all of them passed.
A script compares each example in this book with its source file in the project. The build of the book fails when an example drifts from its source. So the code you read is the code that ran.
What it leaves out#
| Not covered | Where to read instead | Why it is out |
|---|---|---|
| CQRS, event sourcing and sagas in code | the chapters that explain each as a choice, and the Atlas, the sibling book on DDD and microservices | each is explained as an option, with the sign that makes it worth its cost, and none is needed by this service |
| JPA and a relational database | the handbook's chapter on JPA and migrations | the service stores documents, as the reader's services do |
| Kotlin | the handbook's Kotlin appendix | the platform facts in this book apply unchanged to a Kotlin codebase |
| Reactive WebFlux | the handbook's WebFlux chapter | the service blocks on virtual threads, and the handbook explains when WebFlux is the better choice |
| The first-read explanation of each feature | the handbook | this book starts where the handbook stops, and links back |
The glossary at the end of this book is shared with the handbook. It is the same file in both books, so its heading carries the handbook's appendix letter. A term's link to the section that explains it may open the handbook.
How it was made#
The two books are built from one set of chapter files by one build script. The build renders every chapter once, checks every link across both books, and writes one self-contained file for each book. A link into the other book opens that book's page on the website. The sidebar shows the date when a copy was built. That date is not the edition.
End of the book