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.

Compiled bykodebot

October 2026 edition

Java 25, a long-term support release .NET 10 · C# 14 Spring Boot 4.1

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:

WhereWhat you findUse it for
the table at the topevery difference the chapter covers, one row each, with the answer in the rowthe first read, and every refresh
the sectionsthe full explanation of each row, with the service's own codethe first read
the pragmatic calloutseach place where the service departs from a strict rule, with the reason and the costjudging 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#

Why DDD, and how much · Chapter 01· 27 min read

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.

KindA usual ASP.NET Core serviceThe order service, with DDDNote
neworder.Status = OrderStatus.Shipped;order.ship(clock)the rule is a method of the order, so no caller can change the status without it Why
newone EF Core entity for each tablebusiness words, a bounded context and aggregatesDDD names the business ideas first and designs the code around them Why
newno equivalentevent storming, bounded contexts and aggregates, but no event sourcinguse the parts of DDD that find rules and boundaries, and keep the rest light Why
newno equivalentthe conditions that make DDD worth its costcomplex rules, many states, rules nobody may bypass, a long life and old data are the signs Why
newa CRUD service with EF Core entitiesthe reference feature, with no DDD building blocksdata with no rules does not need DDD, even inside a DDD service Why
newone class for each tablea model, a document, a mapper, a view, and request and response classesDDD adds types and mapping, and it takes time with business experts Why
newrules spread over services and controllersone place to look for each rulethe code answers questions such as when an order can ship, and whether anything bypasses it Why
trapa method call you can follow in the IDEan event that a listener in another package receivesmore types and events hide the flow, so a team must work to keep it easy to follow Why
newconventions that each team member keeps in mindone layout, business names, architecture tests and decision notesthe same structure everywhere is what keeps a DDD service readable Why
twistpublic setters and an outbox row added by the servicea rule method that records its event, and one save stepan outbox makes events reliable, but it does not stop a caller that forgets them Why
newentities with private settersstate-change methods, recorded events and value objectsthe middle ground keeps most of the value of DDD for a small part of its cost Why
newa rules engine called from a servicea DMN decision table behind a domain interfacea table of many cases is easier to read and change in a rules engine than in code Why
newan order with a long idan 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
newno equivalentthree events out, one event in, and two services calledevery message and call of the service is listed in one place Why
newone solution with several projectsthree repositories for the platform, the service and the local environmentthe platform is released on its own, and every service shares the local environment Why
newno equivalenta map of the 26 chapters of this parteach chapter adds one layer or one concern to the same service Why
Note

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.

ASP.NET Core and EF Core
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();
    }
}
The order service
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));
}
Reading the code
order.Status = OrderStatus.Shipped;

In C#, Status has a public setter. Any class can write this line, with or without the two checks above it.

public void ship(Clock clock)

In Java, ship is a method of Order. It receives the clock, so a test can fix the time. Clock is from java.time. It has the same purpose as TimeProvider in .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 ship is 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 blockIn the order serviceTaught in
aggregateOrder, with its lines and its last payment, saved as one documentthe chapter on aggregates
entityOrderLine, which has its own identity inside the orderthe chapter on aggregates
value objectMoney, ProductCode and Payment, which have no identity and never changethe chapter on aggregates
domain eventOrderShipped, which the order records when it shipsthe outbox chapter
repositoryOrderRepository, the interface that loads and saves ordersthe chapter on layers
domain serviceShippingPolicy, a rule that needs an order and its notesthe chapter on rules
application serviceOrderService, which runs the steps of each use casethe 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.

ConditionIn the shopWhy DDD helps
complex rulesan order ships only when it is paid, picked, and no note needs actioneach rule has one home, and a test that names it
many states and workflowsan order is new, paid, shipped or cancelled, and each state allows other actionsthe methods of the aggregate allow only the changes that the state permits
rules that must never be bypasseda shipped order must never changethe data has no setter, so the only way in is the method that checks
a long lifethe order service will run for years and change many timesa new developer finds each rule in one place, not spread over the code
high stakesshipping an unpaid order loses moneya mistake in a rule is found by a fast unit test, not by a customer
rules to recover from an old systeman old shop system may hold some rules only in its stored procedureseach recovered rule becomes a method and a test with a business name
data that breaks rules during a migrationold orders without a valid e-mail addressthe model refuses bad values, so the migration finds them before customers do
finding service boundariesorders, stock and payments change for different reasonsbounded 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.

QuestionWhere the answer isWhy it is enough to look there
When can an order ship?Order.ship and ShippingPolicyevery rule about shipping is in these two classes
Can anything bypass that rule?Order has no setter, and ArchitectureTest checks itthe build fails when someone adds a public setter to an entity
What does this use case do?the method in OrderService, such as shipeach use case is a short list of steps, with no rule in it
What happens when an order ships?OrderIntegrationEventMapper and the listeners of OrderShippedevery reaction to the event is a listener of that event type
Is the rule tested?OrderTest, in the nested class Shippingeach rule has a test whose name states the rule
What breaks if storage changes?the persistence package of the featurethe domain package uses no MongoDB class, and a test checks it
Where is the code for notes?the note packageeach 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.

Gotcha

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 as OrderDomainService.
  • 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.

Anaemic, with an outbox
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
}
The order service
/**
 * 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.

ConcernAnaemic model with an outboxRich model with an outbox
events reach other services after a commityes, the outbox is reliableyes, the same outbox
the change and its event commit togetheryes, if the service adds the rowyes, the save step always publishes
a rule has one homeno, each service method checks it againyes, the method of the aggregate
a caller can skip a ruleyes, any class can set the statusno, the status has no setter
a new developer can learn it quicklyyes, it is the usual designit takes some weeks
proof that a rule always holdsread every class that sets the fieldread 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.ship and 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.

  1. State-change methods with their rules, and no state setters. An entity changes only through methods such as ship and resolveAction.
  2. 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.
  3. 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 Money or ProductCode.
  4. 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 methodZoneWeight up toCharge
STANDARDUK2 kg3.95 GBP
STANDARDUK10 kg6.95 GBP
EXPRESSUK10 kg12.50 GBP
STANDARDEU10 kg14.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.

PackageWhat it holdsLevel of DDD
orderorders, their lines, the last payment, and shippingfull
notethe notes of customer-service agents, some of which need actionlight
referencelookup values, such as delivery methods and note categoriessimple, no DDD building blocks
migrationthe indexes and the seed data of the databasenot a feature with rules
sharedthe permission annotations, the audit names and the OpenAPI descriptionnot 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).

agents' web app HTTP with a token token issuer keys and the service's token payment service publishes payments via SNS order service order, note, reference DocumentDB stock service reserves the products SNS order-events placed, shipped, cancelled SQS payment queue PaymentReceivedV1
The order service receives HTTP requests with a token from the token issuer. It calls the stock service over HTTP. It publishes OrderPlacedV1, OrderShippedV1 and OrderCancelledV1 to the SNS topic order-events. The payment service publishes PaymentReceivedV1 through SNS. The order service receives it from an SQS queue. The order service stores its data in Amazon DocumentDB.

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.

DirectionEventWhereMeaning
outOrderPlacedV1SNS topic order-eventsan order was placed, with its delivery method and lines
outOrderShippedV1SNS topic order-eventsan order was shipped, and it can no longer change
outOrderCancelledV1SNS topic order-eventsan order was cancelled before it shipped
inPaymentReceivedV1SQS queue order-service-payment-receivedthe 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.

DependencyUsed forWhen it is down
DocumentDBall datareadiness is down, so no traffic arrives, but liveness stays up, so instances are not restarted
stock serviceplacing an orderonly POST /orders answers 503, and everything else works
SNSpublishing eventsevents wait in the outbox and are resent
SQSpaymentspayments wait in the queue
token issuerchecking tokens, and the token of the stock callrequests 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.

RepositoryWhat it holdsWho uses it
shop-platforma parent POM, and a library of code that every service needs, but no business codeevery shop service, as a versioned dependency
order-servicethe order service, with its tests, its contracts and its Dockerfilethe team of the order service
local-environmenta Docker Compose file with MongoDB, stubs of other services, fake AWS and a token issuerevery 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 --build

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

Check yourself
1
Name four conditions that make DDD worth its cost for a service.
Complex rules, many states and workflows, rules that must never be bypassed, and a long life. High stakes, rules to recover from an old system, old data that breaks rules, and unclear service boundaries are signs too.
2
An expert asksA service uses entities with public setters and an outbox. Why can it still lose an event?
The outbox sends only the rows that a service method writes. A new endpoint or a batch job can set the status and forget to write the row. The rich model records the event inside the state-change method, so the change and its event cannot be separated.
3
TranslateIn the order service, write this C# in Java: 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.
4
An expert asksWhat makes a DDD service harder to follow, and what keeps it readable?
More layers, and events whose listeners are in other classes. Several practices keep it readable. Use the same layout in every feature, steps-only use cases and business names. Add one event mapper per feature, trace IDs, architecture tests and decision records.
5
Which feature of the order service uses no DDD building blocks, and why?
The reference feature. It holds read-only lookup values with no business behaviour, so an aggregate and events would only add files.

Finding the aggregates#

Why DDD, and how much · Chapter 02· 26 min read

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.

KindA usual EF Core modelThe order serviceNote
newDbSet<Order> and DbSet<OrderLine>, each saved on its ownOrder, with its lines inside, saved as one documentan aggregate is the data that one rule must see, saved together in one transaction Why
twistorder.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
newno equivalentseven questions about rules, size, conflicts and lifefinding an aggregate needs judgement, but questions and tests guide each step Why
newan Order class with a property for each columnstatus, delivery method, customer, lines and last paymenteach piece is inside because a rule needs it at every change, or because it has the same life Why
trappublic List<Note> Notes { get; set; } on Ordernotes as an aggregate of their owna list with no limit grows until saves conflict or the document reaches its size limit Why
trapOrderLine as an aggregate of its ownthe lines inside the orderwhen every change must update several aggregates together, they are one aggregate Why
newno equivalentmove the part with no limit out, or move the small part ina split keeps the rule with a count or an event, and a migration moves the data Why
newno equivalentOrder and Note, two aggregatesnotes have no limit, no order rule needs all of them, they conflict less, and they live differently Why
twistpublic Order Order { get; set; } on Noteprivate final String orderId; on Notea reference by ID never loads or saves the other aggregate Why
twistpublic long OrderId { get; set; } on NoteString orderId, checked by OrderService.requireExiststhe strict style gives a foreign ID its own type, but the light style keeps text Why
twistpublic 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
twistpublic readonly record struct OrderId(Guid Value);final class OrderId extends Identifiera typed ID lets the compiler catch an order ID passed where a line ID belongs Why
trapa check of another table in the same service methodShippingPolicy, checked when the order shipsa rule across two aggregates holds at the moment of the check, not forever Why
newno equivalentaccept, refuse, make strict, or repairchoose with the business owner, then record the choice in a decision record and a test Why
newa call to another service inside the database transactionthe stock call before the transaction, with an idempotency keyno transaction spans two services, so each step must be safe to repeat Why
traptwo services that inject each other@Lazy OrderService in NoteServicea 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#

EF Core entity
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();
The order service
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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.

PieceThe rule that needs itWhy it is inside
statusevery method, because a finished order cannot changeevery rule reads it, so it must be saved with every change
linesship needs every line picked, and pickLine needs the lineat most 50, and a line never exists without its order
last paymentrecordPayment ignores a payment that is not later than the last onethe idempotent check needs the last payment at every payment
customerno rule, only the format of the name and the e-mail addressit is part of the order, and it has the same life
delivery methodno rule, only the format, because the reference feature checks the codeit is part of the order, and it never changes
notesthe shipping rule needs only the number that need actionoutside, 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.

Gotcha

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 @Version as 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 into DataIntegrityViolationException. 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.

  1. Move the part with no limit into an aggregate of its own, with its own root, its own ID and its own version.
  2. Give the new aggregate the ID of the old root. Remove the list from the old root.
  3. 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 ShippingPolicy does.
  4. If a rule can wait, react to an event of the other aggregate instead.
  5. 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.

  1. 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.
  2. No order rule needs every note. The shipping rule needs one number, the count of notes that need action.
  3. 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.
  4. 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.
order aggregate, one document Order (root) Payment (value) OrderLine OrderLine up to 50 Note (root) orderId Note (root) orderId no limit, one document each
The order aggregate holds the Order root, its OrderLine entities and its last Payment, in one document. Each Note is an aggregate of its own, in its own document, and refers to its order by the orderId.

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#

EF Core entity
public class Note
{
    public long Id { get; set; }
    public long OrderId { get; set; }
    public Order Order { get; set; } = null!;
    public string Text { get; set; } = "";
}
The order service
@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 Order class. 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.

Pragmatic choice: A String ID between features

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#

C# 12
public record Money(decimal Amount, string Currency);
public record PaymentReference(string Value);
public record Payment(PaymentReference Reference, Money Amount, DateTimeOffset PaidAt);
The order service
/**
 * 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.

KindEqual whenChangesIn the order aggregate
entityit has the same ID, whatever its other datayes, through the rootOrderLine, which a picker names by its line ID
value objectall its values are equalnever, because a new value replaces itPayment, 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#

C# 12
public readonly record struct OrderId(Guid Value)
{
    public static OrderId New() => new(Guid.NewGuid());
}

public readonly record struct LineId(Guid Value);
The order service
/** 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.

agent A agent B count notes: 0 save order: shipped commit order exists: yes insert the note commit time runs from left to right
Agent A counts the notes that need action, finds none, and ships the order. Agent B checks that the order exists and inserts a note that needs action. The two transactions write different documents, so both commit.

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.

StatementGuaranteed?Why
an order and its first notes are stored togetheryesone transaction, shown by OrderJourneyIT.anOrderAndItsNotesAreStoredTogetherOrNotAtAll
a note refers to an order that existsyesthe note feature checks it, and orders are never deleted
an order has every line picked when it shipsyesthe lines are inside the order, and ship checks them in the same save
no note needs action when an order shipsonly at the moment of shippingthe rule is across two aggregates
a note that needs action on a shipped orderit can happenthe note feature checks only that the order exists
Gotcha

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.

OptionWhat the service doesWhat it costs
A, acceptnothing more, and the note is still seen and handleda shipped order can have a note that needs action
B, refusethe note feature refuses a note that needs action on a shipped orderthe note feature must ask for the order's status, and the race at the same moment remains
C, make strictadding such a note also writes the order document, so the two transactions conflictevery note conflicts with every change of its order, as if the notes were inside
D, repaira listener of OrderShipped looks for notes that need action, and alerts someonemore 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.

Pragmatic choice: A shipping rule that holds only at the moment of shipping

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.

Gotcha

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.

Check yourself
1
Which two questions decide whether a piece of data belongs inside an aggregate?
Does a rule need the data at every change? And is the data small and bounded? Data that passes both tests goes inside. Data with no limit stays outside. Data that no rule needs usually stays outside too. Small data that is created with the root may stay inside, such as the customer's contact on an order.
2
An expert asksWhy are notes an aggregate of their own, and not a list inside the order?
Notes have no limit, so they would grow the order document. No order rule needs every note, only a count. Each note has its own version, so agents and warehouse staff do not conflict. And a note has a different life.
3
An 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?
The note and the order are separate aggregates in separate documents. A note added at the same moment, or a moment later, writes a different document. So nothing conflicts, and the rule held only at the moment of the check.
4
TranslateIn the order service, write this C# in Java: 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.
5
Name the four options for a rule across two aggregates that holds only at the moment of the check.
Accept the gap, refuse the later change, make it strict so the two transactions conflict, or repair it afterwards. Choose with the business owner, and record the choice with a test.
6
What is the main sign of an aggregate that is too small?
A rule must change several aggregates together every time. Most use cases then save the same aggregates in one transaction, or need a lock across them.

How much DDD: three levels in one service#

Why DDD, and how much · Chapter 03· 18 min read

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.

KindA usual ASP.NET Core serviceThe order serviceNote
newone style for every featurefull for order, light for note, simple for referenceeach feature gets the least DDD that still protects its rules Why
twistpublic interface IOrderRepositoryinterface OrderRepository extends Repository<Order, OrderId>the full style hides storage behind a port, with a document class and a mapper Why
twistan EF Core entity with private setters and methodsNote, a rich entity that is also its @Documentthe light style keeps the rules of the entity and drops the mapping Why
twistpublic 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
newno equivalentno mapper, no port, no typed IDs, and domain services only across aggregatesthe light style drops the parts that protect against changes of storage and coupling Why
newpublic setters on the entitystate-change methods, recorded events and checked valuesthe light style keeps every part that protects a rule Why
trapcatch DbUpdateConcurrencyException, then retry with the same entityload the note again after a failed saveSpring 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> eventsthe list of events is never stored, and a test without a database proves it Why
twistan Equals override that compares IdEntity.equals, or Note.equals on its String idthe full style compares class and typed ID in a base class, but the light entity writes equals itself Why
twista private constructor that EF Core bindsa constructor marked @PersistenceCreatorSpring Data loads through a constructor that applies no rules, and create applies them Why
newno equivalentquestions about rules, stored shape and other serviceschoose the level for each feature, and name it where the architecture tests can see it Why
newno equivalenta rule appears, or the stored shape starts to bend the modeleach 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.

LevelFeatureWhat it hasWhat it protects
fullordera 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 portthe rules, and the model against changes of storage and of other services
lightnotea rich entity with its state-change methods, rules and events, which is also the stored document, a Spring Data repository used directly, and String IDsthe rules of the entity
simplereferencea document record, a Spring Data repository with a cache, and the logic in the application servicethe 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#

C# 12
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 order service
/**
 * 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#

EF Core entity
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));
    }
}
The order service
@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));
    }
Reading the code
@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.

@Version

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

@Transient

Spring Data leaves this field out of the stored document. The list of recorded events is kept only in memory.

@PersistenceCreator

Spring 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 event NoteAdded.

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#

C# 12
public record ReferenceItem(string Id, string Type, string Code, string Label, bool Active);
The order service
/**
 * 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.

Pragmatic choice: One class for read-only lookup data

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.

DroppedThe full style hasThe light style doesWhat is lost
separate document and mapperOrderDocument and OrderDocumentMapperannotations on the entity, and @PersistenceCreator for loadingthe model and the stored shape must stay the same
repository portOrderRepository, with an adapterthe Spring Data interface NoteRepository, injected into the servicea test mocks a Spring Data interface, and the service sees Spring types
typed IDsOrderId and LineId, checked at every boundaryString IDs, not checked, so an unknown ID is simply not foundthe compiler cannot catch a swapped ID
domain services, except across aggregatesShippingPolicy, for the rule across order and notesno domain service, because the note's rules need only the notenothing, 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.

Pragmatic choice: Spring Data annotations on the note's model

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.

Pragmatic choice: The Spring Data repository used directly

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.

Pragmatic choice: Plain String IDs in the note feature

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. create records NoteAdded. The method resolveAction records NoteActionResolved. The use case publishes them when it saves.
  • Checked values. create checks the text and the category with Guard, 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.

Gotcha

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.

  1. 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.
  2. 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.
  3. 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.

MoveThe signWhat to add
simple to lighta value gets a rule, or users start to change the dataan entity with state-change methods, rules and events
light to fullthe stored shape starts to bend the modela domain package, a document class and a mapper
light to fullthe model needs types that Spring Data cannot store as they area document of plain types, and a mapper that converts them
light to fullthe feature starts to call another servicea port in the application package, and an adapter
light to fulla rule across aggregates appearsa domain service named after the rule
full to lightthe mapper only copies fields of the same names and typesconsider 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.

Check yourself
1
What does the light style keep from the full style?
State-change methods with their rules, no setters for state, the events that the entity records, and checks of its values. It keeps every part that protects a rule.
2
What does the light style drop?
The separate document class and its mapper, the repository port and its adapter, and typed IDs. It has a domain service only for a rule across aggregates, as the full style does.
3
An expert asksA save of a light-style note fails with a conflict. Why must the service not retry with the same object?
Spring Data writes the new version into the object before it sends the update. After the failure, the object holds the version that the other save stored. A retry would pass the version check and overwrite the other change. Load the note again, and apply the change again.
4
TranslateWrite the C# property [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.
5
What is the sign that a light-style feature needs the full style?
The stored shape starts to bend the model. Other signs are types that Spring Data cannot store as they are, a call to another service, or a rule across aggregates.

Features, layers and the rules#

Why DDD, and how much · Chapter 04· 33 min read· Quick reference

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.

KindC#JavaNote
newno equivalentthe service's folders, from pom.xml to src/testevery service has the same files in the same places, so a developer finds a class without searching Why
twistProgram.csOrderServiceApplication with @SpringBootApplicationthe class must be in the base package, because Spring scans that package and every package below it Why
newfolders by technology, such as Controllers and Servicestop-level packages by feature: order, note, referenceone change touches one folder, and a feature can later move to its own service Why
newno equivalentthe same sub-packages in every featuredomain, application, api, persistence, client and messaging, and a feature leaves out what it does not need Why
newno equivalenta table of what goes whereeach kind of class has one package, and the table says which packages it may use Why
newno equivalentapi and messaging, then application, then domainthe domain depends on nothing, and the adapters implement ports that the inner code declares Why
newno equivalentOrderRepository in domain, StockChecker in applicationstorage belongs to the model, but a call to another service is a step of the use case Why
newno equivalenttwelve rules for every serviceeach rule has a reason, and most are checked by a test that fails the build Why
newno equivalentArchitectureTest, with the platform's ArchitectureRulesthe layering is checked on the compiled classes, so a forbidden import fails the build Why
twistinternal sealed class OrderDocumentclass OrderDocument, with no modifierpackage-private is narrower than internal, so only the persistence package sees the document Why
twista namespace takes no XML comment, and CS1587 warnspackage-info.java with a Javadoc commentthe 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
newno equivalentfeatures and layers, not adapter/in and adapter/outthe protection comes from the direction of the dependencies, which a test checks, not from folder names Why
newone folder per use case, such as Features/PlaceOrderone application service per featurea slice per use case is fine for simple features, but the rules of one aggregate must stay in one place Why
newno equivalentone model for reads and writes, with no CQRSa separate read model is worth its cost only when reads need a shape or a scale that the aggregate cannot give Why
newno equivalentOrderService split by area of the businessat about ten use cases, split by area, such as fulfilment, and never into a read side and a write side Why
newno equivalenta small shared packageit holds the permission annotations, the audit names and the OpenAPI description, and no business logic Why
newno equivalentthree repositories: shop-platform, order-service, local-environmentthe root pom.xml of the companion exists only for the book, and real services never build together Why
newno equivalenta README with purpose, rules, endpoints, events, dependencies and configurationthe 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 nameExampleWhen it runs
ends in TestOrderTest, OrderControllerTestin every build, with mvn verify, and it needs no Docker
ends in ITOrderPersistenceAdapterIT, OrderJourneyITonly 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):

PackageWhat a business user calls itIts level of DDD
orderorders, picking, payment and shippingfull
notethe notes that agents keep on an orderlight
referencethe drop-down lists, such as delivery methodssimple

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 note package 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 note package 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, OrderCancelledV1

The 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, NoteAddedResponse

So 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, ReferenceItemRepository

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

Pragmatic choice: Three shapes of feature under one layout rule

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:

PackagePurposeContainsMay use
domain/modelthe business concepts and their rulesaggregate roots, which extend AggregateRoot, entities, which extend Entity, IDs, which extend Identifier, and value objects, records that implement ValueObjectits own domain and the platform's domain package
domain/eventwhat happened in the modelrecords that implement DomainEventits own domain model
domain/exceptionthe broken rulesclasses that extend NotFoundException, InvalidValueException or BusinessRuleViolationExceptionits own domain and the platform's exception kinds
domain/repositorythe port for storing one aggregateone interface per aggregate, which extends Repositoryits own domain
domain/servicea rule that needs more than one aggregateplain classes, such as ShippingPolicyits own domain
applicationthe use casesone service per feature, its views, and records that other features pass in, such as NewNoteits own domain, the application package of other features, DomainEventPublisher and AuditLog
application/portwhat the use cases need from other servicesinterfaces in this service's own typesits own domain only
apiHTTP in and outcontrollers, mappers and DTOs with Bean Validationits own application and its own domain types
persistencehow the aggregate is stored@Document classes, Spring Data repositories, mappers and adaptersits own domain and the platform's mongo package
clientcalls to other services@HttpExchange clients, their DTOs and adaptersits own application ports, its own domain and the platform's HTTP support
messagingevents between servicesintegration events, the event mapper and listenersits own application, its own domain and the platform's messaging package
the feature's rootthe feature's wiringthe OrderConfig classanything 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 extendsExample in the order featureStatus
NotFoundExceptionOrderNotFoundException, LineNotFoundException404
InvalidValueExceptionthrown by a value object, such as the platform's PageQuery for a page size over 100400
BusinessRuleViolationExceptionOrderNotPaidException, UnpickedLinesException422

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:

WHERE CALLS COME IN api controllers, DTOs, mappers messaging listeners, integration events application use cases, views, port/StockChecker domain model, rules, events, repository port ADAPTERS client implements the port persistence implements the port An arrow means "uses". No arrow leaves the domain.
The layers of a full-style feature. The api and messaging packages call the application package, which uses the domain. The persistence and client packages are adapters. They implement ports that the domain and the application declare. The domain depends on nothing.

Each box holds one kind of building block. The order feature has one of each:

Building blockWhat it isIn the order feature
application servicethe use cases of a feature, which order the steps and contain no ruleOrderService
portan interface that the inner code needs, written in the inner code's own typesOrderRepository, StockChecker
adaptera class that implements a port with real technology, such as MongoDB or HTTPOrderPersistenceAdapter, StockAdapter
anti-corruption layeran adapter that translates another service's model into this service's modelStockAdapter
viewthe plain data that a use case returns, so a caller never holds the aggregateOrderView
repositorythe port for loading and saving one aggregate, one per aggregateOrderRepository

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:

#RuleWhyWhere it is taught
1A 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
2The top-level packages are features, never technologies.One business change touches one folder.this chapter
3Every feature has the same sub-packages, and leaves out the ones it does not need.A developer finds a class without searching.this chapter
4The 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
5Business 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
6The 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
7Features 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
8Outside 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
9A 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
10Cross-cutting code comes from the platform library.Every service gets the same errors, security, logging and storage support.Writing the platform library
11Events 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
12Every 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);
    }
Reading the code
new ClassFileImporter()

The importer reads the compiled .class files, 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. check throws an AssertionError that 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 methodWhat it checksRule
domainIsPlainJavano domain package uses Spring, MongoDB, Jackson, AWS or another layer4
applicationOfFullStyleFeaturesUsesNoAdapterthe order feature's application uses ports, never an adapter, HTTP or MongoDB6
apiDoesNotUsePersistenceOrClientsan api package uses no persistence, client or messaging class8
controllersUseNoRepositorya controller calls a service, never a repository6
everyEndpointHasAPermissionevery endpoint carries a permission annotationsecurity
documentsOnlyInPersistenceOrModela stored document lives in persistence, or in a light feature's model8
httpClientsOnlyInClientan HTTP client interface lives in a client package8
entitiesHaveNoPublicSettersan entity changes only through methods that apply its rules5
featuresUseEachOtherOnlyThroughApplicationa feature uses another feature only through its application package, and never through its ports7
servicesUseNoRepositoryOrEntityOfAnotherFeaturea service never takes another feature's repository or entity7

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");
}
Reading the code
noClasses().that().resideInAPackage("..domain..")

Two dots stand for any number of package levels. So ..domain.. matches com.acme.orderservice.order.domain.model and 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 domain package, 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()));
    }
}
Reading the code
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 shared package 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#:

C#
internal sealed class OrderDocument
{
    public string? CustomerName { get; set; }
}
Java 25
/**
 * 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:

ClassPackageWho uses it
OrderDocument, OrderDocumentMapper, OrderMongoRepositoryorder.persistenceonly OrderPersistenceAdapter, in the same package
OrderPersistenceAdapterorder.persistenceSpring, which injects it where OrderRepository is asked for
StockAdapter, StockHttpClient, StockClientConfigorder.clientSpring, which injects StockAdapter where StockChecker is asked for
OrderController, OrderApiMapperorder.apiSpring MVC, which calls the controller
PaymentReceivedListener, OrderIntegrationEventMapperorder.messagingSpring and the platform's messaging
OrderConfig, ReferenceConfigthe feature's rootSpring, 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, OrderRepository and StockChecker, 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 in api uses them. A sub-package is a different package.
  • In the light note feature, Note and NoteRepository, because NoteService in the application package uses them directly.
  • In the reference feature, ReferenceItemDocument and ReferenceItemRepository, because ReferenceDataService uses 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:

C#
/// <summary>The building blocks of a domain model.</summary>
namespace Shop.Platform.Domain
{
    /// <summary>A marker for value objects.</summary>
    public interface IValueObject { }
}
package-info.java
/**
 * 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:

TagWhat javadoc showsC# 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 100no 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/out stops 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 OrderService as easily as an interface. So port/in would 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.

Pragmatic choice: One model for reads and writes

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 saveAndPublish saves, 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 featuresUseEachOtherOnlyThroughApplication still 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:

PackageWhat it holdsWhy it is shared
sharedAuditActionsevery feature records audit actions with the same names
shared.securityOrdersRead, OrdersWriteevery controller names one of the two scopes
shared.apiOpenApiConfigthe 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.

Pragmatic choice: A shared package inside the service

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:

  1. The order service's README.md, for the rules and the decisions.
  2. The package order/domain/model, with Order, and its test OrderTest.
  3. OrderService, for the steps of every use case.
  4. The adapters, OrderPersistenceAdapter and StockAdapter.
  5. The note feature, to compare the light style with the full style.
  6. 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:

HeadingWhat it holds
the first paragraphthe purpose, in the words of the business
Rulesthe business rules, one line each
Three styles in one servicethe level of each feature, a section that only the companion needs
Endpointsevery path, its scope and its success status
Eventseach event sent or consumed, with its topic or queue and its schema
Dependencieseach dependency, what the service uses it for, and what happens when it is down
Configurationeach environment variable and what it means
Run locallythe commands, and how to call the service
Teststhe kinds of test, with examples and the command that runs them
Decisionseach 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.

Check yourself
1
An expert asksBoth StockChecker and OrderRepository are ports. Why is the first one in application/port and the second one in the domain?
Storage is part of the model, so the domain declares what it needs from storage. A call to another service is a step of a use case, and the domain only judges the answer. With the port in the application, a domain service cannot call the stock service in the middle of a rule. So only a use case makes the call, before its transaction starts.
2
An expert asksA team says that folders named adapter/in and adapter/out protect the domain better. What does the book answer?
Folder names stop nothing. A test that fails when the domain imports MongoDB stops the mistake. The shop has the same ports and adapters, and an ArchUnit rule checks the direction of every dependency. The hexagonal names are worth it for a team that already knows them, or where a use case has several implementations.
3
The note feature wants to read an order's status. Which classes of the order feature may it use, and which rule checks that?
Only classes in the order feature's application package, such as 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.
4
When should the order feature get a separate read model?
When a read needs data from several aggregates or features at once, such as a search across orders and notes. Or when reads are far more frequent than writes and the aggregate is slow to load. Then add a read model for that query only. The screens of today are close to one aggregate each, so one model serves both.
5
TranslateWrite 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; }
}
@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:

ConceptFile in the companion
the application classorder-service/src/main/java/com/acme/orderservice/OrderServiceApplication.java
an aggregate rootorder/domain/model/Order.java
an entity inside the aggregateorder/domain/model/OrderLine.java
a typed IDorder/domain/model/OrderId.java
a value objectorder/domain/model/Money.java
a domain eventorder/domain/event/OrderShipped.java
a rule violationorder/domain/exception/OrderNotPaidException.java
a repository portorder/domain/repository/OrderRepository.java
a domain serviceorder/domain/service/ShippingPolicy.java
an application serviceorder/application/OrderService.java
a vieworder/application/OrderView.java
a port to another serviceorder/application/port/StockChecker.java
a controllerorder/api/OrderController.java
a request DTOorder/api/dto/PlaceOrderRequest.java
a stored documentorder/persistence/OrderDocument.java
a persistence adapterorder/persistence/OrderPersistenceAdapter.java
an HTTP clientorder/client/StockHttpClient.java
an anti-corruption layerorder/client/StockAdapter.java
an integration eventorder/messaging/event/OrderPlacedV1.java
a message listenerorder/messaging/PaymentReceivedListener.java
the feature's wiringorder/OrderConfig.java
a light-style entitynote/model/Note.java
a simple featurereference/application/ReferenceDataService.java
a migrationmigration/V001CreateIndexes.java
the permission annotationsshared/security/OrdersRead.java
the architecture rulesshop-platform/shop-platform/src/main/java/com/acme/platform/test/ArchitectureRules.java

The build: a parent POM and a platform library#

The build and the model · Chapter 05· 21 min read· Quick reference

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.

KindC#JavaNote
newno equivalentfive POMs: two aggregators, the parent, the library and the servicean aggregator only lists folders to build, and a parent passes its settings on to its children Why
twistDirectory.Build.props, found in a folder above the projectshop-platform-parent, a published POM that a service namesthe 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 parentBoot's parent turns java.version into maven.compiler.release, so one property sets the release Why
twist<PackageVersion Include="..." Version="..." /> in Directory.Packages.propsdependencyManagement, with BOMs importedone BOM sets the versions of a whole family of libraries, and the first import wins a clash Why
trapa version in Directory.Packages.props is enougha library that no BOM covers needs its own managed entrywithout the entry, a child that leaves out the version fails to build Why
newa test project with its own PackageReference listtest libraries in the parent's dependencies, with the test scopeevery child gets them, and they never reach a production jar Why
trapdotnet test runs every [Fact], whatever the class is calledsurefire runs only classes named like *Test, *Tests, Test* or *TestCasea test class named OrderCheck never runs, and nothing warns you Why
newdotnet test --filter*IT classes in failsafe, switched on by the it profilemvn verify runs the fast tests, and mvn verify -Pit adds the tests that need Docker Why
twistno equivalentpluginManagement in the parentit 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 getsone dependency brings web, security, tracing, clients, DocumentDB and messaging Why
twistspring-boot-starter-webspring-boot-starter-webmvc and the other Boot 4 namesBoot 4 named its modules after their technology, and the old names of some starters are deprecated Why
twistPrivateAssets="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
trapno equivalentspring-boot-configuration-processor in the compiler's annotationProcessorPathsjavac 26 does not run a processor that is only on the class path, so an optional dependency alone writes no metadata Why
twista csproj with one PackageReference and no versionthe service POM: a parent, two dependencies and one pluginevery version and setting comes from the parent Why
twistdotnet publishspring-boot-maven-pluginit repackages the jar into an executable jar, with layers for the Docker image Why
twistdotnet build Shop.slnmvn verify at the root, or mvn -pl order-service -am packagethe commands that each part of the companion uses Why
newupdate the SDK version in global.json and every packagechange the parent's Boot version and release a new platform versioneach service then moves to the new platform version when it is ready Why
twistDirectory.Build.props, Directory.Packages.props and a NuGet packagethe parent POM and the platform librarythe 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:

FilePackagingIts job
pom.xml, at the rootpombuilds the platform and the order service together, for the book only
shop-platform/pom.xmlpombuilds the parent and the library together, and is the root of the platform's repository
shop-platform/shop-platform-parent/pom.xmlpomthe parent of every service and of the library, which sets Java, the plugins and the versions
shop-platform/shop-platform/pom.xmljarthe library that every service adds
order-service/pom.xmljarthe 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>
Reading the code
<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 empty relativePath switches 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.release from java.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:

Directory.Build.props
<Project>
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>12</LangVersion>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>
shop-platform-parent/pom.xml
<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:

Directory.Packages.props
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="Shop.Platform" Version="1.0.0" />
  </ItemGroup>
</Project>
shop-platform-parent/pom.xml
    <!-- 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.

Gotcha

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:test

The 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:

ArtifactWhat a test gets from it
spring-boot-starter-testJUnit, 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-testjwt() and the other helpers that put a caller into a test request
spring-boot-testcontainers@ServiceConnection, which points Spring Boot at a container
testcontainers-mongodbthe MongoDB container, in the Testcontainers 2 artifact
archunitthe architecture rules
awaitilitya wait for a result that arrives later, such as a message that a listener handles
json-schema-validatora 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:

PredictA test class whose name does not end in Test#
The test method fails on purpose. Does mvn verify fail?
class OrderCheck {

    @Test
    void anOrderShipsOnlyWhenPaid() {
        fail("this should fail the build");
    }
}
Show the answer
No. The build passes, and the test never runs.

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:

CommandPluginRunsNeeds Docker
mvn verifymaven-surefire-pluginevery *Test classno
mvn verify -Pitsurefire, then maven-failsafe-pluginevery *Test class, then every *IT classyes

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>
Reading the code
<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 phase test, 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:jar holds 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 release

So 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:

ConcernArtifacts
web, validation and healthspring-boot-starter-webmvc, spring-boot-starter-validation, spring-boot-starter-actuator
the OpenAPI documentspringdoc-openapi-starter-webmvc-ui
securityspring-boot-starter-security-oauth2-resource-server, spring-boot-starter-security-oauth2-client
tracingspring-boot-starter-opentelemetry
calling other servicesspring-boot-starter-restclient, resilience4j-circuitbreaker, resilience4j-retry
DocumentDBspring-boot-starter-data-mongodb
the outbox and the inboxspring-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 resendershedlock-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 starterNote
spring-boot-starter-webmvcit 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-serverthe old name spring-boot-starter-oauth2-resource-server still exists in 4.1.2, and is deprecated
spring-boot-starter-security-oauth2-clientthe old name spring-boot-starter-oauth2-client still exists in 4.1.2, and is deprecated
spring-boot-starter-restclientwithout it, Boot creates no RestClient.Builder bean, and the platform's HTTP clients need one
spring-boot-starter-opentelemetryit sends traces and metrics with OpenTelemetry, and it has no Boot 3 starter
spring-boot-starter-webmvc-testit holds @WebMvcTest and MockMvc, in the package org.springframework.boot.webmvc.test
spring-boot-starter-data-mongodb-testit holds @DataMongoTest, in the package org.springframework.boot.data.mongodb.test
spring-boot-starter-security-testit 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:

Shop.Platform.csproj
<ItemGroup>
  <PackageReference Include="xunit.assert" Version="2.9.3" PrivateAssets="all" />
</ItemGroup>
shop-platform/pom.xml
<!--
  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.

Gotcha

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>
Reading the code
<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:

Order.Service.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <ItemGroup>
    <PackageReference Include="Shop.Platform" />
  </ItemGroup>
</Project>
order-service/pom.xml
<!--
  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>
Reading the code
<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) that java -jar starts. 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:

CommandWhereWhat it does
mvn verifythe companion's rootbuilds the platform and the service, and runs every *Test
mvn verify -Pitthe companion's rootalso runs every *IT, with MongoDB and an AWS emulator in Docker
mvn packageorder-servicebuilds the executable jar that the Dockerfile copies
mvn -pl order-service -am packagethe companion's rootbuilds the service and the modules it depends on, here the parent and the library
mvn installshop-platformputs 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:

  1. The platform team changes the version of spring-boot-starter-parent in shop-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.
  2. 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.
  3. It releases a new platform version, such as 1.4.0, with upgrade notes.
  4. Each service changes the version in its <parent> element to 1.4.0. The library's version follows, because the parent manages it.
  5. 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:

.NETThe shop's buildWhat differs
Directory.Build.propsthe parent's properties and pluginsthe parent is published and named by coordinates, so it works across repositories
Directory.Packages.propsthe parent's dependencyManagementa Maven BOM imports a whole family of versions in one entry
a NuGet packagethe platform librarya library can bring auto-configuration, which runs without a line of startup code
a test project's PackageReference listthe parent's test dependenciesevery 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.

Check yourself
1
An expert asksThe parent declares the test libraries in <dependencies> with the test scope, and the library declares the same libraries as optional. Why both?
The library's main source holds test support, such as @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.
2
Two imported BOMs in dependencyManagement both manage the same artifact. Which version does a child get, and how do you choose another one?
The version from the BOM that is declared first. An entry written directly in the POM's own dependencyManagement wins over every import, so write one to settle the clash.
3
An expert asksWhy does the parent set <relativePath/> for Spring Boot's parent, when the service sets a real path for the shop's parent?
By default, Maven first looks for a parent at ../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.
4
A 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.
5
TranslateWrite 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>
<!-- 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:

LibraryVersionManaged by
Spring Boot4.1.2the parent's parent, spring-boot-starter-parent
Java release25the property java.version
Spring Modulith2.1.2spring-modulith-bom
Spring Cloud AWS4.1.0spring-cloud-aws-dependencies, plus one pinned artifact
Resilience4j2.4.0resilience4j-bom
ShedLock7.10.1shedlock-bom
springdoc-openapi3.2.0a pinned entry
ArchUnit1.5.1a pinned entry
json-schema-validator3.0.8a pinned entry
openapi-generator-maven-plugin7.26.0the property openapi-generator.version, used in the profile contract-first
Testcontainers2.0.5Spring Boot
the platform librarythe same as the parenta pinned entry, with the property shop-platform.version

Maven in depth: resolution and the lifecycle#

The build and the model · Chapter 06· 19 min read

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.

KindC#JavaNote
twistdotnet build runs a fixed MSBuild sequencemvn verify runs the phases of the default lifecycle, in order, up to verifyeach phase runs the plugin goals bound to it, and a phase with no goals does nothing Why
twista target in the project file, or in Directory.Build.targetsa plugin execution bound to a phasethe goal runs in that phase of every build, after the goals that the packaging binds Why
twistthe project after the SDK and Directory.Build.props are appliedthe effective POM, printed by help:effective-pomthe parent chain, the imported bills of materials and the active profiles are folded in, and nothing else counts Why
trapNuGet takes the lowest version that satisfies every referenceMaven takes the nearest declaration, and the first one at equal deptha direct dependency on an old version wins over a library's need for a newer one, with no warning Why
twistNuGet warns of a downgrade with NU1605nothing warns, unless an enforcer rule runsthe build is green, the tests may be green, and the mismatch shows at run time Why
twistDirectory.Packages.props, with versions managed centrallydependencyManagement in the parent POMa managed version beats mediation, and a direct version beats the managed one Why
twistthe NuGet global-packages folder and NuGet.Config~/.m2/repository and settings.xmlthe local repository caches every download, and a mirror in settings.xml sends every request to the team's repository manager Why
newno equivalentNoSuchMethodError, at the first callthe compiler checked one version of a class, and the runtime linked another Why
twista .dll in bin/ with its dependencies beside itBOOT-INF/lib inside the repackaged jarthe Spring Boot plugin's repackage goal puts every dependency jar inside the jar, with an index and layers Why
twistpackages.lock.json, checked with restore --locked-modeproject.build.outputTimestamp, and exact versionswith 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:

C#, the shell
dotnet restore
dotnet build --configuration Release
dotnet test --no-build
Java, the shell
mvn verify

mvn 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:

PhaseWhat it means, in Maven's wordsWhat ran in the service's build
validatevalidate the project is correct and all necessary information is availablenothing bound
compilecompile the source code of the projectcompiler:compile
testrun tests using a suitable unit testing frameworksurefire:test, the fast tests
packagetake the compiled code and package it in its distributable format, such as a JARjar:jar, then spring-boot:repackage
integration-testprocess and deploy the package if necessary into an environment where integration tests can be runnothing, unless the profile it is active
verifyrun any checks to verify the package is valid and meets quality criteriafailsafe:verify, with the profile it
installinstall the package into the local repository, for use as a dependency in other projects locallynot 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>
Reading the code
maven-surefire-plugin

Surefire is the plugin that runs the unit tests, in the phase test. By default it includes every class whose name starts with Test. It also includes the classes whose names end with Test, Tests or TestCase. The parent excludes the *IT.java classes, which are the integration tests.

${org.mockito:mockito-core:jar}

A property that the dependency:properties goal 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:

LayerWhat it adds
the Super POM, inside Maventhe 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.2the compiler settings, the resource filtering, and the repackage execution
shop-platform-parentthe test libraries, the Mockito agent, the surefire excludes, and the profile it
order-service/pom.xmlthe 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:

RuleWhat it saysIn the run
mediationthe nearest definition wins, and at equal depth the first declaration3.9, declared in the service, is at depth one, and 3.14.0 is at depth two
dependency managementa managed version replaces the version a transitive dependency asked forthe BOM turned 3.14.0 into 3.20.0, which is still at depth two
direct declarationthe current POM's own version beats its parent's managed version3.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.0

The 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 FAILURE

The 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 -> 201

The 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 -> 2

The 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.jar

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

Pragmatic choice: No reproducible-build property in the parent yet

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.

Check yourself
1
A 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?
Version 3.9. The direct declaration is the nearest definition, and it also beats a version that the parent's dependencyManagement sets. Maven prints no warning. The dependency:tree -Dverbose=true output shows the loser in brackets with "omitted for conflict with 3.9".
2
An expert asksWhy can a build compile and pass its tests, and still throw NoSuchMethodError at run time?
The compiler checks only the project's own source against the jars on the classpath. A dependency jar was compiled earlier, against the versions it declared, and Maven never compiles it again. If mediation puts an older version of one of those jars on the classpath, the call inside the dependency breaks. It links against a class that no longer has the method. The error appears at the first call, and only if a test walks that path.
3
TranslateTranslate 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" />
A managed version in the parent POM, and a dependency without a version in the project.
<!-- 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>
The managed version also replaces the version that any transitive dependency asks for, which Directory.Packages.props does only with transitive-pinning switched on.
4
Which goal turns the plain jar of the service into the jar that java -jar runs, and where do the dependency jars go?
The Spring Boot plugin's 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 build and the model · Chapter 07· 46 min read· Quick reference

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.

KindC#JavaNote
newno equivalentthe package com.acme.platform.domain, with no Spring importthe base classes hold no business concept, and an architecture rule keeps every domain package plain Java Why
twistan abstract Entity<TId> class that you write yourselfEntity<ID extends Identifier>equals and hashCode are final, so no subclass can change what identity means Why
newno equivalentAggregateRoot, with raise and pullEventsthe root collects its events, and pullEvents returns a copy and clears the list Why
twistreadonly record struct OrderId(Guid Value)final class OrderId extends Identifiera record cannot extend a class, so a typed ID is a small final class with two factories Why
trapGuid.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
twistGuid.TryParse accepts upper and lower caseIdentifier stores the UUID in lower casean upper-case ID from a client is accepted, and two spellings of one UUID are equal Why
twista record whose constructor throwsa record whose compact constructor calls Guardeach check names the field, and the platform answers the error with 400 Why
twista record's ToString prints every propertytoString() returns CustomerContact[masked]both default forms print personal data, so a value object that holds it overrides the method Why
twistMath.Round(12.345m, 2) gives 12.34Money.of("12.345", "GBP") throwsrounding would change an amount from another service without a word, so the value object refuses it Why
twist10m == 10.00m is trueamount.setScale(2) in the compact constructorBigDecimal equality compares the scale too, so Money stores two places and 10 equals 10.00 Why
newno equivalentGuard.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 onlyBean Validation on the request, and Guard in the value objectthe request lists every bad field at once, and the domain's check is the one that counts Why
newpublic setters on the entityprivate fields that only the rule methods changethe state changes only through methods that apply the rules, and an architecture rule checks it Why
newa public constructorOrder.place and Order.rehydrateplacing applies the rules and raises an event, but loading applies no rule and raises nothing Why
twista TimeProvider passed to the methoda Clock passed to each method that reads the timetests pass Clock.fixed, and no domain code calls Instant.now() Why
newno equivalentship, cancel and pickLine, each with requireNotFinishedeach method checks its rule, changes the state and raises its event in one place Why
newno equivalentboolean recordPayment(Payment payment)a repeated or late payment changes nothing, so the caller saves only when the method returns true Why
twistpublic LineStatus Status => ...LineStatus status(), computed from pickedAta derived status cannot disagree with the time it comes from Why
trappublic IReadOnlyList<OrderLine> Lines => linesreturn List.copyOf(lines)Java has no read-only list interface, so returning the field would let callers change it Why
newno equivalentrecord OrderShipped(...) implements DomainEventeach event is a record with a time, raised in the method that changed the state Why
twistan IOrderRepository that you write yourselfRepository<A extends AggregateRoot<ID>, ID extends Identifier>two type parameters with bounds tie each repository to one aggregate and its ID type Why
twista default interface member, since C# 8default Order get(OrderId id)the port loads the order or throws the 404 exception in one line, for every adapter Why
twistTask<Order?> FindByIdAsync(OrderId id)Optional<Order> findById(OrderId id)the return type says that the order may be missing Why
newno equivalentOrder saved = orders.save(order), with a final versionthe domain never changes its own version, so the use case and its tests continue with the returned instance Why
twistSkip and Take, or a page class that you writethe records PageQuery and PageResult<T>paging without Spring types, counted from zero, with at most 100 items on a page Why
newan abstract exception with a private protected constructorsealed DomainException, which permits three non-sealed kindsthe kind decides the HTTP status, and no feature can add a fourth kind Why
newno equivalentone exception class for each broken rule, with a code such as ORDER_NOT_PAIDclients may rely on the code, so it never changes, and the message holds no personal data Why
twistno equivalentNote, an entity in the light stylethe note keeps its rules with String IDs and no base class, and the chapter on levels compares the styles in full Why
newno equivalentplain JUnit tests with a fixed clockthe 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 aggregates

The 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");
}
Reading the code
noClasses().that().resideInAPackage("..domain..")

This is ArchUnit, a test library that reads compiled classes. The pattern ..domain.. matches every package with domain in 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, @Component or 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.domain is 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 blockJava typeWhat it gives each aggregate
AggregateRoot<ID>abstract classthe version for optimistic locking, and the list of raised events
Entity<ID>abstract classequality by class and ID
Identifierabstract classthe UUID check, equality and toString of a typed ID
ValueObjectinterface with no methodsno code, because it only marks the intent for readers and for architecture tests
DomainEventinterfacea time on every event
DomainEventPublisherinterface, a portthe way events leave the use case, which the outbox implements
Repository<A, ID>interface, a portfindById, save and exists, typed for one aggregate
Guardfinal class with static methodschecks for null, blank text, length, format, range and size
PageQuery and PageResult<T>recordspaging without the Page and Pageable types of Spring Data
DomainException and its three kindsa sealed exception classthe 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.

Pragmatic choice: Base classes from a shared library in the domain

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#

C# 12
public abstract class Entity<TId> where TId : Identifier
{
    protected Entity(TId id)
    {
        ArgumentNullException.ThrowIfNull(id);
        Id = id;
    }

    public TId Id { get; }
}
The platform library
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;
    }
Reading the code
public abstract class Entity<ID extends Identifier> {

The type parameter ID must be an Identifier or a subclass of it. The C# form writes this as where TId : Identifier. Java writes the bound inside the angle brackets, with extends. For bounds, see the section Declaration.

protected Entity(ID id) {

Only a subclass can call a protected constructor. Code in the same package can call it too, because protected in Java includes the package. For the access levels, see the section The mapping.

this.id = Objects.requireNonNull(id, "id");

Objects.requireNonNull is in java.util.Objects. It returns the value, or throws NullPointerException with the message id. So an entity always has an ID.

public ID id() {

The accessor has no get prefix, 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;
    }
}
Reading the code
public abstract class AggregateRoot<ID extends Identifier> extends Entity<ID> {

A root is an entity, so it inherits equality by ID. The bound on ID is repeated, because Entity accepts only an Identifier.

private final Long version;

The type is Long, the object form of long, so it can be null. A null version means that the aggregate was never saved. The field is final, 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) {

protected lets only subclasses record an event. final stops 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.copyOf returns 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;
    }
}
Reading the code
Pattern.compile("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");

Pattern is in java.util.regex. The pattern is compiled once, when the class loads, because the field is static 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.ROOT applies 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. UUID is in java.util, and randomUUID creates a random UUID.

if (other == null || getClass() != other.getClass()) {

Two identifiers are equal only when they have the same class. So an OrderId is never equal to a LineId, even with the same UUID. A run for this book confirmed it.

return Objects.hash(getClass(), value);

Objects.hash combines several values into one hash code. The class is part of the hash, as it is part of equals.

public String toString() {

The text form is the UUID itself. So a message such as "Order " + id prints the ID, with no class name around it.

Gotcha

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());
    }
}
Reading the code
public final class LineId extends Identifier {

final means that no class can extend LineId. 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) {

of wraps 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() {

newId creates 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#

C# 12
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]";
}
The order service
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]";
    }
}
Reading the code
public record CustomerContact(String name, String email) implements ValueObject {

The record gets a constructor and the accessors name() and email(). It also gets equals and hashCode, which compare the values. implements ValueObject adds 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:

FormWhat it printed
the C# record without the overrideCustomerContact { Name = Jo Bloggs, Email = jo@example.com }
a Java record without the override, named Plain in the runPlain[name=Jo Bloggs, email=jo@example.com]
CustomerContact in the order serviceCustomerContact[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);
    }
}
Reading the code
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 than new StockAvailability(true, List.of()).

The other value objects of the order feature follow the same pattern:

Value objectWhat it checksWhy
ProductCodethe format [A-Z0-9-]{1,30}whether the product is in stock is the stock service's answer
OrderItema product, and a quantity from 1 to 999an item becomes an order line when the order is placed
PaymentReferencenot blank, at most 50 charactersthe limit matches the schema of the event that brings it
Paymenta reference, an amount and a time, none of them nulla value object made of value objects, compared as a whole
OrderStatusan enum with NEW, PAID, SHIPPED and CANCELLEDstored as its name, so a value is never renamed
LineStatusan enum with WAITING and PICKEDcomputed 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#

C# 12
public record Money(decimal Amount, string Currency)
{
    public decimal Amount { get; } = Math.Round(Amount, 2);
}
The order service
/**
 * 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);
    }
}
Reading the code
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 BigDecimal is the number of digits after the point. stripTrailingZeros() removes zeros at the end first. So 12.340 has two real decimal places and is accepted, but 12.345 has 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. setScale without a rounding mode throws ArithmeticException: Rounding necessary when 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))) {

Currency is in java.util. The check refuses a code with the right form that is not a real currency, such as XYZ.

public static Money of(String amount, String currency) {

The factory takes the amount as text. new BigDecimal("12.50") keeps the exact value. A double cannot 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;
    }
Reading the code
public final class Guard {

The class only holds static methods. final stops 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.matches checks the whole text against the pattern. So the patterns of the value objects need no ^ and $. For how this differs from Regex.IsMatch, see the section IsMatch in Java.

public static <C extends Collection<?>> C notEmpty(C values, String field) {

The type parameter C is 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 a List<OrderItem> comes back as a List<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:

CheckText after the field nameExample message
notNullis requiredpaidAt is required
notBlankmust not be blankcustomer.name must not be blank
maxLengthmust be at most N characterscustomer.name must be at most 200 characters
matcheshas an invalid formatcustomer.email has an invalid format
betweenmust be between MIN and MAXquantity must be between 1 and 999
notNegativemust not be negativeamount must not be negative
notEmptymust have at least 1 itemlines must have at least 1 item
maxSizemust have at most N itemslines 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.

Pragmatic choice: The same check on the request and in the value object

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;
    }
Reading the code
public class Order extends AggregateRoot<OrderId> {

The type argument fixes the ID type. So order.id() returns an OrderId, 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 final field 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 place or rehydrate, 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");
}
Reading the code
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 set and 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);
}
Reading the code
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::add is 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 raise on 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#

ASP.NET Core 10
builder.Services.AddSingleton(TimeProvider.System);
The platform library
/** The clock that the domain reads the time from. Code never calls {@code Instant.now()}. */
@Bean
@ConditionalOnMissingBean
Clock clock() {
    return Clock.systemUTC();
}
Reading the code
@ConditionalOnMissingBean

Spring creates this bean only if the service has no Clock bean of its own. So a test can declare a fixed clock, and the platform then creates none.

return Clock.systemUTC();

Clock is in java.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);
    }
}
Reading the code
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())) {

anyMatch returns true when at least one element matches, like Any in 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;
}
Reading the code
if (lastPayment != null && !payment.paidAt().isAfter(lastPayment.paidAt())) {

isAfter compares two Instant values. 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#

C# 12
public DateTimeOffset? PickedAt { get; private set; }

public bool IsPicked => PickedAt is not null;

public LineStatus Status => IsPicked ? LineStatus.Picked : LineStatus.Waiting;
The order service
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:

PredictA caller clears the lines of an order#
A caller writes order.lines().clear(). Does it compile, and what happens when it runs?
/** Returns a read-only copy, so outside code cannot add or remove lines. */
public List<OrderLine> lines() {
    return List.copyOf(lines);
}
Show the answer
It compiles. When it runs, it throws 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 returnsCan the caller change it?Does it see later changes of the order?
the field itselfyes, and the order's own list changesyes
Collections.unmodifiableList(lines)no, it throwsyes, because it is a view of the order's list
List.copyOf(lines)no, it throwsno, because it is a copy made at the call
lines.stream().toList()no, it throws, from Java 16no, 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#
Means
A class outside the order's model package called a method that has no access modifier. Only classes of the same package may call such a method.
Fix
Call the method of the aggregate root instead, such as 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 eventRaised byLeaves the service as
OrderPlacedOrder.placeOrderPlacedV1, on the topic order-events
LinePickedOrder.pickLinenothing, because no other service needs it
OrderShippedOrder.shipOrderShippedV1
OrderCancelledOrder.cancelOrderCancelledV1
PaymentRecordedOrder.recordPaymentnothing, 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;
}
Reading the code
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#

C# 12
public interface IOrderRepository
{
    Task<Order?> FindByIdAsync(OrderId id);

    async Task<Order> GetAsync(OrderId id) =>
        await FindByIdAsync(id) ?? throw new OrderNotFoundException(id);
}
The order service
/**
 * 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);
}
Reading the code
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 default method 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));

findById returns an Optional. orElseThrow returns 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);
    }
}
Reading the code
items = List.copyOf(items);

The record stores a copy that cannot change, as StockAvailability does.

return size == 0 ? 0 : (int) Math.ceil((double) totalItems / size);

41 items in pages of 20 make 3 pages. The test GuardTest.pageQueryLimitsTheSize checks 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 T and return any subtype of R. This rule is called PECS, "producer extends, consumer super". See Variance is at the use site.

items.stream().<R>map(mapper).toList()

.<R>map gives the type argument of map explicitly. Without it, the compiler infers a stream of "some subtype of R". A run for this book then failed with error: 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:

C# 12
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 platform library
/**
 * 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;
    }
}
Reading the code
public abstract sealed class DomainException extends RuntimeException

A 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 DomainException directly. Only these three may. A run for this book compiled class Oops extends DomainException in 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)#
Means
A class extends DomainException directly, but the sealed class allows only its three kinds.
Fix
Extend one of the kinds instead, such as 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:

KindStatusConstructorHow a feature uses it
NotFoundException404protected, with a code and a messageone subclass for each kind of thing, such as OrderNotFoundException
InvalidValueException400public, with a field and a problemthrown directly, mostly by Guard, and its code is always INVALID_VALUE
BusinessRuleViolationException422protected, with a code and a messageone 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:

ExceptionKindCodeThrown when
OrderNotFoundExceptionnot foundORDER_NOT_FOUNDno order has the ID
LineNotFoundExceptionnot foundLINE_NOT_FOUNDthe order has no line with the ID
ProductsUnavailableExceptionrulePRODUCTS_UNAVAILABLEthe stock service cannot hold every product
LineAlreadyPickedExceptionruleLINE_ALREADY_PICKEDa line is picked a second time
OrderNotPaidExceptionruleORDER_NOT_PAIDan order ships before it is paid
UnpickedLinesExceptionruleUNPICKED_LINESan order ships with a line that is not picked
OrderHasOpenActionsExceptionruleORDER_HAS_OPEN_ACTIONSan order ships while a note on it needs action
OrderFinishedExceptionruleORDER_FINISHEDa 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:

ConcernOrder, the full styleNote, the light style
the ID of the entityOrderId, checked by Identifiera String made from UUID.randomUUID()
a reference to another aggregatenone, because the order refers to no other aggregatea String orderId, with no OrderRef type, checked by the order feature
valuesvalue objects, such as CustomerContactStrings checked in create, such as the category with Guard.matches
personal data in toStringCustomerContact[masked]the text is left out, and only its length is printed
loadingOrder.rehydrate, called by the mappera constructor marked @PersistenceCreator, called by Spring Data
eventsAggregateRoot.raise and pullEventsits own list, marked @Transient, and its own pullEvents
the repositorya port with an adaptera 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));
        }
Reading the code
@Nested

JUnit runs the tests of an inner class as a group. The class OrderTest has five such groups, one for each method of the order, such as Placing. 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.

Check yourself
1
An expert asksWhy does Order have two static factories, place and rehydrate, and a private constructor, instead of one public constructor?
Placing is a business action. It checks the stock answer and the number of lines, and it raises 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.
2
An 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?
The amount comes from another service. A third decimal place means that the other side made a mistake or uses another unit. Rounding would change the amount and hide the mistake. Refusing makes the mistake visible. Rounding with a named mode is right only where the service computes an amount itself.
3
The 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?
The real repository returns a new instance, because the version of an order is final. A mock that returned the same object would let a use case that keeps the old instance pass its tests. With a rebuilt copy, the test behaves like storage, and a use case must continue with the returned instance.
4
A 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?
It extends 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.
5
TranslateWrite 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));
}
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 get prefix, such as amount(). See The basics.
  • Compact constructors check values, and may replace them. Examples are Money and PageResult. 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, newId and place. 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 in PageResult.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, AggregateRoot and Identifier. See Abstract classes.
  • A final class with a private constructor holds static helpers, in Guard and TestData. See No static classes.
  • Package-private access keeps OrderLine.add and OrderLine.pick inside 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 as IntegrationEvent.newEventId(). It may hold constants too, such as CurrentActor.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, anyMatch and toList(). The outbox also uses flatMap(Optional::stream) and sorted. See Operator translation.
  • Optional is the return type of findById, and orElseThrow turns it into a 404. See Optional is for return values.
  • Collections that cannot change come from List.copyOf and List.of. Maps have Map.of and Map.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, Clock and ZoneOffset. Other features use LocalDate and Duration. See java.time.
  • BigDecimal holds an amount, with setScale and stripTrailingZeros in Money. 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 Entity and Identifier. See Writing them.

Where each rule goes#

The build and the model · Chapter 08· 19 min read

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.

KindC#JavaNote
newan if in the controller or the service classa method of the aggregate, a domain service, or a step of the application serviceeach place holds one kind of logic, and may use only certain things Why
newno equivalentfive questions, asked in orderthe first yes decides the place, so each rule ends in the most local place that can check it Why
newno equivalentthe business-expert testa line that a business expert would call a rule does not belong in the application service Why
newif (notesNeedingAction > 0) throw ... in ShipAsyncShippingPolicy.assertCanShip(order, notesNeedingAction)the rule needs two aggregates, so a domain service receives a number from the note feature Why
newno equivalentStockChecker in application/port, and the answer judged in Order.placethe use case asks the other service, and the domain decides what the answer means Why
newno equivalenta table of the service's rules, each with its placeeach rule has one home, which a reader finds without a search Why
newno equivalentReferenceDataService.requireActivethe lookup check needs stored data, so it stays in the simple feature's service as a pragmatic choice Why
neworder.Status = OrderStatus.Shipped in a serviceorder.ship(clock)with public setters, any caller can skip the rule, forget the event or read the system time Why
newOrderDomainServiceShippingPolicya 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
}
Reading the code
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:

PlaceWhat it holdsWhat it may useWhat it must not useDoes it change state?How it is tested
the aggregate, such as Orderrules on its own data, such as "paid and every line picked"its own fields, value objects, and the clock it receivesrepositories, other aggregates, Spring, HTTPyes, its own stateplain JUnit, in milliseconds
a domain service, such as ShippingPolicya rule that needs data from more than one aggregatethe aggregates and values that it receivesrepositories, Spring, and state of its ownnoplain JUnit
the application service, such as OrderServicethe steps of a use caserepositories, ports, other features' services, transactionsbusiness rulesno, because it asks the domain to changeMockito mocks of the ports
a value object, such as Moneythe form of one valueGuard, and its own componentsanything outside itselfno, because it never changesplain 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:

1. Does it check the form of one value? such as a product code, a quantity or an amount a value object, with Guard 2. Does it need only one aggregate's data? such as "paid, and every line picked" a method of the aggregate 3. Does it need two or more aggregates? such as an order and its notes a domain service, given the data 4. Does another feature own the data? such as the lookup lists, or the notes that feature's service 5. Is it input, output, a transaction or a permission? such as calling the stock service first the application service no to all: ask a business expert what the rule means
The five questions, in order. The form of one value goes into a value object. One aggregate's data goes into the aggregate. Two or more aggregates go into a domain service. Data of another feature goes through that feature's service. Input, output, transactions and permissions are steps of the application service.

Each question, with an answer from the order service:

  1. 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.
  2. One aggregate's data. "At most 50 lines" needs only the order's lines. So Order.place checks it. The rules on one aggregate are the most common kind, so most rules stop here.
  3. 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.
  4. 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.
  5. 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 wordsWould an expert call it a rule?Where it belongs
an order that is not paid cannot shipyesOrder.ship
an order with a note that needs action cannot shipyesShippingPolicy
a repeated payment changes nothingyesOrder.recordPayment
save the order only if the payment changed itno, this is about storageOrderService.recordPayment
reserve the stock before the transaction startsno, this is about how the work runsOrderService.place
publish the events after the savenoOrderService.saveAndPublish
the delivery method must be an active lookup valueyes, but it needs stored dataReferenceDataService, 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:

RuleWhere it isWhy there
an order is placed only if the stock service holds every productOrder.place, with the answer from the StockChecker portthe answer comes from outside, but its meaning is a rule of the order
an order has 1 to 50 linesOrder.place, with Guard.notEmpty and Guard.maxSizelines are added only when the order is placed
a quantity is from 1 to 999OrderItem, with Guard.betweenthe form of one value
a product code, a delivery method code and a currency have a fixed formProductCode, DeliveryMethod and Moneythe form of one value
an amount has at most two decimal placesMoneythe form of one value, refused rather than rounded
the delivery method is an active lookup valueReferenceDataService.requireActive, called by OrderService.placethe list is stored data of another feature
a line is picked onceOrderLine.pick, called by Order.pickLinethe line's own data, changed only through the root
a shipped or cancelled order cannot changeOrder.requireNotFinishedthe order's own status
an order ships only when it is paid and every line is pickedOrder.shipthe order's own data
an order cannot ship while a note needs actionShippingPolicy.assertCanShipthe rule needs the order and its notes
a repeated or earlier payment changes nothingOrder.recordPaymentthe order's own last payment
a payment for a finished order is still recordedOrder.recordPayment, on purpose and testedthe money was received, and refunds belong to the payment service
a note that needs action is resolved onceNote.resolveActionthe note's own data
the agent who wrote a note does not resolve its actionNote.resolveAction, with the actor's ID passed inthe note's own author, compared with a plain value
a note under another order is not foundNote.requireBelongsTothe note's own data, answered with 404
a note is added only to an order that existsOrderService.requireExists, called by NoteServicethe order feature owns its IDs
a caller needs a scope to change an orderthe @OrdersWrite annotation on each controller methoda 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");
        }
    }
Reading the code
.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.

Pragmatic choice: A lookup rule in an application service

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:

Anaemic, C# 12
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();
    }
}
The order service
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, and ArchitectureTest.entitiesHaveNoPublicSetters keeps 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, ship raises OrderShipped itself.
  • The time is hidden. DateTime.UtcNow inside the service reads the system clock. A test cannot fix the time, so it cannot check ShippedAt exactly. In the order service, ship receives a Clock.

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() or LocalDate.now() instead of a Clock.
  • 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:

NameWhat it says
ShippingPolicyit decides whether something is allowed, here shipping
a name ending in Calculator, such as a delivery charge calculatorit computes a value from the values it receives
a name ending in Checker or Validatoravoid it for a domain service, because it hides which rule is checked
OrderDomainService, OrderHelper or OrderManageravoid 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.

Check yourself
1
An expert asksWhy does ShippingPolicy.assertCanShip take the number of notes that need action, and not the list of notes?
A number is all the rule needs. With a count, no note object crosses into the order feature, which may not hold another feature's entities. The note feature keeps its own model private, and counts with one query. The policy's test passes 0 or 2, with no note, no repository and no mock.
2
An expert asksWhy is "no note needs action" not an if in OrderService.ship?
A business expert would recognise the line as a rule. A rule in one use case is missing from the next use case that ships, such as a listener for a warehouse message. That use case must copy the check, or it forgets it. Its test would also need the mocks of the whole use case. In a domain service, every caller uses one check, tested with plain values.
3
A 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?
In 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.
4
What is the business-expert test for a line in an application service?
Read the line in plain words to a person who knows the business but no code. If they would call it one of their rules, the line does not belong in the application service. It moves into the aggregate, a value object or a domain service.
5
TranslateWrite 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 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#

Wiring, configuration and use cases · Chapter 09· 29 min read· Quick reference

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.

KindC#JavaNote
twistIServiceProvider, built from the IServiceCollectionApplicationContextthe object that creates and holds every bean, and that a test can ask for a bean Why
twistbuilder.Services.AddScoped<OrderService>() for each class@Service on the class, found by scanning from OrderServiceApplicationSpring searches the package of the application class and every package below it Why
twistbuilder.Services.AddShopPlatform() from a NuGet packageauto-configuration in shop-platformthe platform's packages are not below com.acme.orderservice, so scanning never finds them Why
twista primary constructor with nine parametersone constructor with nine parameters, and no annotationSpring passes a bean for each parameter, and a unit test passes mocks with new Why
twistAddScoped<IOrderRepository, OrderPersistenceAdapter>()@Component on OrderPersistenceAdapter, which implements OrderRepositorySpring finds the one bean that implements the interface, so no line maps one to the other Why
twistAddSingleton(sp => new ShippingPolicy())a @Bean method in OrderConfigthe domain class carries no Spring annotation, so a configuration class of the feature creates it Why
newno equivalent@Configuration(proxyBeanMethods = false)Spring does not subclass the class, so a call from one @Bean method to another creates a second object Why
twista private nested class that implements the interfacenew WebMvcConfigurer() { ... } in a @Bean methoda Java anonymous class implements an interface inline, and C# has no such expression Why
twistTryAddSingleton(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
twistsp.GetService<T>() returns null when nothing is registeredObjectProvider<T>.getIfAvailable()it returns null when no bean exists, and getObject() throws instead Why
trapGetRequiredService<T>() with two registrations returns the last oneObjectProvider<T>.getObject() with two beans throwsSpring refuses to choose between two candidates, so a second bean breaks the lookup Why
twistIEnumerable<IIntegrationEventMapper>ObjectProvider<IntegrationEventMapper>.orderedStream().toList()every bean of the type, in the order of @Order, and an empty list when there is none Why
twistnew TransactionScope() around the codean injected TransactionTemplateBoot adds the bean once a transaction manager exists, and the platform provides that manager Why
twistno equivalent built into the containerApplicationEventPublisher.publishEvent(event)the application context itself is the publisher, and Spring Modulith listens to it Why
twistInvalidOperationException: A circular dependency was detected, at the first resolvethe cycle report at startupboth containers refuse two classes whose constructors need each other Why
newLazy<OrderService>, which needs a registration of its own@Lazy on the constructor parameterSpring injects a proxy that finds the real bean at its first call Why
newIHostedService.StartAsync, before the server listensSmartInitializingSingleton.afterSingletonsInstantiated()the migrations run after every singleton exists, and before the web server and the queue listeners start Why
newa decorator class that you register yourselfthe proxies of @Transactional, @PreAuthorize and @CacheableSpring wraps the bean, and every other bean receives the wrapper Why
trapa call to this.Method() inside a decorated classa call to this.method() inside a proxied beanthe call skips the proxy, so the service puts the cache on the repository that it calls Why
newno equivalentApplicationWiringTestthe 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:

Component scanning @Service, @Component, @RestController under com.acme.orderservice @Bean methods of the features OrderConfig, StockClientConfig for classes without an annotation Auto-configuration shop-platform and Spring Boot each bean only if a condition holds ApplicationContext creates each bean once, passes it to the constructors that need it
The application context of the order service gets its beans from three sources. They are component scanning of com.acme.orderservice, the @Bean methods of the feature configuration classes, and the auto-configuration of shop-platform and Spring Boot.

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");
}
Reading the code
@Autowired

A test class has no constructor that Spring calls. So the test asks for the context with @Autowired on a field. Production code takes its beans through the constructor instead.

context.getBean(PlatformTransactionManager.class)

getBean returns the one bean of a type, like GetRequiredService<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:

StereotypeClasses in the order serviceWhy this one
@ServiceOrderService, NoteService, ReferenceDataServicethe use cases of each feature
@ComponentOrderPersistenceAdapter, StockAdapter, PaymentReceivedListener, OrderMetricsan adapter or a listener, which is neither a use case nor a controller
@RestControllerOrderController, NoteController, ReferenceDataControllerSpring MVC sends HTTP requests to these classes
@ConfigurationOrderConfig, StockClientConfig, ReferenceConfig, OpenApiConfiga 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.PlatformMessagingAutoConfiguration

The 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:

WayUse it forIn the order service
a stereotype on the classyour own classesthe services, adapters, controllers and listeners
a @Bean method in a @Configuration classa class that must not carry a Spring annotation, or that needs settings to buildShippingPolicy, the stock HTTP client
auto-configurationa library that adds beans to every service, with conditionsthe 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#

C#
public class OrderService(
    IOrderRepository orders, NoteService notes,
    ReferenceDataService referenceData, IStockChecker stock,
    ShippingPolicy shippingPolicy, IDomainEventPublisher events,
    IAuditLog audit, ITransactionRunner transaction,
    TimeProvider clock)
{
}
Java 25
@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;
    }
Reading the code
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:

C#
builder.Services
    .AddScoped<IOrderRepository, OrderPersistenceAdapter>();

internal class OrderPersistenceAdapter : IOrderRepository
{
}
Java 25
@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;
    }
Reading the code
@Component

The adapter is a bean. Spring registers it under all its types, so a request for OrderRepository finds it. No line maps the interface to the class, as AddScoped<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#

C#
builder.Services.AddSingleton(sp => new ShippingPolicy());
Java 25
@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);
    }
}
Reading the code
@ConfigurationProperties("clients.stock")

On a @Bean method, this annotation fills the returned object from the settings under clients.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 @Bean method, as it does for a constructor. HttpClientFactory comes 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:

WayWhat it costsWhen it is the better choice
@Bean in the feature's configuration classone more class, and a reader must look in two placesthe full style, where the domain stays plain Java and is tested with new
@Component on the domain classthe domain imports Spring, and the architecture rule must allow itthe 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:

SettingThe class Spring usedThe object that the direct call returned
proxyBeanMethods = true, the defaultRun$FullConfig$$SpringCGLIB$$0, a subclassthe same bean that the context holds
proxyBeanMethods = falseRun$LiteConfig, the class itselfa second object, which the context does not hold
Gotcha

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#
// 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>();
Java 25
@Bean
WebMvcConfigurer platformMdcWebMvcConfigurer(PlatformLoggingProperties properties) {
    PathVariableMdcInterceptor interceptor = new PathVariableMdcInterceptor(properties.getMdcPathVariables());
    return new WebMvcConfigurer() {
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(interceptor);
        }
    };
}
Reading the code
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) {

WebMvcConfigurer is 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 every WebMvcConfigurer bean 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#

C#
// in the library's AddShopPlatform()
services.TryAddSingleton(TimeProvider.System);
Java 25
/** The clock that the domain reads the time from. Code never calls {@code Instant.now()}. */
@Bean
@ConditionalOnMissingBean
Clock clock() {
    return Clock.systemUTC();
}
Reading the code
@ConditionalOnMissingBean

Spring creates this bean only if no other bean of the type Clock exists. With no type in the brackets, the condition uses the return type of the method.

return Clock.systemUTC();

java.time.Clock gives the current time. systemUTC() reads the system clock in Coordinated Universal Time (UTC). The type has the same purpose as TimeProvider in .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 orderWhat GetRequiredService<TimeProvider>() returned
the service's AddSingleton, then the library's TryAddSingletonthe service's clock, and TryAdd added nothing
the library's TryAddSingleton, then the service's AddSingletonthe 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);
}
Reading the code
ObjectProvider<RestClient.Builder> restClientBuilders,

ObjectProvider is in the package org.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, like Func<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, or null when 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::getObject

This is a method reference. It turns the method getObject of this provider into a Supplier, which the factory calls once for each client. Boot's RestClient.Builder bean 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:

MethodNo beanOne beanTwo beans
getObject()throws NoSuchBeanDefinitionExceptionthe beanthrows NoUniqueBeanDefinitionException
getIfAvailable()nullthe beanthrows NoUniqueBeanDefinitionException
getIfAvailable(supplier)the supplier's valuethe beanthrows NoUniqueBeanDefinitionException
getIfUnique()nullthe beannull
orderedStream()an empty streamthe beanboth, in the order of @Order
stream()an empty streamthe beanboth, 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.

Gotcha

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#

C#
public class OutboxDomainEventPublisher(
    IEnumerable<IIntegrationEventMapper> mappers)
{
    private readonly List<IIntegrationEventMapper> _mappers =
        mappers.ToList();
}
Java 25
@Bean
@ConditionalOnMissingBean(DomainEventPublisher.class)
OutboxDomainEventPublisher domainEventPublisher(ApplicationEventPublisher publisher,
                                                ObjectProvider<IntegrationEventMapper> mappers) {
    return new OutboxDomainEventPublisher(publisher, mappers.orderedStream().toList());
}
Reading the code
ObjectProvider<IntegrationEventMapper> mappers) {

Each feature that publishes events to other services has one IntegrationEventMapper. The order feature has OrderIntegrationEventMapper, and the note feature has none.

mappers.orderedStream().toList()

orderedStream() returns every mapper bean, sorted by @Order when 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#

C#
using var scope = new TransactionScope(
    TransactionScopeAsyncFlowOption.Enabled);
var saved = SaveAndPublish(order);
var noteIds = notes.AddNotes(saved.Id, firstNotes);
scope.Complete();
Java 25
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);
});
Reading the code
return transaction.execute(status -> {

transaction is the injected TransactionTemplate, from the package org.springframework.transaction.support. execute starts 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 transaction

addNotes is a @Transactional method 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);
    }
}
Reading the code
private final ApplicationEventPublisher publisher;

ApplicationEventPublisher is in the package org.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::stream is a method reference. It turns each optional into a stream of zero or one element, so flatMap keeps 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:

C#
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'.
Java 25
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.

AT STARTUP 1. NoteService gets a proxy for OrderService 2. OrderService gets the real NoteService 3. Both exist the service starts AT THE FIRST CALL orders.requireExists(id) called on the proxy The proxy finds the OrderService bean OrderService runs requireExists The proxy keeps the bean it found, because OrderService is a singleton.
With @Lazy on a constructor parameter, Spring creates NoteService with a lazy proxy for OrderService, then OrderService with the real NoteService. At the first call, the proxy finds the real OrderService bean and forwards the call.

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 outWhat it gets rightWhat it costs
@Lazy on one parameterone annotation, and the constructor still lists the dependencythe cycle stays, and a missing bean fails only at the first call
ObjectProvider<OrderService> in NoteServicethe code says plainly that it looks the bean up laterthe same cycle, with more code at each call
one dependency made one-way: the note controller calls requireExistsno cycle, and the note service needs nothing from ordersa caller of NoteService.addNote must remember the check
a third bean that both features useno cycle, if the shared question has its own nameone more class, and here the questions belong to their features
an event in place of one callno compile-time dependency at allonly for "something happened", never for a check that must answer now
spring.main.allow-circular-references=truenothing in new codeit 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.

Pragmatic choice: A cycle between two features, broken with @Lazy

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();
        }
    }
Reading the code
public class MongoMigrationRunner implements SmartInitializingSingleton {

SmartInitializingSingleton is in the package org.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:

1. Create every singleton bean constructors, @PostConstruct, proxies 2. afterSingletonsInstantiated() MongoMigrationRunner runs the migrations no port open, no listener running 3. SmartLifecycle.start() the web server, the queue listener containers 4. ApplicationRunner.run() readiness is still OUT_OF_SERVICE 5. Ready readiness accepts traffic
At startup, Spring creates every singleton, then calls afterSingletonsInstantiated, where the migrations run. Then SmartLifecycle beans start the web server and the Amazon Simple Queue Service (SQS) listener containers. ApplicationRunner beans run after that, and readiness then accepts traffic.

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:

PlaceWhat it gets rightWhat it costs
afterSingletonsInstantiated, the platform's choiceno request and no message can arrive before the data is readyno port is open while it waits, so a probe gets no answer
an ApplicationRunnerthe web server answers probes, and readiness stays OUT_OF_SERVICE meanwhilethe SQS listeners have already started, so a message can arrive before the indexes exist
a separate job before the deploymentthe service starts fast, and one job migrates onceone 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: 60s

The 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:

AnnotationOnWhat the proxy does
@Transactionalthe methods of OrderService and NoteService that change datastarts a transaction, or joins one, and commits or rolls back
@OrdersRead and @OrdersWriteeach controller methodchecks the scope before the method runs, because each carries @PreAuthorize
@Cacheable("reference-data")ReferenceItemRepository.findByTypeOrderByCodeAscreturns the cached list for the same type, and calls the method only on a miss
@Lazythe OrderService parameter of NoteServicefinds 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.

Gotcha

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 {
Reading the code
@SpringBootTest

The test starts the whole application context, as main would.

@org.springframework.test.context.ActiveProfiles("wiring")

The test switches on the profile wiring, so Boot also reads application-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.

Check yourself
1
An expert asksEvery configuration class of the order service writes @Configuration(proxyBeanMethods = false). Why is that safe here, and when would it break?
The setting stops Spring from subclassing the class with CGLIB. Without the subclass, a direct call from one @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()).
2
An expert asksWhy does the platform run the migrations in afterSingletonsInstantiated, and not in an ApplicationRunner?
Spring calls 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.
3
The note service takes @Lazy OrderService orders in its constructor. What does Spring inject, and when does it find the real bean?
Spring injects a lazy-resolution proxy, a subclass made with CGLIB. The proxy finds the real 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.
4
TranslateThe 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());
@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 CoreSpringNote
IServiceCollectionthe bean definitionsSpring collects them from scanning, @Bean methods and auto-configuration
IServiceProviderApplicationContextit creates and holds the beans, and is also the event publisher
builder.Services.AddScoped<T>()@Service or @Component on the classthe annotation is the registration
AddScoped<IPort, Adapter>()@Component on the adapter classSpring finds the one bean that implements the interface
AddSingleton(sp => new X())a @Bean methodfor a class that carries no annotation, or needs settings to build
TryAddSingleton<T>()@ConditionalOnMissingBeanonly 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 parameterSpring injects a proxy with no registration
IHostedService.StartAsyncSmartInitializingSingletonbefore the server and the listeners start
TimeProviderjava.time.Clockthe platform's bean is Clock.systemUTC()
ValidateOnBuilda @SpringBootTest that starts the contextthe wiring test also binds every setting

Starting the service: from main() to the first request#

Wiring, configuration and use cases · Chapter 10· 29 min read

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.

KindC#JavaNote
twistWebApplication.CreateBuilder(args), Build(), then Run()SpringApplication.run(OrderServiceApplication.class, args)one call does all three steps, and nine events mark its phases Why
twistbuilder.Configuration, read when Build() runsthe Environment, assembled before the context existsthe settings are complete before any bean is defined, and an EnvironmentPostProcessor can still add to them Why
newno equivalentApplicationContextInitializer, and the four early eventscode that runs before any bean definition exists, registered in spring.factories and never as a bean Why
twistbuilder.Services, a list of ServiceDescriptorthe bean definitions, written by ConfigurationClassPostProcessora recipe for each bean, with its class, its scope and its constructor, and no object yet Why
twistservices.AddShopPlatform(), which every app must callthe imports file, read by a DeferredImportSelectorauto-configuration is registered after the service's own definitions, so its conditions can see them Why
newno equivalentBeanFactoryPostProcessora hook that edits the recipes before the first object exists, and the step that took six of the sixteen seconds Why
trapa wiring error appears at the first resolve, unless ValidateOnBuild is onevery singleton is created at startup, dependency firsta bean that cannot be built stops the start, so a broken wiring never reaches a request Why
twistthe container calls the constructor, and then nothing morethe constructor, then the Aware callbacks, then the post-processors, which also run @PostConstructa fixed order of fourteen callbacks, and the proxy is made in the last one Why
newa decorator class that you registerthe auto-proxy creator, a BeanPostProcessorit wraps the bean in postProcessAfterInitialization, so the object other beans receive is the wrapper Why
twistIHostedService.StartAsync, before Kestrel listensSmartInitializingSingleton, then SmartLifecycle.start() in phase orderthe migrations run when every singleton exists, and Tomcat starts in a later phase Why
twistthe ApplicationStarted tokenApplicationRunner, then ApplicationReadyEventthe runners run after the server listens, and readiness changes to accepting traffic after them Why
twistSIGTERM, then StopAsync in reverse order, with 30 secondsthe shutdown hook closes the context: Lifecycle stop in reverse phase order, then @PreDestroythe same shape as .NET, but Spring gives each phase 30 seconds and .NET gives the whole stop 30 seconds Why
newno equivalent/actuator/startup, /actuator/conditions, /actuator/beans and /actuator/proxiesfour 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:

C#, Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddShopPlatform();
var app = builder.Build();
app.Run();
Java, OrderServiceApplication
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:

EventWhen Spring Boot publishes itWhat exists at that moment
ApplicationStartingEventat the start of run(), before any worknothing, apart from the listeners
ApplicationEnvironmentPreparedEventwhen the Environment is known, before the context is createdevery setting
ApplicationContextInitializedEventwhen the context exists and the initializers have run, before any bean definition is loadedan empty context
ApplicationPreparedEventjust before the refresh starts, after the bean definitions are loadedthe recipe of every bean, and no object
ApplicationStartedEventafter the refresh, before the runnersevery bean, and a listening server
AvailabilityChangeEvent, LivenessState.CORRECTright after ApplicationStartedEventthe liveness probe says live
ApplicationReadyEventafter the runnerseverything
AvailabilityChangeEvent, ReadinessState.ACCEPTING_TRAFFICright after ApplicationReadyEventthe readiness probe says ready
ApplicationFailedEventif any step throwsa 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:

StepTookWhat happened
spring.boot.application.starting194 msthe listeners were found and told
spring.boot.application.environment-prepared975 msthe settings were read, from every source
spring.boot.application.context-prepared5 msthe context was created and initialised
spring.boot.application.context-loaded13 msthe application class was registered as the first bean definition
spring.context.refresh14,152 msevery bean was defined, created and started
spring.boot.application.started227 msthe started event was published, after the Started line
spring.boot.application.ready2 msthe 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.

Environment 1.0 s Context 18 ms Definitions part of 6.5 s Post-processors the rest of 6.5 s 658 beans, one by one about 7 s Migrations, then Tomcat after every singleton, then in phases Started, runners 0.2 s Ready 2 ms The refresh, 14.2 s of 16.2 s: from Definitions to Tomcat. The first request came 0.3 s after Ready, the second 0.04 s.
The start of the order service as a timeline. The refresh, from the definitions to the start of Tomcat, took fourteen of the sixteen seconds. The first request took 0.3 seconds and the second 0.04 seconds.
Pragmatic choice: A trace switch in main()

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.yml and the profile files, are loaded by a class called ConfigDataEnvironmentPostProcessor.
  • That class is an EnvironmentPostProcessor, one among several that Spring Boot ships.
  • Every EnvironmentPostProcessor runs when Spring Boot publishes ApplicationEnvironmentPreparedEvent, before the context exists.
  • The ones that implement Ordered run in their order. The config data one has the order HIGHEST_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;
    }
}
Reading the code
implements EnvironmentPostProcessor, Ordered

The 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.yml to 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.PlatformDefaultsEnvironmentPostProcessor

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

Gotcha
A bean cannot listen for an event that was published before the bean existed. A @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 @SpringBootApplicationWhat ConfigurationClassPostProcessor does with it
@SpringBootConfigurationtreats the class as a @Configuration class, so its @Bean methods and its imports count
@ComponentScanscans the package of the class and every package below it for stereotypes, and writes a definition for each class found
@EnableAutoConfigurationimports 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:

KindWorks onRunsExamples in the order service
BeanFactoryPostProcessorthe definitions, before any bean existsonce, in this sceneConfigurationClassPostProcessor, and the one Spring Boot adds for spring.main.lazy-initialization
BeanPostProcessoreach bean object, as it is createdonce for every bean, in the next scenesthe 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();
    }
Reading the code
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.

@ConditionalOnProperty

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

Pragmatic choice: A diagnostic post-processor in the platform jar

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 createdTookWhat is inside the time
orderController1,030 msorderService and everything below it, then the controller itself
orderService1,007 msits nine dependencies, created first
stockAdapter927 msstockHttpClient, and the client's builder
stockHttpClient903 msthe HTTP interface proxy, and restClientBuilder
restClientBuilder489 msthe 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:

OrderCallbackWhat it is forIn the order service
1 to 10the Aware interfaces, such as BeanNameAware and ApplicationContextAwarethe bean learns its name, or gets the contextnot used
11BeanPostProcessor.postProcessBeforeInitializationa post-processor sees the populated bean, before its init methodsCommonAnnotationBeanPostProcessor calls the @PostConstruct method here, as in the leak experiment of the memory chapter
12InitializingBean.afterPropertiesSet()the bean's own setup, with every dependency presentnot used
13a custom init-methodthe same, named in the definitionnot used
14BeanPostProcessor.postProcessAfterInitializationa post-processor may replace the bean with a wrapperthe 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.

Gotcha
A proxied method called from @PostConstruct runs without its proxy. The @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;
}
Reading the code
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 advised

Every 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:

BeanProxyInterceptors on itWhat put them there
orderServiceclassTransactionInterceptor@Transactional on its methods
noteServiceclassTransactionInterceptor@Transactional on its methods
eventPublicationRegistry, mongoDbEventPublicationRepositoryclassTransactionInterceptor@Transactional inside Spring Modulith
orderController, noteController, referenceDataControllerclassDeferringMethodInterceptorthe method security of @OrdersRead and @OrdersWrite
orderMetrics, snsEventExternalizerclassAnnotationAsyncExecutionInterceptor, CompletionRegisteringMethodInterceptor, TransactionInterceptor@ApplicationModuleListener, which is @Async, @Transactional and a completion record in one
referenceItemRepositoryinterfaceCacheInterceptor@Cacheable on the repository, as the chapter on wiring explains
noteRepository, orderMongoRepositoryinterfacefour Spring Data interceptorsa Spring Data repository is a proxy of your interface, with SimpleMongoRepository behind it
stockHttpClientinterfaceHttpServiceMethodInterceptoran HTTP interface client, which is a proxy that sends requests
stockClientPropertiesclassMethodValidationInterceptor@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 started

The 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 ms

The 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:

ReportWhereHow to turn it onWhat it answers
the startup steps/actuator/startupthe trace switch in main(), and startup in management.endpoints.web.exposure.includewhich step, and which bean, took the time
the conditions/actuator/conditions, or the log with DEBUG=trueconditions in the exposure list, or the environment variablewhy an auto-configuration was or was not applied
the beans/actuator/beansbeans in the exposure listevery bean's type, scope and dependencies
the proxies/actuator/proxies, and one log lineplatform.diagnostics.proxies.enabled=true, and proxies in the exposure listwhich 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.

Check yourself
1
Spring Boot publishes ApplicationStartedEvent and then ApplicationReadyEvent. What runs between the two?
The runner beans. Spring Boot calls every 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.
2
A bean has a @PostConstruct method that calls the bean's own @Transactional method. Does the call run in a transaction?
No. The proxy that opens the transaction is created in the last callback of the bean's initialisation, after @PostConstruct has run. And a call through this never reaches the proxy anyway. Do the work in a SmartInitializingSingleton or an ApplicationRunner.
3
An expert asksWhy can a @Component never receive ApplicationEnvironmentPreparedEvent, and what receives it instead?
The event is published while the settings are assembled, before the application context exists. A component is created during the refresh, seconds later, so it cannot be a listener yet. A listener for the early events is given to SpringApplication.addListeners, or listed in META-INF/spring.factories under the ApplicationListener key.
4
TranslateTranslate 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();
A bean with a listener for the ready event. Spring Boot publishes it after the server listens and after the runners.
@Component
class Listening {

    @EventListener(ApplicationReadyEvent.class)
    void onReady() {
        System.out.println("listening");
    }
}

Configuration in depth#

Wiring, configuration and use cases · Chapter 11· 29 min read· Quick reference

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.

KindC#JavaNote
twistappsettings.json and appsettings.Development.jsonapplication.yml and application-local.ymlthe local profile file overrides the main file, and SPRING_PROFILES_ACTIVE switches it on Why
twistbuilder.Configuration["MONGO_URI"] read in Program.csuri: ${MONGO_URI} in application.ymla placeholder names an environment variable, and ${AWS_REGION:eu-west-2} adds a default Why
twistPlatform__Mongo__DocumentDb__CaBundlePLATFORM_MONGO_DOCUMENTDB_CA_BUNDLESpring maps an environment variable to a key with single underscores, by relaxed binding Why
twista connection string and JwtBearer options in codespring.mongodb.uri, issuer-uri and audiences in application.ymlBoot reads the settings of its starters from fixed keys, so the service writes no setup code Why
trapa setting that ASP.NET Core would reject or warn aboutspring.data.mongodb.uri on Boot 4Boot 4 ignores the old key with no warning, and connects to localhost:27017 Why
twistservices.Configure<LoggingOptions>(section) and IOptions<T>@ConfigurationProperties("platform.logging") on a classthe class itself is the bean, and @EnableConfigurationProperties registers it Why
newa 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
twistValidateDataAnnotations().ValidateOnStart()@Validated on the class and @NotNull on the fielda missing base-url stops the startup, with the field and the prefix in the error Why
twistnested option classes with initialisersstatic nested classes with defaults in field initialisersa key that is not set keeps the default from the code Why
trap"3" for a TimeSpan means 3 daysread-timeout: 3 means 3 millisecondsa 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 listmdc-path-variables: orderId, lineId, noteIdSpring splits a comma list and trims the spaces, but .NET binds nothing Why
twistconfiguration.GetValue<TimeSpan>("key", TimeSpan.FromMinutes(15))@Value("${platform.mongo.migrations.lock-ttl:PT15M}") Durationone value with a default, for a setting that has no properties class Why
newif (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
newSources.Insert(0, ...) in the libraryan EnvironmentPostProcessor that calls addLastthe platform's defaults have the lowest precedence, so every setting of the service wins Why
newno equivalentMETA-INF/platform-defaults/*.propertieseach package of the platform ships its defaults, and every key states a decision Why
trapSystem.Text.Json reads a missing bool as falseJackson 3 rejects a body that leaves out a primitivethe platform sets fail-on-null-for-primitives=false, so an omitted flag means false again Why
twistappsettings, then environment variables, then the command linethe property sources of Spring, from command line down to the platform defaultsthe source higher in the list wins, and the platform is always last Why
newdotnet user-secrets and a secrets storeenvironment variables from the secrets managerno file of the service holds a secret, and the local profile holds only fake values Why
newno equivalentspring-configuration-metadata.json from the configuration processorthe 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.

appsettings.Development.json
{
  "Logging": {
    "LogLevel": { "Acme": "Debug" }
  }
}
application-local.yml
logging:
  level:
    com.acme: DEBUG

Both 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:

FileWhen Boot reads itWhat it holds
src/main/resources/application.ymlalwaysevery key that every environment needs, with placeholders for the values that differ
src/main/resources/application-local.ymlwhen the profile local is activethe addresses of the local containers, and fake secrets
src/test/resources/application-wiring.ymlwhen a test activates the profile wiringvalues that connect to nothing, for the wiring test
src/test/resources/application.propertiesin every testone 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: local

With 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#

C#
var mongoUri = builder.Configuration["MONGO_URI"]
    ?? throw new InvalidOperationException("MONGO_URI");
var region = builder.Configuration["AWS_REGION"]
    ?? "eu-west-2";
Java 25
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}
Reading the code
uri: ${MONGO_URI}

${MONGO_URI} is a placeholder. Spring replaces it with the value of MONGO_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=primary

These 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 variableThe key it fillsWhat it is
MONGO_URIspring.mongodb.urithe DocumentDB connection string, with its user and password
PLATFORM_MONGO_DOCUMENTDB_CA_BUNDLEplatform.mongo.documentdb.ca-bundlethe path of the mounted AWS certificate bundle
OAUTH_ISSUER_URIspring.security.oauth2.resourceserver.jwt.issuer-urithe token issuer whose tokens the service accepts
OAUTH_TOKEN_URI...client.provider.shop-identity.token-uriwhere the service gets its own client-credentials token
STOCK_CLIENT_ID, STOCK_CLIENT_SECRET...client.registration.stock-service.client-id and client-secretthe service's own credentials for the stock service
STOCK_SERVICE_URLclients.stock.base-urlthe base URL of the stock service
PAYMENT_RECEIVED_QUEUEmessaging.queues.payment-receivedthe Amazon Simple Queue Service (SQS) queue, if it is not order-service-payment-received
AWS_REGIONspring.cloud.aws.region.staticthe 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#

C#
// ASP.NET Core: a double underscore separates the levels
// Platform__Mongo__DocumentDb__CaBundle=/certs/global-bundle.pem
Java 25
# 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:

StepFrom the key platform.mongo.documentdb.ca-bundle
Replace each dot with an underscoreplatform_mongo_documentdb_ca-bundle
Remove each dashplatform_mongo_documentdb_cabundle
Convert to upper casePLATFORM_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 variableBound to
CLIENTS_STOCK_BASEURLclients.stock.base-url
CLIENTS_STOCK_READTIMEOUT=250msclients.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.

Gotcha

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-service
Reading the code
issuer-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-service

A token must name this service in its aud claim. 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:

KeyWhat it does in the order serviceExplained in
spring.application.namethe name in every log line and tracedd-obs
spring.mongodb.urithe connection string of DocumentDB, from MONGO_URIdd-sto
spring.security.oauth2.resourceserver.jwt.issuer-urithe issuer whose tokens the service acceptsdd-sec-keys
spring.security.oauth2.resourceserver.jwt.audiencesthe audience a token must name, order-servicedd-sec-claims
spring.security.oauth2.client.registration.stock-service.*the service's own client-credentials login for the stock servicedd-sec-s2s
spring.security.oauth2.client.provider.shop-identity.token-uriwhere that token comes fromdd-sec-s2s
spring.cloud.aws.region.staticthe AWS region, and credentials come from the IAM roledd-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: local
Reading the code
endpoint: http://floci:4566

This 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: local

The 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:

Gotcha

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");
}
Reading the code
context.getBean(MongoConnectionDetails.class).getConnectionString()

MongoConnectionDetails is 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 keyBoot 4 keyWhat happens to the old key in Boot 4.1.2
spring.data.mongodb.uri, and the other driver keysspring.mongodb.urideprecated at level error, and the run showed that it is ignored
spring.data.mongodb.uuid-representationspring.mongodb.representation.uuiddeprecated at level error
management.metrics.mongo.*management.metrics.mongodb.*deprecated at level error
management.otlp.tracing.endpointmanagement.opentelemetry.tracing.export.otlp.endpointdeprecated at level error
server.error.include-message, include-stacktracespring.web.error.include-message, include-stacktracedeprecated at level error
spring.http.client.read-timeout, connect-timeoutspring.http.clients.read-timeout, connect-timeoutdeprecated 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:

SettingThe body of the 400 response
nonetimestamp, status, error and path, with no message
server.error.include-message=always, the Boot 3 keythe same, with no message
spring.web.error.include-message=always, the Boot 4 keythe 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=never

Both 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#

C#
public class PlatformLoggingOptions
{
    public bool Structured { get; set; } = true;
    public List<string> MdcPathVariables { get; set; } = [];
}

services.Configure<PlatformLoggingOptions>(
    configuration.GetSection("Platform:Logging"));
Java 25
/** 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;
    }
Reading the code
@ConfigurationProperties("platform.logging")

The class receives every key under platform.logging. The field mdcPathVariables takes platform.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:

ClassPrefixHolds
PlatformLoggingPropertiesplatform.loggingJSON logs on or off, and the path variables for the mapped diagnostic context (MDC)
PlatformSecurityPropertiesplatform.securitythe paths that need no token
ResiliencePropertiesplatform.resiliencethe retry and circuit breaker settings, in two nested classes
DocumentDbPropertiesplatform.mongo.documentdbthe path of the certificate bundle
MessagingPropertiesplatform.messagingthe 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:

FormWhat it gets rightWhat it costs
a record, bound through its constructorit cannot change after binding, and it is shorteach default needs the annotation @DefaultValue, and a record has no setters for a @Bean method to fill
a class with setters, as in the platformdefaults are plain field initialisers, and one class can be bound under several prefixesit 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-service

The 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();
}
Reading the code
@ConfigurationProperties("clients.stock")

Spring binds the keys under clients.stock into the returned object, by calling its setters. A second client, say clients.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>DefaultWhat it does
base-urlnone, and it is requiredthe root URL of the other service
connect-timeout2show long to wait for a connection
read-timeout5show long to wait for the response once connected
token-registration-idnonethe 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#

C#
public class StockOptions
{
    [Required] public Uri? BaseUrl { get; set; }
}

builder.Services.AddOptions<StockOptions>()
    .BindConfiguration("Clients:Stock")
    .ValidateDataAnnotations()
    .ValidateOnStart();
Java 25
@Validated
public class HttpClientProperties {

    /** The root URL of the other service, such as {@code https://stock.internal}. */
    @NotNull
    private URI baseUrl;
Reading the code
@Validated

@Validated, from org.springframework.validation.annotation, tells Boot to validate the object after it binds the settings. Validation runs at startup, when Spring creates the bean.

@NotNull

A constraint of Bean Validation, from jakarta.validation.constraints. A missing base-url leaves 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]#
Means
A validated properties class did not get a required value. The message names the bean, the prefix, the field and the constraint.
Fix
Set the key, here 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 missing

The 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#

C#
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);
    }
}
Java 25
@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;
Reading the code
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 calls getRetry() and then binds the keys under platform.resilience.retry into that object.

public static class Retry {

A static nested 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:

KeyDefaultWhat it does
platform.resilience.retry.max-attempts3the attempts in total, so up to 2 retries
platform.resilience.retry.initial-interval200msthe wait before the first retry
platform.resilience.retry.multiplier2.0how much each later wait grows
platform.resilience.circuit-breaker.failure-rate-threshold50the failure rate, in percent, that opens the breaker
platform.resilience.circuit-breaker.sliding-window-size20the number of recent calls the rate is computed over
platform.resilience.circuit-breaker.minimum-number-of-calls10the breaker computes no rate before this many calls
platform.resilience.circuit-breaker.wait-duration-in-open-state30show 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.

PredictA read timeout without a unit#
A developer changes read-timeout: 3s to read-timeout: 3, to mean three seconds. What read timeout does the stock client get?
clients:
  stock:
    base-url: ${STOCK_SERVICE_URL}
    read-timeout: 3s
Show the answer
3 milliseconds, which is far too short for an HTTP call.

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 settingsDuration
3sPT3S, 3 seconds
250msPT0.25S, 250 milliseconds
PT15MPT15M, the ISO-8601 form, 15 minutes
3PT0.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, noteId

The 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#

C#
var lockTtl = configuration.GetValue(
    "Platform:Mongo:Migrations:LockTtl",
    TimeSpan.FromMinutes(15));
Java 25
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);
}
Reading the code
@Value("${platform.mongo.migrations.lock-ttl:PT15M}") Duration lockTtl

@Value injects one setting into a parameter. After the colon is the default, 15 minutes in the ISO-8601 form. Spring converts the text to a Duration, 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,
Reading the code
@ConditionalOnProperty(name = "platform.mongo.migrations.enabled", havingValue = "true", matchIfMissing = true)

Spring creates the bean only when the property has the value true. matchIfMissing = true also creates it when the property is not set at all. So the runner is on by default, and a test turns it off with enabled: false.

A run for this book checked the three cases of this condition:

platform.messaging.enabledThe bean
not setpresent, because of matchIfMissing
falseabsent
TRUE, in upper casepresent, 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;
    }
}
Reading the code
implements EnvironmentPostProcessor, Ordered {

In Boot 4, the interface is org.springframework.boot.EnvironmentPostProcessor. The Boot 3 type org.springframework.boot.env.EnvironmentPostProcessor still exists, and is deprecated since 4.0.0. Ordered lets 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 Resource objects. A Resource is 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. addLast puts the file at the end, below every other source. PropertiesLoaderUtils.loadProperties reads the file into a Properties object.

if (environment.getProperty("platform.logging.structured", Boolean.class, true)) {

This condition reads the settings that exist already, with true as 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 MapPropertySource holds 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.yml has the order HIGHEST_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.PlatformDefaultsEnvironmentPostProcessor

The 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:

FIRST SOURCE WINS command-line arguments, system properties environment variables application-local.yml, if local is active application.yml platform-defaults:core.properties messaging.properties, mongo.properties structured-logging, the ECS switch Added last by the post-processor. Sorted by the file names. Every source above wins. the deployment sets these the files of the service
The property sources of the order service, first wins. Command-line arguments, system properties and environment variables come first, then application-local.yml and application.yml. The post-processor adds the platform defaults last, as core.properties, messaging.properties, mongo.properties and the structured-logging switch.

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");
}
Reading the code
environment.getPropertySources().addFirst(new MapPropertySource("application.yml",

The test replaces the service's file with a map source. It sets server.shutdown, which core.properties also 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=20s

The 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:

KeyPlatform valueBoot 4.1.2 defaultWhy
management.endpoint.health.probes.enabledtruetruerestated, so the probes stay on even if a Boot release changes the default
management.endpoints.web.exposure.includehealth,infohealthinfo is added, which the platform's security also leaves public
management.endpoint.health.show-detailsneverneverrestated, so a probe shows the status and no details
management.tracing.sampling.probability1.00.1every trace is kept, and a busy service lowers the rate
management.otlp.metrics.export.enabledfalsetruewith no collector, Boot would push metrics to localhost:4318 and log failures
server.shutdowngracefulgracefulrestated, so requests in flight finish when an instance stops
spring.lifecycle.timeout-per-shutdown-phase20s30sa real change, so each phase of the shutdown waits at most 20 seconds
spring.jackson.deserialization.fail-on-null-for-primitivesfalsetrue in Jackson 3an omitted boolean means false again, as the next section shows
spring.web.error.include-stacktrace and include-messageneverneverBoot's own error page shows no stack trace and no message, and Boot 4 ignores the old server.error names
logging.structured.json.customizerPersonalDataMaskingCustomizernoneJSON logs mask e-mail addresses and other personal data
springdoc.writer-with-order-by-keystruefalsethe generated OpenAPI file lists keys in a fixed order, so it changes only with the API
springdoc.default-produces-media-typeapplication/jsonnot checkedthe document says JSON instead of */*
springdoc.api-docs.enabled and swagger-ui.enabledtrue and falsenot checked, and falsethe 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,mongo

The excerpt shows two of the four keys. auto-index-creation=false leaves every index to the migrations. The table gives all four:

KeyPlatform valueDefaultWhy
spring.data.mongodb.auto-index-creationfalseoff in Spring Datarestated, because every index comes from a reviewed migration
management.endpoint.health.group.readiness.includereadinessState,mongoreadinessState onlyreadiness fails when the database is down, and liveness does not
spring.mongodb.representation.uuidstandardunspecifiedBoot 4 expects an explicit choice, and the outbox stores UUIDs
spring.data.mongodb.representation.big-decimaldecimal128unspecifiedmoney 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:

KeyPlatform valueDefaultWhy
spring.modulith.events.completion-modedeleteupdatea sent event is removed, so the collection stays small
spring.modulith.events.mongodb.transaction-management.enabledfalsetruethe platform's mongo package already declares the transaction manager
spring.modulith.events.republish-outstanding-events-on-restartfalsefalserestated, because the resender sends unsent events on a schedule
spring.modulith.events.externalization.enabledtruetruerestated, so the events reach SNS
spring.cloud.aws.sqs.queue-not-found-strategyfailcreate, in the codea missing queue stops the startup, instead of a new queue with no dead-letter queue
spring.modulith.events.staleness.resubmittedPT15Mnot set, so the monitor is offa 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.

Gotcha

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=false

spring.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:

PositionSourceIn the order service
1command-line arguments, such as --clients.stock.base-url=...the README uses them to run the service from an IDE
2Java system properties, set with -Dnot used
3OS environment variablesevery value that differs between environments
4application-{profile}.ymlapplication-local.yml, and application-wiring.yml in a test
5application.ymlevery key that every environment needs
6the platform defaults, added lastcore.properties, then messaging.properties, mongo.properties and the ECS switch
7the defaults in codethe 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:8089

Each 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.reserve
Reading the code
client-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:

SecretIn productionLocally
the database user and passwordinside MONGO_URI, from the secrets managerno password, the local MongoDB has none
the client secret for the stock serviceSTOCK_CLIENT_SECRET, from the secrets managerlocal, in application-local.yml, which the mock issuer accepts
AWS credentialsnone, the IAM role of the task or podthe 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.

Check yourself
1
An expert asksThe platform's post-processor adds its files with 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.
2
An expert asksWhy does the platform validate base-url with @Validated and @NotNull, instead of letting the first call fail?
The check runs when Spring creates the bean, at startup. A deployment that forgets the URL never starts, and it fails with an error that names the prefix and the field. Without the check, the service would start and pass its probes. It would then fail at the first order, far from the cause.
3
TranslateWrite 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();
/** 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:

PropertyDefaultWhat it does
platform.logging.structuredtrueJSON (ECS) console logs, and the local profile sets false
platform.logging.mdc-path-variablesnonepath variables copied into the MDC for each request
platform.security.public-paths/actuator/health/**, /actuator/infothe paths that need no token
platform.resilience.retry.max-attempts3calls in total, so up to 2 retries
platform.resilience.retry.initial-interval200msthe first wait, and later waits grow with random spread
platform.resilience.retry.multiplier2.0how much each wait grows
platform.resilience.circuit-breaker.failure-rate-threshold50the failure rate, in percent, that opens the breaker
platform.resilience.circuit-breaker.sliding-window-size20the calls the rate is computed over
platform.resilience.circuit-breaker.minimum-number-of-calls10no rate is computed before this many calls
platform.resilience.circuit-breaker.wait-duration-in-open-state30show long the breaker stays open
clients.<name>.base-urlnone, requiredthe root URL of one other service
clients.<name>.connect-timeout2show long one client waits for a connection
clients.<name>.read-timeout5show long one client waits for a response
clients.<name>.token-registration-idnonethe client registration whose token each call carries
platform.mongo.documentdb.ca-bundlenonethe AWS certificate bundle for TLS, and it is not set locally
platform.mongo.migrations.enabledtruerun the migrations at startup
platform.mongo.migrations.lock-ttlPT15Mhow long a migration lock lasts, longer than the slowest migration
platform.messaging.enabledtruethe outbox publisher, the inbox and the resender
platform.messaging.outbox.resend-enabledtruethe scheduled resender, and scheduling with it
platform.messaging.outbox.resend-intervalPT1Mhow often the resender runs
platform.messaging.outbox.resend-older-thanPT5Monly events older than this are resent
platform.messaging.outbox.lock-at-most-forPT5Mthe longest one instance holds the resender lock
platform.messaging.inbox.retentionP14Dhow long processed event IDs are kept, by a TTL index

Use cases: the application service#

Wiring, configuration and use cases · Chapter 12· 27 min read

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.

KindC#JavaNote
twistone handler class per command, as with MediatRone application service per feature, such as OrderServicereads and changes of a feature are on one class, until it grows past about ten use cases Why
newno equivalentload, rule and change, save, publish, auditevery use case that changes data has the same steps, so a reader finds each step in the same place Why
twistan action that takes a DTO and returns the entityparameters are value objects, results are IDs or viewsa caller cannot skip a rule, because no aggregate leaves the service Why
newno equivalentOrderView leaves out the customer's contacta view carries personal data only when a use case needs it Why
twistan interface in the application projectStockChecker in order/application/portcalling another service is a step of the use case, so its port belongs to the application layer Why
newno equivalentthe stock call before TransactionTemplate.executea slow HTTP call never holds a DocumentDB transaction open, and the order ID is the idempotency key Why
trapcontext.SaveChanges(), then keep using the same entityOrder saved = orders.save(order)the instance passed in keeps the old version, so the use case continues with the returned copy Why
newno equivalentthe order and its first notes in one transactionone request saves two aggregates, because the agent sees it as one action Why
newno equivalentNoteService.addNotes(orderId, List<NewNote>)features call each other only through the application service, with plain values Why
twista second service class in the same styleNoteService, with String IDs and a Spring Data repositorythe light style has the same steps, with fewer types Why
twista validation attribute that reads a lookup tableReferenceDataService.requireActive(type, code, field)the rule "a code must be an active lookup value" is written once, and every feature asks it Why
newno equivalentif (order.recordPayment(payment))the only if in a use case decides the next step, and never a business rule Why
twistan action per verb on the same servicepickLine, cancel, get and listthe short use cases have the same steps, and the reads return views Why
newno equivalentthe journeys of a request, a message and an eventeach step of a journey has one home, so a bug is found by its step Why
newno equivalentOrderFulfilmentService beside OrderServicea large service is split by area of the business, not into a read side and a write side Why
twista unit test of the service with MoqOrderServiceTest with Mockito and a real TransactionTemplatethe 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:

FeatureApplication serviceIts public methods
orderOrderServiceplace, pickLine, ship, cancel, recordPayment, get, list, requireExists
noteNoteServiceaddNote, addNotes, resolveAction, list, countNeedingAction
referenceReferenceDataServicelist, 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:

StyleWhat it gets rightWhat it costs
one service per feature, as herea reader finds every use case of a feature in one file, and shared steps stay privatethe class grows with the feature, and must be split by hand
one class per use case, with a command recordeach class is small, and a pipeline can wrap every command the same waymany 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.

1. Load orders.get(id) 2. Ask other features 3. Rule and change 4. Save orders.save 5. Publish the events 6. Audit audit.record Steps 1 to 6 run in one transaction. Only step 3 holds business rules, in the domain. A call to another service runs before the transaction, as in place.
A use case loads the aggregate and asks other features. The domain applies its rules and changes itself. The use case then saves, publishes the events and records the audit. The six steps run in one transaction, and only the third holds business rules. A call to another service runs before the transaction.

The use case ship shows every step:

C#
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);
}
Java 25
/** 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
}
Reading the code
@Transactional

The 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); // load

get is a default method of the repository port. It throws OrderNotFoundException when 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 feature

The 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 aggregates

A 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 change

The aggregate checks its own rules, paid and every line picked, and changes its status. It raises the event OrderShipped and keeps it until it is pulled.

saveAndPublish(order, AuditActions.ORDER_SHIPPED); // save, publish, audit

One 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#

C#
public PlacedOrder Place(CustomerContact customer,
    DeliveryMethod deliveryMethod, List<OrderItem> items,
    List<NewNote> firstNotes)
{
    throw new NotImplementedException();
}
Java 25
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);
    }
}
Reading the code
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 caseReturnsWhy
placePlacedOrder, with the IDsthe controller needs the ID for the Location header
ship, cancel, pickLine, recordPaymentnothingthe caller knows the ID already, and reads again if it needs more
get, listOrderView, or a PageResult of themthe data of the order, with no method that changes it
NoteService.addNotethe note's ID, as a Stringthe 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()));
    }
}
Reading the code
public record OrderView(String orderId, String status, String deliveryMethod, Instant placedAt, Instant shippedAt,

Every component is plain data, such as a String or an Instant. 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.Line is the view of one line, and OrderView.LastPayment the 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#

C#
// in the application project
public interface IStockChecker
{
    StockAvailability Reserve(
        OrderId order, List<OrderItem> items);
}
Java 25
/**
 * 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:

PortPackageWhy there
OrderRepositoryorder/domain/repositorythe domain says what it needs to load and save an aggregate
StockCheckerorder/application/portcalling 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);
    });
}
Reading the code
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 transaction

This is the call to the other service. No transaction is open yet.

return transaction.execute(status -> {

The field transaction is a TransactionTemplate. Only the lambda is transactional. execute returns what the lambda returns, here the PlacedOrder. The parameter status can 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:

TestWhat it checks
OrderServiceTest.Place.checksTheDeliveryMethodBeforeCallingTheStockServicean inactive delivery method throws, and the stock port is never called
OrderServiceTest.Place.savesNothingWhenTheStockIsNotAvailablea 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;
}
Reading the code
List<DomainEvent> raised = order.pullEvents();

The events are taken from the aggregate first. The copy that save returns 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. place continues with it, as in saved.id().value().

Gotcha

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);
}
Reading the code
"notes": [ { "text": "Called the customer", "category": "PHONE_CALL", "actionRequired": false } ] }

The category PHONE_CALL is 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.

Pragmatic choice: One transaction for an order and its first notes

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:

DesignWhat it gets rightWhy the service does not use it
an event from the order adds the notesthe strict rule holds, and the order commits alonea note that fails validation fails after the order exists, and the agent does not see the error
two requests from the user interfaceeach request changes one aggregatethe 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 orderone document, so one write and no transactionthe 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:

InteractionCaller and calleeWhat crossesTransaction
place an order with its first notesOrderService.place calls NoteService.addNotesthe order ID as text, and a list of NewNotethe caller's, joined
ship an orderOrderService.ship calls NoteService.countNeedingActionthe order ID as text, and a long backthe caller's
add a noteNoteService.addNote calls OrderService.requireExiststhe order ID as textthe note's
list the notes of an orderNoteService.list calls OrderService.requireExiststhe order ID as textnone, a read

The rules for such calls follow from the table:

RuleWhy
call the other feature's application service, never its repositorythe owner checks its own rules, and its data is stored as it decides
pass plain values, such as a String ID or a NewNoteno type of one feature's domain enters the other feature
never pass or return an aggregatethe caller could change it and skip the owner's steps
let the owner check its own IDsa 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);
    }
}
Reading the code
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#

C#
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);
}
Java 25
@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);
}
Reading the code
notes.findById(noteId).orElseThrow(() -> new NoteNotFoundException(noteId));

The light style injects the Spring Data repository directly. findById returns an Optional, and orElseThrow throws 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:

MethodWho calls itWhat it does
addNote(orderId, newNote)the note controllerchecks that the order exists, then adds one note, in its own transaction
addNotes(orderId, newNotes)OrderService.placeadds the first notes, in the caller's transaction
list(orderId, page)the note controllera page of NoteView, newest first, and 404 for an unknown order
countNeedingAction(orderId)OrderService.shipa 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#

C#
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");
}
Java 25
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);
    }
}
Reading the code
if (order.recordPayment(payment)) {

recordPayment returns true when it recorded the payment. It returns false for 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:

DecisionWhere it belongsIn the order service
save only when the domain recorded the paymentthe use casethe if in OrderService.recordPayment
list every order when no status is giventhe use casethe choice of query in OrderService.list
ignore a payment that is not newer than the last onethe domainthe first if in Order.recordPayment
refuse to ship an order that is not paidthe domainOrder.ship, which throws OrderNotPaidException
refuse an order when a product is missingthe domainOrder.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#

C#
public async Task PickLine(OrderId orderId, LineId lineId)
{
    var order = await orders.Get(orderId);
    order.PickLine(lineId, clock);
    await SaveAndPublish(order, AuditActions.LinePicked);
}
Java 25
@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);
}
Reading the code
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);

PageResult is the platform's page type, in plain Java. map converts 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:

1. Filter chain: the token is checked. 2. Interceptor: path IDs into the MDC. 3. Spring MVC: the body is read and validated. 4. Method security: @OrdersWrite. 5. Controller: value objects are built. 6. OrderService: lookup check, stock call. 7. Transaction: order, events, audit, notes. commit, or roll back everything. 8. After the commit: OrderPlacedV1 to SNS. Answers 401. Answers 400. Answers 403. Answers 400. Answers 400, 422 or 503. Answers 409 or 400, and stores nothing. Resent by the outbox on failure.
The journey of POST /orders passes the filter chain, the MDC interceptor and Spring MVC with validation. Then it passes method security, the controller and the use case with the stock call. One transaction stores the order, its events, its audit record and its notes. After the commit, the event goes to SNS.

Each step, with the chapter that explains it:

StepWhat happensExplained in
1the bearer token is checked, or the request gets 401dd-sec-path
2the interceptor copies orderId and the other path IDs into the MDCdd-obs
3Jackson reads the body, and @Valid checks itdd-web
4@OrdersWrite checks the scope orders.writedd-sec-method
5the controller builds CustomerContact, DeliveryMethod and the itemsdd-web
6requireActive, then the stock call outside the transactiondd-uc-place
7the order, its outbox rows, its audit record and its notes, in one transactiondd-uc-two
8Spring Modulith sends OrderPlacedV1 to SNS, on another threaddd-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:

JourneyIts stepsExplained in
ship an orderthe same as place up to step 5, then load, ask the notes, check the policy, ship and save, with 409 when another agent saved firstdd-uc-steps
a payment messageSQS delivers it, the inbox records its ID, recordPayment runs as the system actor, in the inbox's transactiondd-inb
an event outthe use case publishes, the outbox stores a row in the transaction, Spring Modulith sends it after the commitdd-out
the stock callthe port, the adapter, the client with its token, retries, the circuit breaker, a 503 when the stock service is downdd-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:

SplitWhat it gets rightWhat it costs
by area, as recommendedeach class is a part of the business that a team member can namethe shared helper must move to a small class that both use
into a read side and a write sidereads can change faster, and use other storage laterthe two halves of one feature change together, and the reads still need the same views
one class per use caseeach class is tinythe 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#

C#
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);
Java 25
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);
Reading the code
private final StockChecker stock = mock(StockChecker.class);

mock is 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 place runs, 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 notes
Reading the code
InOrder order = inOrder(referenceData, stock, orders, events, audit, notes);

InOrder checks 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 first

An ArgumentCaptor keeps 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 testedTestNeeds
the order of the steps, the event, the audit, a refusalOrderServiceTest and NoteServiceTest, with mocksnothing, it runs in milliseconds
the HTTP status, the scope, the JSONOrderControllerTest and NoteControllerTest, with the service mockeda slice of Spring, no database
the order and its notes in one transaction, the rollbackOrderJourneyITMongoDB and the AWS emulator in Docker
the stock call, its token, its retriesStockAdapterTesta 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.

Check yourself
1
An expert asksWhy does place call the stock service before it starts the transaction, and what does that order cost?
A DocumentDB transaction has a limit of one minute, and a slow HTTP call inside it would hold it open. So the call runs first, and only the lambda of the template is transactional. The cost is a hold on the stock with no order, when the transaction fails afterwards. The stock service releases the hold after its timeout. The order ID is sent as the idempotency key, so a retried call is safe.
2
An expert asksAn application service could return the aggregate, which saves a view type. Why does the order service return OrderView instead?
A caller with an 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.
3
OrderService.recordPayment contains an if. Why is it not a business rule in the wrong layer?
The aggregate decides whether to record the payment, and returns 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.
4
The note feature needs to know whether an order exists. Which call does it make, and what crosses between the features?
It calls 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.
5
TranslateA 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);
}
@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 and security · Chapter 13· 37 min read· Quick reference

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.

KindC#JavaNote
newmiddleware, routing, then filters around the actionservlet filters, the DispatcherServlet, interceptors, argument resolution, the controller method and the message converterseach part can stop the request, and the part that stops it decides who writes the answer Why
twistJwtBearer's challenge writes the 401the security filter chain writes the 401, before Spring MVC runsthe platform's advice never sees a 401, so the security chain writes that problem itself Why
trapan exception in afterCompletion means the request failedafterCompletion gets ex = null when an advice handled the exceptionread response.getStatus() instead, because a handled exception never reaches afterCompletion Why
twistan [ApiController] class that calls a servicea @RestController class with no rule, no repository and no try/catchthe 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
twista request class with [Required] and [MaxLength]a record with @NotBlank, @Size, and @Valid on each nested record and list elementthe 400 lists every bad field, and the domain checks the values again Why
twistthe action returns a DTO, never an entitythe controller returns a response record, never a domain classthe JSON is a contract with other teams, so the domain can change without changing it Why
twista hand-written mapping methoda package-private @Component mapper for each featureplain Java code, so a new record component is a compile error in the mapper Why
twistCreated(uri, body)ResponseEntity.created(URI.create(...)).body(...)a 201 with a Location header and a body, built in one expression Why
twistNoContent()ResponseEntity.noContent().build()a 204 for a command that returns nothing, with ResponseEntity<Void> as the return type Why
newa paging class of your ownPageQuery(page, size) in, PageResponse<T> outone page shape for every service, and a size over 100 is a 400 that names the field size Why
trapan omitted bool binds to falsean omitted boolean fails the request with 400 in Jackson 3Boot 4 rejects the body unless fail-on-null-for-primitives is false, which the platform sets Why
sameunknown JSON properties are ignoredunknown JSON properties are ignoredSystem.Text.Json and Jackson 3 both skip a property that the record does not have
twista UTC DateTimeOffset is written as 2026-01-15T09:00:00+00:00an Instant is written as 2026-01-15T09:00:00Zan ISO-8601 string with no extra module, so a test can compare it as text Why
newAddProblemDetails() and an IExceptionHandlera @RestControllerAdvice that extends ResponseEntityExceptionHandlerone platform class answers every exception, and the kind of the exception decides the status Why
twista feature writes its own handlera feature extends NotFoundException, InvalidValueException or BusinessRuleViolationExceptionthe base class decides the status, so a feature never writes a handler Why
trapAddProblemDetails() puts a traceId into the problems it writesa traceId appears only where your own code adds itoverride handleExceptionInternal, or inherited cases such as 405 and 415 have none Why
twistStatusCodes.Status422UnprocessableEntityHttpStatus.UNPROCESSABLE_CONTENTSpring 7 uses the new name for 422, and UNPROCESSABLE_ENTITY is deprecated Why
twistConflict() for a rule that the current state breaks409 only for a concurrent update, and 422 for every broken ruleeach status asks the client for one reaction, so a client can act on the status alone Why
trapseveral IExceptionHandler classes, tried in the order they are registeredseveral advices, and Spring picks the advice before the methodwithout @Order, the class names decide which advice answers, so the catch-all needs the lowest precedence Why
newa handler for DbUpdateConcurrencyExceptionOptimisticLockingExceptionHandler in the platform's mongo packagethe 409 handler is next to the technology that throws, and it also catches a write conflict Why
twistUseExceptionHandler() with AddProblemDetails()spring.web.error.include-stacktrace and include-message, both neverBoot's own error response holds no stack trace and no message, and the old server.error keys are deprecated Why
newno equivalentmessages 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
newa middleware with ILogger.BeginScopea HandlerInterceptor, added by a WebMvcConfigurer beana library adds the interceptor with a bean of its own, and the interceptor then runs around every controller method Why
twistcontext.Request.RouteValues["orderId"]request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE)a map of the path variables, or null when no controller method matched Why
twistif (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:

BEFORE SPRING MVC Servlet filters Spring Security checks the token no valid token: 401 from the chain IN SPRING MVC DispatcherServlet finds the method no path: 404. A wrong verb: 405, and no interceptor runs Interceptors: preHandle Argument resolution reads the JSON, runs @Valid, converts parameters The controller method after the permission check, it calls the use case The result is written as JSON, then postHandle Interceptors: afterCompletion, always Servlet filters again: they see the final status The advice GlobalExceptionHandler and the 409 handler write a problem, and postHandle is skipped
The request path in Spring MVC. Servlet filters run first and last. The DispatcherServlet finds the controller method, the interceptors run preHandle, argument resolution reads and validates the JSON, and the controller method runs. An exception after the filters goes to the advice, which writes the problem. postHandle runs only on success, and afterCompletion always runs.

Each part has one job in the order service:

PartWhat it does hereWhat it answers when it stops the request
servlet filtersSpring Security checks the token401, written by the security chain
DispatcherServletfinds the controller method for the path and the HTTP method404 or 405, written by the advice
interceptorsthe platform's MDC interceptor copies orderId, lineId and noteId into the MDCnothing, it only adds log context
argument resolutionreads the JSON body, runs @Valid, and converts path and query values400, written by the advice
the controller methodbuilds value objects and calls one use case400, 403, 404, 409, 422 or 503, written by the advice
the message converterwrites the returned record as JSON, or the problem as application/problem+jsonnothing, 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.

PredictAn interceptor after a business rule fails#
The ship use case throws OrderNotPaidException. The platform's advice turns it into a 422 problem. Which value of ex does this afterCompletion receive?
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
                            Exception ex) {
    names.forEach(MDC::remove);
}
Show the answer
null

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 requestpreHandleThe controllerpostHandleafterCompletion
a normal requestruns, with the path variablesrunsrunsruns, ex is null
the use case throws, and the advice answers 422runsrunsdoes not runruns, ex is null
the method throws, and no advice handles itrunsrunsdoes not runruns, ex is the exception
a body that fails @Validrunsdoes not rundoes not runruns, ex is null
DELETE on a path that has only GET, a 405does not rundoes not rundoes not rundoes not run
a path with no controller, a 404runs, with no path variablesdoes not rundoes not runnot 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;
    }
Reading the code
@RestController

Spring 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.api can 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();
}
Reading the code
@OrdersWrite

The caller needs the orders.write scope. For permissions, see the chapter Security in depth.

@PostMapping("/{orderId}/ship")

Shipping is a POST to 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:

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#

C#
[HttpGet("{orderId}")]
public OrderResponse Get([FromRoute] Guid orderId) =>
    mapper.ToResponse(orders.Get(new OrderId(orderId)));
Java 25
@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 to UUID would 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) {
    }
    // ...
}
Reading the code
@NotNull @Valid CustomerRequest customer,

@NotNull requires the customer object. @Valid tells 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. @Valid on 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 is null, and the mapper turns it into an empty list.

@Schema(example = "MUG-001")

@Schema adds 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());
    }
    // ...
}
Reading the code
@Component

The 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.api uses it.

new OrderItem(new ProductCode(line.productCode()), line.quantity())

The mapper builds domain value objects from the request. ProductCode checks 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#

C#
[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));
}
Java 25
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));
}
Reading the code
ResponseEntity.created(URI.create("/orders/" + orderId + "/notes/" + noteId))

created sets the status 201 and the Location header. It takes a java.net.URI, and URI.create builds one from the text.

.body(new NoteAddedResponse(noteId));

body finishes 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);
}
Reading the code
@RequestParam(required = false) OrderStatus status,

The status filter is optional, so a missing value is null. Spring converts the text to the enum OrderStatus. An unknown value such as LOST is a 400 that names the field status.

@RequestParam(defaultValue = "0") int page,

Without the default, a missing page would 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.from maps each view to its response record, and keeps the numbers of the page. mapper::toResponse is 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);
        }
    }
Reading the code
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());
    }
}
Reading the code
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 items to totalPages.

Function<? super S, ? extends T> mapper

Function is like Func<S, T> in C#. The question marks are wildcards. They let the method accept a mapper written for a supertype of S.

result.items().stream().<T>map(mapper).toList(),

<T>map names the type argument of map explicitly, so the result is a List<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:

PredictA request body that leaves out a boolean#
Spring Boot 4.1 reads this body with its default Jackson settings. The body leaves out actionRequired. What does the client get?
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
400 Bad Request. Jackson cannot read the body, and the note is not added.

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=false

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

Gotcha

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 have FAIL_ON_UNKNOWN_PROPERTIES off. A plain Jackson 2 mapper had it on.
  • An Instant is an ISO-8601 string. Jackson 3 writes "2026-01-15T09:00:00Z" with no extra module. Jackson 2 needed a module for java.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 exceptionStatusWhat the problem adds
InvalidValueException, a failed @Valid, unreadable JSON, a value of the wrong type400errors, a list of the fields with a message each
AccessDeniedException, from method security403nothing, the detail is "You are not allowed to do this"
NotFoundException404code, such as ORDER_NOT_FOUND
OptimisticLockingFailureException, or a write conflict409code CONCURRENT_UPDATE
BusinessRuleViolationException422code, such as ORDER_NOT_PAID
DownstreamServiceException503nothing, and the log names the service that failed
anything else500nothing, 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));
    }
Reading the code
@RestControllerAdvice

The 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 NotFoundException and every subclass of it, such as OrderNotFoundException.

problem.setProperty("errors", List.of(fieldError(ex.field(), ex.getMessage())));

setProperty adds a member to the problem. Spring writes it at the top level of the JSON, next to title and status.

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."));
}
Reading the code
"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);
}
Reading the code
protected @Nullable ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,

This method of the base class handles a failed @Valid. The platform overrides it. @Nullable comes from JSpecify, and says that the method may return null. 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:

C#
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 });
    }
}
Java 25
@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;
}
Reading the code
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.

Gotcha

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;
}
Reading the code
ResponseEntity<Object> response = super.handleExceptionInternal(ex, body, headers, statusCode, request);

The base class builds the response first. For most exceptions, the body argument is null, and super creates the problem. So the method adds the ID to the result of super, not to the argument.

response.getBody() instanceof ProblemDetail problem

A type pattern. It checks that the body is a ProblemDetail, and names it problem in one step.

ProblemDetails.withTraceId(problem);

The platform's helper adds the traceId member.

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#

C#
context.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
Java 25
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:

StatusWhat happenedWhat the client does
409 CONCURRENT_UPDATEsomeone else changed the same order at the same momentreload the order, and try the same action again
422 with a code, such as ORDER_NOT_PAIDthe order's rules refuse the actionshow 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.

Gotcha

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 @OrderWho answered a ConflictException
AaaCatchAll and ZzzSpecificthe catch-all, with 500
AaaSpecific and ZzzCatchAllthe 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);
}
Reading the code
@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=never

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

Gotcha

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);
    }
}
Reading the code
public class PathVariableMdcInterceptor implements HandlerInterceptor {

HandlerInterceptor has 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 be null. The pattern is false for null, so the code needs no separate null check. The type arguments of a map are not known at run time, so the pattern writes Map<?, ?>, 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;

true lets the request continue. false would stop it here.

names.forEach(MDC::remove);

afterCompletion runs 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);
        }
    };
}
Reading the code
WebMvcConfigurer platformMdcWebMvcConfigurer(PlatformLoggingProperties properties) {

Spring MVC reads its settings from the WebMvcConfigurer beans. 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.

Check yourself
1
An expert asksThe order service takes @PathVariable String orderId, and calls OrderId.of(orderId) inside the method. Why not declare the parameter as a UUID?
Three reasons. The permission check then runs before the conversion, so a read-only caller who sends a bad ID gets 403, not 400. The domain's 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.
2
An expert asksWhy does GlobalExceptionHandler override handleExceptionInternal, when every one of its own handlers already adds the traceId?
The class also inherits handlers for 20 exception types of Spring MVC, such as a 405, a 415 and a bad enum in the query. Those handlers build their own problems, without the traceId. Every one of them ends in handleExceptionInternal. So one override adds the traceId to all of them.
3
The 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?
Spring asks the advices in order, and the first advice with any matching handler answers. Without @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.
4
TranslateWrite 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));
}
@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 pathSuccessErrors the contract lists
POST /orders201, with a Location400, 401, 403, 422 PRODUCTS_UNAVAILABLE, 503
GET /orders?status=&page=&size=200, one page400, 401, 403
GET /orders/{orderId}200400, 401, 403, 404
POST /orders/{orderId}/lines/{lineId}/pick204400, 401, 403, 404, 409, 422
POST /orders/{orderId}/ship204400, 401, 403, 404, 409, 422
POST /orders/{orderId}/cancel204400, 401, 403, 404, 409, 422
POST /orders/{orderId}/notes201, with a Location400, 401, 403, 404
GET /orders/{orderId}/notes200, one page400, 401, 403, 404
POST /orders/{orderId}/notes/{noteId}/resolve204401, 403, 404, 409, 422
GET /reference-data/{type}200401, 403, 404

The members of a problem of the shop:

MemberWhen it appearsWhat it holds
statusalwaysthe HTTP status, as a number
titlealwaysa short text, such as Invalid request or Business rule violated
detailalwaysthe explanation, with IDs and never personal data
instancealways, because Spring MVC adds itthe path of the request
traceIdwhenever the MDC holds a trace ID, which tracing sets for each requestthe ID that finds every log line of the request
code404, 409 and 422a stable code, such as ORDER_NOT_PAID or CONCURRENT_UPDATE
errors400a list of fields, each with field and message

The controller tests that check each status:

StatusTest
400, every invalid fieldOrderControllerTest.invalidFieldsAreAllListed
400, a bad ID in the pathOrderControllerTest.anInvalidIdInThePathIsA400ThatNamesTheField
400, a page size over 100OrderControllerTest.thePageSizeIsLimited
401OrderControllerTest.Security.aRequestWithoutATokenIsA401ProblemBeforeAnyController
403OrderControllerTest.Security.aCallerWithoutTheWriteScopeIsA403AndNothingRuns
404OrderControllerTest.aMissingOrderIsA404WithItsCode
409, a stale versionOrderControllerTest.aStaleVersionIsA409
409, a write conflictOrderControllerTest.aWriteConflictInsideATransactionIsAlsoA409
422OrderControllerTest.aBrokenBusinessRuleIsA422WithItsCode
503OrderControllerTest.anUnavailableDependencyIsA503
500, and no exception message in the bodyOrderControllerTest.anUnexpectedErrorIsA500WithoutItsMessage
a traceId on Spring MVC's problemsthe seven tests of OrderControllerTest.TraceId

The settings of the web layer:

SettingValue in the platformWhat it does
spring.jackson.deserialization.fail-on-null-for-primitivesfalsea missing boolean or int gets its default value, instead of a 400
spring.web.error.include-stacktracenever, the default restatedBoot's own error response holds no stack trace, and the old server.error key is ignored
spring.web.error.include-messagenever, the default restatedBoot's own error response holds no exception message, and the old server.error key is ignored
platform.logging.mdc-path-variablesset by each service, here orderId, lineId, noteIdthe path variables that the interceptor copies into the MDC
spring.mvc.problemdetails.enablednot set, so falseBoot'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:

GroupException types
the request does not matchHttpRequestMethodNotSupportedException, HttpMediaTypeNotSupportedException, HttpMediaTypeNotAcceptableException, NoHandlerFoundException, NoResourceFoundException
a value is missing or wrongMissingPathVariableException, MissingServletRequestParameterException, MissingServletRequestPartException, ServletRequestBindingException, TypeMismatchException, ConversionNotSupportedException
the body is wrongHttpMessageNotReadableException, MethodArgumentNotValidException, HandlerMethodValidationException, MethodValidationException, MaxUploadSizeExceededException
otherHttpMessageNotWritableException, AsyncRequestTimeoutException, AsyncRequestNotUsableException, ErrorResponseException

API and event contracts#

The web layer and security · Chapter 14· 34 min read· Quick reference

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.

KindC#JavaNote
newan OpenAPI file that a team keeps beside the codecontracts/openapi.yaml, generated from the code and committedthe file is a contract with the UI and other teams, so it must match what the code does Why
trapa hand-written openapi.yaml that a review keeps in stepthe same file, which nothing checksnothing fails when the code and the file differ, so the file drifts with every change Why
twistAddOpenApi() and MapOpenApi()the dependency springdoc-openapi-starter-webmvc-ui, version 3.2.0springdoc 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 @Parameterthe annotations add what the code cannot say, such as summaries, examples and error responses Why
twistoptions.AddDocumentTransformer(...)an OpenApiCustomizer bean in the platform libraryit adds the security scheme, the Problem schema and the shared error responses to every service Why
trapTypedResults.Created(...) is documented as 201ResponseEntity.created(...) is documented as 200springdoc cannot see the status inside the method, so a 201 needs @ApiResponse(responseCode = "201") Why
twistOpenApiGenerateDocumentsOnBuildOpenApiContractTest, which reads /v3/api-docs.yaml through MockMvcthe build writes the file with no port, fixes the server URL, and fails when the committed file differs Why
twistthe aspnetcore generator of openapi-generatoropenapi-generator-maven-plugin 7.26.0the plugin generates interfaces and models in every Maven build, so a changed signature fails the compile Why
newno equivalentthe option useRecords, which version 7.26.0 does not havethe generator's web page lists it, but 7.26.0 still writes classes with getters and setters Why
newno equivalentcode first for an internal API, contract first for an API that others sign off firstthe recommendation follows who must agree the API, and when Why
twistMicrosoft.AspNetCore.OpenApi, with OpenAPI 3.1springdoc, with OpenAPI 3.1both generate the document from the code, but only springdoc brings Swagger UI in the same dependency Why
newa schema for each message, if the team writes onecontracts/events/OrderPlacedV1.json, beside the record OrderPlacedV1the schema is the contract for other teams, and a test checks each published event against it Why
twista version in the type name, such as OrderShippedV1OrderShippedV1, a recordwithin V1, fields are only added, and a breaking change is published as V2 beside V1 Why
twistGuid.NewGuid() as the message IDIntegrationEvent.newEventId(), called once when the event is createda resend keeps the ID, so the consumer's inbox detects the duplicate Why
twistJsonSerializer ignores an unknown property by default@JsonIgnoreProperties(ignoreUnknown = true) on the consumer's recordJackson 3 ignores them by default too, and the annotation keeps that true whatever the mapper's settings Why
twist[JsonRequired] on each propertya compact constructor that refuses a missing fielda wrong subscription then fails loudly, and the message is not lost Why
newa shared NuGet package of message classesour own copy of a consumed event, a record and a schema with only the fields we useeach team owns its classes, and a contract change is checked by reading one file Why
newno equivalentan IntegrationEventMapper that returns Optional.empty() for internal eventsevery 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:

ContractFormatWho builds against it
the HTTP APIan OpenAPI 3.1 document, contracts/openapi.yamlthe web application, QA, and any other caller
each published eventa JSON Schema file, such as contracts/events/OrderPlacedV1.jsoneach 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 enum of allowed values, but the code accepts any value, and answers 200 [] for an unknown one.
Gotcha

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:

FixWhere the truth isWhat fails the build
code firstthe controllers and DTOs, and the file is generated from thema test, when the committed file differs from the generated one
contract firstthe file, and code is generated from itthe 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:

PathWhat it returns
/v3/api-docsthe document as JSON, starting with "openapi":"3.1.0"
/v3/api-docs.yamlthe same document as YAML
/swagger-ui.htmla redirect to /swagger-ui/index.html
/swagger-ui/index.htmlSwagger UI, a page to read and call the API in a browser

The run also showed what springdoc reads from the code:

In the codeIn the document
@NotBlank on a componentthe 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 Instanttype string, with format date-time
a primitive int with no constraintnot 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:
  - productCode

productCode 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=false
Reading the code
springdoc.writer-with-order-by-keys=true

The 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/json

Without it, every response would say */*. The committed contract says application/json.

springdoc.api-docs.enabled=true

The document stays on in every environment. It is not public, because the security chain asks for a token on /v3/api-docs too.

springdoc.swagger-ui.enabled=false

Swagger 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:

C#
[HttpPost("{orderId}/ship")]
[EndpointSummary("Ship an order")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public IActionResult Ship(string orderId) => NoContent();
Java 25
@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)))
Reading the code
@Operation(operationId = "shipOrder", summary = "Ship an order",

operationId names the operation. A client generator uses it as the method name, so the service sets it, and never changes it. summary is the one-line text, and description the 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)

ref points to a shared response, defined once for every service. PlatformOpenApi.NOT_FOUND is 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:

AnnotationWhereWhat it adds
@Tag(name = "orders")on the controller classgroups the operations, by feature
@Operation(operationId, summary, description)on each methodthe operation's name and its texts
@ApiResponse(responseCode, description or ref, content)on each method, once for each statusthe responses, including the errors
@Schema(description, example, allowableValues)on a record componentthe texts, an example, and the allowed values of a String
@Parameter(schema = @Schema(allowableValues = ...))on a method parameterthe 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"));
    }
}
Reading the code
@Configuration(proxyBeanMethods = false)

A configuration class whose @Bean methods do not call each other. For what proxyBeanMethods changes, see the chapter Wiring the service: the container in depth.

OpenAPI orderServiceOpenApi() {

springdoc starts from this OpenAPI object, 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));
            }));
        }
    };
}
Reading the code
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, traceId and errors.

components.addResponses(name, problemResponse(description)));

The shared responses, such as NotFound and ConcurrentUpdate. A controller names them with ref.

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: string

The 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. PageResponseOrderResponse is one of them. Each such schema repeats the five members of PageResponse. 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. shippedAt has no value until the order ships. The document writes it as type: string, and leaves it out of required. 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 accepted null for 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.

PredictA method that returns ResponseEntity.created#
springdoc builds the document from this method. Suppose the // ... line holds no @ApiResponse annotation. Which success status does the document list for POST /orders?
@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
200 OK. The 201 that the method really returns is not in the document.

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);
    }
Reading the code
@SpringBootTest

The test starts the whole application, so springdoc sees every controller.

@AutoConfigureMockMvc

The 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.getBoolean reads 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:

  1. Change the controller or a request or response record. mvn verify fails in OpenApiContractTest.
  2. Read target/openapi.yaml, and check that the change is the one you meant.
  3. Run mvn verify -Dopenapi.update=true, and commit contracts/openapi.yaml with 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.

Gotcha

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: Cancelled

From 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 @Operation and @ApiResponse.
  • Each method returns a ResponseEntity, because the option useResponseEntity is on by default.
  • One model class for each schema, such as PlaceOrderRequest and OrderResponse.

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>
Reading the code
<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.orderservice only, 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:

OptionDefaultIn the runWhat the help or the run showed
interfaceOnlyfalsetruethe run generated only the interfaces and the models
useSpringBoot3truenot setthe help says that it also turns on useJakartaEe
useSpringBoot4falsetruethe help says "Generate code and provide dependencies for use with Spring Boot 4.x"
useJackson3falsetruethe help says that it is allowed only when useSpringBoot4 is set
useJakartaEefalsenot setturned on by useSpringBoot3, as the help of that option says
useJspecifyfalsenot setwithout it, the models import the deprecated org.springframework.lang.Nullable
openApiNullabletruefalseits effect was not checked for this book
skipDefaultInterfacefalsetruewith it, a controller that misses a method does not compile
useBeanValidationtruenot setthe generated interface carried @Valid and @Min from the file
useResponseEntitytruenot setevery 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#
Means
The contract changed a parameter's type, so the generated interface has a new method signature. The controller still implements the old one.
Fix
Change the controller's method to the new signature, here with a 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.

Gotcha

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 boxed Integer. A format: uuid becomes a UUID parameter, 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:

QuestionCode first, as in the order serviceContract first
Where is the truth?the controllers and DTOsthe OpenAPI file
When does the contract exist?after the controller and DTOs are writtenbefore any code
What catches a difference?the drift test, at every buildthe compiler, for a changed signature only
What does an API change look like?a Java change, plus a diff of the generated filea diff of the file, plus a Java change
Who can write the change?any developer of the serviceanyone who can edit YAML, including other teams
What does the code look like?the team's own records and controllersgenerated interfaces and model classes
What needs tuning?a few springdoc settings and annotationsthe 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.

Pragmatic choice: The OpenAPI contract is generated from the code

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#

C#
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapOpenApi();
pom.xml
<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:

QuestionASP.NET Core 10springdoc 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.13.1.0
YAMLonly when the route is mapped with a .yaml suffixalways, at its own path
A UInone, so Swagger UI or Scalar is added separatelySwagger UI in the same dependency
Export at build timethe property OpenApiGenerateDocumentsOnBuildthe Maven plugin, or a test such as OpenApiContractTest
How the export worksit starts the app's entry point with a mock serverthe plugin starts the app on a port, and the test uses MockMvc
A 201 from a result helperTypedResults.Created is documented as 201ResponseEntity.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.

Note

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
}
Reading the code
"$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: uuid describes the text. See the next paragraph for what a validator does with it.

"additionalProperties": true

An 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 eventThe result
every required field, with the right typesvalid
an extra field, giftWrapvalid, because additionalProperties is true
an extra field, with additionalProperties set to falserefused, giftWrap was unexpected
deliveryMethod removedrefused, deliveryMethod is a required property
deliveryMethod set to nullrefused, because null is not a string
orderId set to not-a-uuidvalid, 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);
    }
}
Reading the code
@JsonTest

A slice test that starts only Jackson's part of Spring Boot. The injected JsonMapper is 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, format is a note by default, not a check. This option turns it into a check, so an orderId that is not a UUID fails. The test anOrderIdThatIsNotAUuidBreaksItsSchema shows 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);

validate returns 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 as order-service/OrderPlacedV1. The first run of the test failed with InvalidSchemaException: '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#

C#
public record OrderShippedV1(string EventId, DateTimeOffset OccurredAt, string OrderId);
Java 25
/**
 * 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 changeAllowed within V1Why
add a fieldyesa tolerant reader ignores it, and the schema allows extra fields
remove a fieldnoa consumer that reads it would get nothing
rename a fieldnoa rename is a removal and an addition, for the consumer
change the type of a fieldnoa consumer's parser would fail
make an optional field requirednoan event from an older producer, or one already in a queue, may lack it
change what a field meansnothe 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#

C#
var eventId = Guid.NewGuid().ToString();
Java 25
/** 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#

C#
public record PaymentReceivedV1(
    [property: JsonRequired] string EventId,
    [property: JsonRequired] string OrderId);
Java 25
@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");
    }
}
Reading the code
@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. requireNonNull throws a NullPointerException with 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" }
  }
}
Reading the code
"$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 PaymentReference checks 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();
        };
    }
}
Reading the code
return switch (event) {

A switch expression with type patterns. Each case tests 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. LinePicked and PaymentRecorded are 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.

Check yourself
1
An 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?
The committed file makes every API change a diff in the pull request, so a reviewer and the UI team see it. The test fails when the code and the file differ, so the file can never drift. A document served only at run time shows the current API, but nobody sees what changed.
2
An expert asksWhen is contract first the better choice for a Spring service?
Contract first is better in two cases. A UI or partner team must sign off the API before any code exists. Or the API is shared outside the team or the company. Then the file is the agreement, and the generator makes the compiler check that the controllers follow it. For an internal API, code first with a committed file and a drift test is simpler.
3
The order service's OpenApiConfig sets a fixed server URL. What would happen without it?
springdoc would write the server URL from the request. A file exported through MockMvc would say 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.
4
Within OrderPlacedV1, which changes may the order service make, and what does it do for any other change?
It may add fields. It may not remove or rename a field, change a field's type or meaning, or make an optional field required. For such a change, it publishes OrderPlacedV2 next to V1, until every consumer has moved.
5
TranslateWrite 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);
@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:

SettingValueWhat it does
springdoc.writer-with-order-by-keystruewrites keys in a fixed order, so the file changes only when the API changes
springdoc.default-produces-media-typeapplication/jsonwrites application/json instead of */* for each response
springdoc.api-docs.enabledtrueserves /v3/api-docs, which needs a token
springdoc.swagger-ui.enabledfalse, and true in the local profileserves Swagger UI at /swagger-ui.html
springdoc.api-docs.pathnot set, so /v3/api-docsthe path of the document
springdoc.swagger-ui.pathnot set, so /swagger-ui.htmlthe path of Swagger UI

The versions, as of October 2026:

ToolVersionNote
springdoc-openapi-starter-webmvc-ui3.2.0its parent is Boot 4.1.2, and 3.1.x also supports Boot 4.1
springdoc-openapi-maven-plugin1.5it needs the application running, on a port
openapi-generator-maven-plugin7.26.0set useSpringBoot4 and useJackson3 for a Boot 4 service
Microsoft.AspNetCore.OpenApi10.0.8brings Microsoft.OpenApi 2.0.0, which has a known vulnerability

The contracts of the order service:

FileWhat it describesWho owns it
contracts/openapi.yamlthe HTTP API, generated by springdocthe order service
contracts/events/OrderPlacedV1.jsonthe event of a placed orderthe order service
contracts/events/OrderShippedV1.jsonthe event of a shipped orderthe order service
contracts/events/OrderCancelledV1.jsonthe event of a cancelled orderthe order service
contracts/events/consumed-PaymentReceivedV1.jsonthe part of the payment event that the order service readsthe payment service

Security in depth#

The web layer and security · Chapter 15· 58 min read· Quick reference

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.

KindC#JavaNote
new[Authorize(Policy = "orders.write")] on each action@OrdersWrite on each controller methodthe service checks scopes where users enter, so its application services carry no permission annotation Why
twistAddAuthentication().AddJwtBearer() in each serviceone SecurityFilterChain bean from the platform libraryevery service gets the same chain, and a service may still replace it with a bean of its own Why
newno equivalentProblemAuthenticationEntryPointthe filter chain answers 401 before Spring MVC runs, so the platform writes the problem body there Why
newno equivalent@AutoConfiguration(beforeName = ...)Boot's own chains back off when a chain exists, so the library's chain must be created first Why
twisto.Authority = "https://..."issuer-uri, used at the first tokenSpring fetches the discovery document and the keys on first use, so startup does not need the issuer Why
trapa ClockSkew of 5 minutesa clock skew of 60 secondsa token that expired 90 seconds ago is refused by Spring and accepted by ASP.NET Core Why
trapa token without exp is refuseda token without exp is acceptedSpring checks exp only when the claim is present, so the issuer must always set it Why
trapa typ header of at+jwt is accepteda typ header of at+jwt is refused with 401Spring Security 7 accepts only JWT or no typ header, so an RFC 9068 issuer needs a custom validator Why
twistValidateAudience is on by defaultaudiences: order-serviceSpring checks the aud claim only when this setting lists audiences, so set it in every service Why
twistp.RequireClaim("scope", "orders.write")hasAuthority('SCOPE_orders.write')Spring splits a scope claim at its spaces, but RequireClaim compares the whole string Why
newno equivalentthe authority FACTOR_BEARERSpring Security 7 adds it to every JWT login, but the jwt() test helper does not add it Why
twistAddControllers() and AddJwtBearer(), with no antiforgery setupcsrf(disable) and SessionCreationPolicy.STATELESSbearer requests already skip CSRF and create no session, so the two lines matter only for requests without a token Why
twistIHttpContextAccessor and HttpContext.UserSecurityContextHolder.getContext()the caller of the request, stored for the thread that handles the request Why
trapHttpContext.User flows into Task.Runthe security context stays on its threada new thread, a thread pool or an @Async method sees no caller unless a wrapper copies it Why
newa decorator class that you register yourself@EnableMethodSecurity and a proxyan interceptor checks @PreAuthorize before the method runs, and outside its transaction Why
twistno equivalent@PostFilter, @AuthorizeReturnObject and @HandleAuthorizationDeniedfurther method annotations that the order service does not need Why
twist[Authorize(Policy = "orders.write")]@OrdersWrite, which carries @PreAuthorizea 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 firstSpring MVC reads and validates the body before the method's check runs Why
twistIAuthorizationService.AuthorizeAsync inside the action@PreAuthorize on the application serviceboth run after the body is read, so an invalid body gets 400 before 403 Why
twistRequireAuthorization("orders.write") on an endpointrequestMatchers(...).hasAuthority(...) in the chaina URL rule answers 403 before the body is read, but it is a second list of paths Why
newno equivalentthe place of the check, chosen by the shape of the serviceone entry point for users means a check there, and several mean a check on the use case Why
trapno equivalenta helper that runs one call with extra scopeseven a careful one grants rights that the token lacks, because a check is on a method that internal callers use Why
twistAuthorizationHandler<BelongsToOrder, Note>a domain method such as Note.requireBelongsToa rule about one record is written with the record, and a wrong order answers 404 Why
newno equivalentInboxMessageHandler empties the security contexta message or a job acts as the system actor, and calls use cases with no check Why
twista ClaimsPrincipal read through IHttpContextAccessorCurrentActor, read by SecurityContextCurrentActorthe audit log asks one small interface who acts, and gets the token's subject or system Why
twista DelegatingHandler that adds a tokenOAuth2ClientHttpRequestInterceptor with client credentialsthe service sends its own token to the stock service, not the user's token Why
trapno equivalentthe interceptor's default principal resolverit caches the client token per signed-in user, so each user causes a new token request Why
twistAuthenticationHandler<AuthenticationSchemeOptions> written for testsjwt() from spring-security-testit puts an authentication into the context and never calls the decoder Why
trapno equivalent@WithMockUser in a resource server testit passes scope checks, but code that reads JWT claims gets a user object instead of a Jwt Why
twistno equivalentissuer-uri set to empty, and a jwk-set-uri that nothing servesthe full application starts with no token issuer, because jwt() never calls the decoder Why
newno equivalentArchitectureRules.everyEndpointHasAPermission()the build fails when an endpoint has no permission annotation Why
twisto.MetadataAddress and ValidIssuer set apartissuer-uri for the host, jwk-set-uri for the Docker networkthe local token's iss is the host URL, but the service fetches the keys by container name Why
twistno equivalentno AWS keys in application.ymlthe 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:

CallerHow it proves who it isWhat it may do
an agent, over HTTPa JWT from the shop's token issuer, in the Authorization headerwhat the scopes in the token allow
the payment service, with a message on a queueit proves nothing in the message, because the service trusts the queuerecord a payment, as the system actor
the orchestrator's health probesno token at allread /actuator/health and /actuator/info, and nothing else
the order service, calling the stock serviceits own token, from the client-credentials grantreserve 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();
}
Reading the code
@ConditionalOnMissingBean(SecurityFilterChain.class)

Spring creates this bean only when the service has no SecurityFilterChain of 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[]::new is a method reference. Here it tells toArray how 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 from application.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.html

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

IN THE FILTER CHAIN BearerTokenAuthenticationFilter reads the token, checks it, fills the security context no token or a bad one: 401 problem AuthorizationFilter the URL rules: here only "authenticated" a URL rule fails: 403, empty body IN SPRING MVC Argument resolution reads the JSON body, and runs @Valid on it body not valid: 400 problem Method security on the controller checks @OrdersWrite, which is @PreAuthorize scope missing: 403 problem OrderController.place builds value objects, such as OrderId.of(text) value not valid: 400 problem OrderService.place no permission check, then its transaction
The path of POST /orders. BearerTokenAuthenticationFilter answers 401. The AuthorizationFilter answers 403 for a URL rule. Spring MVC answers 400 for an invalid body. Method security on the controller answers 403 for a missing scope. The controller answers 400 for an invalid value. OrderService checks no permission.

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:

FilterWhat it does in the order service
SecurityContextHolderFiltergives the request a security context, and clears the thread's context when the request ends
BearerTokenAuthenticationFilterreads the bearer token, checks it, and stores the caller for this request only
AnonymousAuthenticationFilterputs an anonymous authentication into the context when the request has no caller
ExceptionTranslationFilterturns a missing login into 401, and a refusal into 403
AuthorizationFilterchecks 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));
Reading the code
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-Authenticate header, 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:

CaseStatusWWW-Authenticate header
no token401Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource"
a token that fails a check401Bearer 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 scope403Bearer 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.

Gotcha

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 {
Reading the code
@AutoConfiguration(beforeName = {

This annotation marks the class as an auto-configuration. beforeName lists 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 ManagementWebSecurityAutoConfiguration creates a chain for the Actuator, and OAuth2ResourceServerWebSecurityAutoConfiguration creates a JWT chain when a JwtDecoder bean 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 an OAuth2AuthorizedClientService bean 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.

@EnableMethodSecurity

This 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-configurationThe chain that was created
with beforeName, in a package named aaa.platformthe platform's chain
with beforeName, in a package named zzz.platformthe platform's chain
no ordering, in aaa.platformthe platform's chain
no ordering, in zzz.platform, with issuer-uri setBoot's jwtSecurityFilterChain
no ordering, in zzz.platform, without issuer-uriBoot'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.

Note

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:

C#
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.Authority = "https://login.shop.example/default";
        o.Audience = "order-service";
    });
application.yml
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-service
Reading the code
issuer-uri: ${OAUTH_ISSUER_URI}

This is the URL of the token issuer. ${OAUTH_ISSUER_URI} reads an environment variable. issuer-uri is like Authority in the JwtBearer options. Spring uses the URL twice. It finds the signing keys from it, and it compares every token's iss claim with it.

audiences: order-service

Every token must name order-service in its aud claim. This setting is like Audience in 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:

The first token arrives after startup GET {issuer}/.well-known/openid-configuration the discovery document names the URL of the key set GET {issuer}/jwks the public keys, each with a key ID (kid) kept in memory for 5 minutes, used for every later token A token with a new kid signed with a newer key one more fetch of the key set: a rotated key works without a restart
The JWT key path with issuer-uri. The first token causes a fetch of the discovery document (.well-known/openid-configuration) and then of the JSON Web Key Set (JWKS). Spring keeps the key set for 5 minutes. A token with an unknown key ID (kid) causes one more fetch of the key set.

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 the kid of 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.
Gotcha

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.

Gotcha

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 typ header, the time claims exp and nbf, and tokens that are bound to a certificate.
  • The issuer check, when issuer-uri is set.
  • The audience check, when audiences is 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 tokenSpring Security 7.1.2The reason in the header
signed with a key the issuer does not publish401Signed JWT rejected: Another algorithm expected, or no matching key(s) found
signed with the wrong key401Signed JWT rejected: Invalid signature
not a JWT at all401Malformed token
iss is another issuer401The iss claim is not valid
aud does not name order-service, or is missing401The aud claim is not valid
exp was 30 seconds ago200none, because the clock skew is 60 seconds
exp was 90 seconds ago401Jwt expired at ...
nbf is 90 seconds in the future401Jwt used before ...
no exp claim at all200none, because exp is checked only when present
typ header at+jwt401the 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.

Gotcha

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.

Gotcha

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:

CheckASP.NET Core 10, AddJwtBearerSpring Security 7.1.2
clock skew5 minutes, so exp 4 minutes ago is accepted60 seconds, so exp 90 seconds ago is refused
a token with no exprefused, because RequireExpirationTime is trueaccepted
typ header at+jwtaccepted, because ValidTypes is nullrefused with 401
audiencechecked by default, because ValidateAudience is truechecked only when audiences is set
claim namesMapInboundClaims renames sub to a long URI claim typethe claims keep their names from the token
a 401 without a tokenthe header WWW-Authenticate holds only the word Bearerthe header adds resource_metadata
a 403no WWW-Authenticate headererror="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:

AuthorityWhere it comes from
SCOPE_orders.readthe first scope, with the prefix SCOPE_
SCOPE_orders.writethe second scope, with the prefix SCOPE_
FACTOR_BEARERadded 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 csrf line 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 a Set-Cookie header.
  • STATELESS also 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:

C#
public class CurrentActor(IHttpContextAccessor accessor)
{
    public string Id() =>
        accessor.HttpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system";
}
Java 25
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();
    }
}
Reading the code
accessor.HttpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value

In ASP.NET Core, MapInboundClaims renames sub to the claim type ClaimTypes.NameIdentifier. So the C# code reads that name.

SecurityContextHolder.getContext().getAuthentication();

SecurityContextHolder is 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 AnonymousAuthenticationToken into the context. Its name is anonymousUser, and isAuthenticated() 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 sub claim.

Three objects are involved, one inside the other:

ObjectWhat it holdsASP.NET Core
SecurityContextHolderthe strategy that stores one context for each threadIHttpContextAccessor
SecurityContextone Authentication, or noneHttpContext, for this purpose
Authenticationthe caller's name, its authorities and the principal, here a JwtClaimsPrincipal

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:

StrategyWhat it doesUse it
MODE_THREADLOCALone context for each thread, and the defaultalways, in a web service
MODE_INHERITABLETHREADLOCALa new thread starts with a copy of its parent's contextnever with a thread pool, because a pool thread keeps its first copy
MODE_GLOBALone context for the whole JVMonly 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 ranWhat getAuthentication() saw
a new platform threadnull
a new virtual threadnull
CompletableFuture.supplyAsyncnull
a task on a plain fixed thread poolnull
an @Async method, on Boot's default executornull
an @Async method, with spring.threads.virtual.enabled=truenull
a DelegatingSecurityContextRunnable on a new threadalice
a DelegatingSecurityContextExecutorService over a fixed poolalice, and the next plain task on that pool thread saw null
the same wrapper over Executors.newVirtualThreadPerTaskExecutor()alice
an @Async method, on a DelegatingSecurityContextAsyncTaskExecutoralice

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.

Gotcha

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);
    }
}
Reading the code
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 answers system. 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:

Spring MVC THE PROXY, A CGLIB SUBCLASS order 200 @PreAuthorize, so @OrdersWrite checks the authorities order 450, 600 return objects and @PostFilter Integer.MAX_VALUE @Transactional begins your method refused: AuthorizationDeniedException, no transaction, and the exception handler answers 403
Method security in the proxy. The @PreAuthorize interceptor has order 200 and the transaction interceptor has order Integer.MAX_VALUE, so the permission check runs first. A refused call throws AuthorizationDeniedException before any transaction begins.

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:

InterceptorOrderWhen it runs
@PreFilter100before the method
@PreAuthorize200before the method
@Secured300before the method
JSR-250 annotations such as @RolesAllowed400before the method
@AuthorizeReturnObject450after the method, and it wraps the result
@PostAuthorize500after the method returns
@PostFilter600after the method, and it filters the returned collection
@TransactionalInteger.MAX_VALUEaround 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.

Gotcha

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:

AnnotationWhat it doesWhat the run showed
@PostFilterremoves elements from the returned collection that fail the expressionwith filterObject.startsWith(authentication.name) as alice, [alice-1, bob-1, alice-2] became [alice-1, alice-2]
@AuthorizeReturnObjectreturns a proxy of the result, so the result's own annotated methods are checkedit works on a plain class, but a record result failed with ClassCastException
@HandleAuthorizationDeniednames a handler that returns a fallback value instead of throwingnew in Spring Security 6.3, read from the source, and not run
annotation templateslet 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#

C#
[HttpPost("/orders"), Authorize(Policy = "orders.write")]
public IActionResult Place(PlaceOrder body) => StatusCode(201);
Java 25
/** 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 {
}
Reading the code
@Target({ElementType.METHOD, ElementType.TYPE})

@Target says 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.

@Documented

The 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 @PreAuthorize on @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 {

@interface declares 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.

PredictA read-only caller posts an invalid order#
A caller whose token has only the orders.read scope posts an order with an empty customer name. Which status does the caller get?
@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
400 Bad Request, with the field errors. Not 403.

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 iswrite scope, invalid bodyread scope, invalid bodyread scope, valid bodyread scope, broken JSONno token
a URL rule in the filter chain400403403403401
@PreAuthorize on the controller method400400403400401
@PreAuthorize on the service method the controller calls400400403400401
ASP.NET Core, [Authorize(Policy)] on the action400403403403401
ASP.NET Core, AuthorizeAsync inside the action400400403400401

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());
}
Reading the code
.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);

orders is 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:

QuestionURL rule in the chainAnnotation on the controllerAnnotation on the service
When the check runsbefore Spring MVC reads anythingafter the arguments are read and validatedwhen the use case is called
A read-only caller with an invalid body403400400
The body of the 403empty, unless the chain also writes a problemthe platform's problem, with a traceIdthe platform's problem, with a traceId
Where the rule is writtenin a list of paths, apart from the controllerson the method it protectson the use case it protects
A new endpoint that nobody protectedit gets only the authenticated rulethe architecture rule fails the builddepends on whether the endpoint calls a protected method
Other features and message listenersnot affectednot affectedchecked 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.

Pragmatic choice: Permissions on the controllers, not on the use cases

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.

Pragmatic choice: 400 before 403 for an invalid body

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#
Means
A method with a permission annotation was called on a thread that has no caller, such as a message listener or a scheduled job.
Fix
Check permissions where users enter, and call the use case from the listener without a check. Do not invent a caller for the thread.

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 is orders.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 names system.
  • It builds a new context, and restores the old one in a finally block, 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:

  1. 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.
  2. 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 only orders.read cannot call the write use case directly. Yet code inside the agent's request now runs with orders.write.
  3. The inner check protects nothing that a user can reach directly. It says that a caller with orders.write may 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.
  4. 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.
  5. 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.
  6. 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:

C#
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;
    }
}
Java 25
/**
 * 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);
    }
}
Reading the code
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 finally block 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);
}
Reading the code
.append("actor", currentActor.id())

The actor is the token's sub claim for a request, and system for 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.

Note

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}
Reading the code
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.reserve

The 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;
}
Reading the code
new OAuth2ClientHttpRequestInterceptor(tokenManager.get());

This interceptor of RestClient adds 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.

Gotcha

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 {};
}
Reading the code
@WebMvcTest

This 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")

@AliasFor passes 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.#
Means
The security chain accepts JWTs, but the test has no issuer setting and no decoder bean.
Fix
Give the slice a decoder for tests, such as the platform's 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"));
Reading the code
jwt().jwt(token -> token.subject("agent-1"))

The inner jwt(...) changes the claims of the token. Here the subject is agent-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 the SCOPE_ 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.

Gotcha

@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",
Reading the code
"spring.security.oauth2.resourceserver.jwt.issuer-uri=",

The issuer setting is empty. So Spring adds no issuer check, and the environment variable of application.yml is 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, and jwt() 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");
}
Reading the code
methods().that().areDeclaredInClassesThat().areAnnotatedWith(RestController.class)

The rule looks at the methods of every @RestController class.

.and().areMetaAnnotatedWith(RequestMapping.class)

It keeps the methods that handle requests. @GetMapping and @PostMapping carry @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"]
Reading the code
ports: ["8090:8080"]

The server listens on port 8080 in its container. The host reaches it on port 8090.

JSON_CONFIG_PATH: /config/config.json

This 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"
          }
        },
Reading the code
"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_id is exactly read-only. The server also accepts "*" and regular expressions.

"aud": ["order-service"],

The token names the order service as its audience, so it passes the audiences check.

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_token

The 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/jwks

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

YOUR MACHINE curl or Playwright calls localhost:8090 POST /default/token DOCKER NETWORK local-dev mock-oauth2 port 8080, 8090 on the host iss: http://localhost:8090/default order-service container issuer-uri: compared with iss, never called jwk-set-uri: http://mock-oauth2:8080/default/jwks GET /default/jwks
The issuer-versus-keys trick. A token from localhost:8090 has the issuer http://localhost:8090/default. The order service compares iss with issuer-uri, which it never calls, and fetches the keys from jwk-set-uri, the container name mock-oauth2 on the Docker network.

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-key and secret-key
  • the instance profile, when spring.cloud.aws.credentials.instance-profile is 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's sts module 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:

PlaceWhat it reads
Java system propertiesaws.accessKeyId and aws.secretAccessKey
environment variablesAWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
a web identity tokenthe 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 credentialsthe credentials that Amazon ECS delivers, when AWS_CONTAINER_CREDENTIALS_RELATIVE_URI is set
the instance profilethe 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.

Gotcha

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.

Check yourself
1
An expert asksThe order service checks permissions on its controllers. Why can the payment listener then record a payment without any identity?
The permission check runs at the door that users use, the HTTP API. The application service has no check. The listener calls 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.
2
An 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?
Even a careful helper is an elevation of privilege that the code grants itself. Code runs with a scope that the caller's token may not hold. The inner check guards an action that no user starts. The helper breaks when the call moves to another thread, and every caller and test must know it. It exists only because the check is on a method that internal callers use. Move the check to the entry point.
3
A token's exp was 30 seconds ago. Does Spring Security 7 accept it? Does ASP.NET Core with the defaults of AddJwtBearer?
Both accept it. Spring allows a clock skew of 60 seconds, and ASP.NET Core allows 5 minutes. At 90 seconds, Spring refuses the token with "Jwt expired at", and ASP.NET Core still accepts it.
4
The 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?
The default resolver stores the token under the signed-in user, so each agent causes a token request of their own. The platform calls setPrincipalResolver with a fixed principal for the service. Then all callers share one token. StockAdapterTest.twoDifferentUsersShareOneServiceToken checks it.
5
TranslateIn 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.
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 pathScopeSuccess
POST /ordersorders.write201, with the first notes of the order
GET /orders?status=&page=&size=orders.read200, one page of orders
GET /orders/{orderId}orders.read200, the order
POST /orders/{orderId}/lines/{lineId}/pickorders.write204, after the line is picked
POST /orders/{orderId}/shiporders.write204, after the order is shipped
POST /orders/{orderId}/cancelorders.write204, after the order is cancelled
POST /orders/{orderId}/notesorders.write201, with the ID of the new note
GET /orders/{orderId}/notesorders.read200, one page of notes
POST /orders/{orderId}/notes/{noteId}/resolveorders.write204, after the action is resolved
GET /reference-data/{type}orders.read200, the values of one lookup list

The settings of the resource server and the client, as Boot 4.1.2 reads them:

SettingDefaultWhat it does
spring.security.oauth2.resourceserver.jwt.issuer-urinonefinds the keys by discovery, and checks the iss claim
spring.security.oauth2.resourceserver.jwt.jwk-set-urinonefetches the keys from this URL, with no discovery
spring.security.oauth2.resourceserver.jwt.audiencesan empty listwhen set, the aud claim must share one entry with this list
spring.security.oauth2.resourceserver.jwt.authority-prefixSCOPE_the prefix that each scope gets as an authority
spring.security.oauth2.resourceserver.jwt.authorities-claim-namescope, then scpthe claim that holds the authorities
spring.security.oauth2.resourceserver.jwt.authorities-claim-delimitera spacethe separator inside a string claim
spring.security.oauth2.resourceserver.jwt.authorities-claim-expressionsnoneSpEL expressions that read authorities, and they cannot be combined with the two settings above
spring.security.oauth2.resourceserver.jwt.principal-claim-namesubthe claim that getName() returns
spring.security.oauth2.client.registration.{id}.authorization-grant-typenoneclient_credentials, for a service's own token
spring.security.oauth2.client.provider.{id}.token-urinonethe issuer's token endpoint
platform.security.public-paths/actuator/health/**, /actuator/infothe 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:

PositionFilterNote
1DisableEncodeUrlFilterin every chain, and the service does not configure it
2WebAsyncManagerIntegrationFilterin every chain, and the service does not configure it
3SecurityContextHolderFiltergives the request a security context, and clears it at the end
4HeaderWriterFilteradds protective headers, and CsrfFilter follows it when CSRF is on
5LogoutFilterin the chain by default, though the service has no logout
6OAuth2ProtectedResourceMetadataFilternew, and it belongs to the metadata of RFC 9728
7BearerTokenAuthenticationFilterreads and checks the bearer token
8RequestCacheAwareFilterin the chain by default, and STATELESS gives it a cache that stores nothing
9SecurityContextHolderAwareRequestFilterin the chain by default, and the service does not use it
10AnonymousAuthenticationFilterputs an anonymous authentication into a context without a caller
11SessionManagementFiltermissing in the run without STATELESS
12ExceptionTranslationFilterturns a missing login into 401, and a refusal into 403
13AuthorizationFilterchecks the URL rules, and it is last by default

Storing aggregates in DocumentDB#

Storing and changing data · Chapter 16· 41 min read· Quick reference

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.

KindC#JavaNote
newno equivalentone document for each order, with its lines and its last payment insidethe aggregate is what a use case loads and saves, so one read returns all of it Why
twista separate collection for notes, read with its own Findthe collection notes, where each note holds its order's IDnotes have no limit, and embedding them would rewrite the whole order for each new note Why
twista class with [BsonElement] attributes on its propertiesOrderDocument, a package-private class with package-private fieldsthe full style keeps the stored shape inside the persistence package, apart from the domain Why
twista hand-written method that copies a document into an entityOrderDocumentMapper implements DocumentMapper<Order, OrderDocument>loading goes through Order.rehydrate, so it applies no rules and raises no events Why
newno equivalentOrderPersistenceAdapter extends MongoAggregateRepositorythe platform's base class implements findById, save and exists, so the adapter adds only its own queries Why
twistan entity class that the driver maps directlyNote with @Document, @PersistenceCreator and a @Transient list of eventsthe light style stores the entity itself, and the service uses the Spring Data repository directly Why
twistan enum, stored as a number by defaultthe 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 BigDecimalthe amount stays exact, and the platform also sets this representation for every BigDecimal Why
twista property named Id in a nested class, stored as _ida field named id in an embedded class, stored as _idSpring rewrites only its own queries, so the shop names the field lineId Why
twistReplaceOneAsync with a filter on Id and Version, then a check of MatchedCount@Version private Long versionSpring Data adds the version to the filter itself, and throws when nothing matched Why
traporder.Version = expected + 1 on the same object, before ReplaceOneAsyncthe order passed to save keeps its old versiona second save of that object fails, as an insert with a duplicate key or as a conflict Why
trapcatch DbUpdateConcurrencyException for every conflictOptimisticLockingFailureException, or a write conflict as DataIntegrityViolationExceptioninside a transaction, the conflict usually arrives as a write conflict, so the platform answers both with 409 Why
twistFind(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
twistSkip and Limit, then CountDocumentsAsynca Pageable in, and a Page outSpring Data sends the count query only when it needs it, and never on the last page Why
twistUpdateOneAsync with Builders<T>.Update.SetMongoTemplate with Criteria, Update and org.bson.Documentfor writes that do not store an aggregate, such as the inbox key and the audit record Why
newno equivalenta missing field handled on load, or a migration that writes a defaultstored documents keep their old shape, so every change of shape needs a plan Why
trapno equivalentspring.mongodb.uri, not spring.data.mongodb.uriBoot 4 ignores the old key without a warning, and connects to localhost instead Why
trapreadPreference=secondaryPreferredreplicaSet=rs0&readPreference=primarythe service often reads its own writes, so it reads from the primary instance Why
twistsettings.RetryWrites = falsea MongoClientSettingsBuilderCustomizer that calls retryWrites(false)it runs after the connection string is applied, so a copied URI cannot switch retryable writes on again Why
traptlsCAFile in the connection stringan SSLContext built from the CA bundle filethe Java driver ignores tlsCAFile, so the platform sets the trusted certificates in code Why
traptests against a MongoDB container onlya MongoDB container for most tests, and one test stage against a real clusterthe container accepts features that DocumentDB lacks, as of October 2026 Why
trapno equivalentnew MongoDBContainer(image).withReplicaSet() from org.testcontainers.mongodbthe new class starts no replica set by default, so a changed import turns off transactions in tests Why
newno equivalenttwo copies of one order loaded, both changed, and the second save failsevery 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:

CollectionOwnerWhat one document holds
ordersthe order featureone order, with its lines and its last payment
notesthe note featureone note, with the ID of its order
reference_datathe reference featureone lookup value, such as the delivery method EXPRESS
audit_logthe platform's audit logone record of who changed what, and when
event_publicationSpring Modulith, the outboxone event for one listener, until it is sent
processed_messagesthe platform's inboxthe key of one event that was processed
schema_migrationsthe platform's migration runnerthe ID of one migration that ran
schema_migrations_lockthe platform's migration runnerthe lock, while one instance migrates
scheduler_locksShedLock, for the outbox resenderthe 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:

QuestionLinesNotes
Do the rules of the order need it?yes, picking and shipping check every lineno, only the count of open actions matters
Is the number limited?yes, at most 50 lines, checked by Order.placeno, an agent can add notes for years
Does it change on its own?no, a line changes only through the orderyes, 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;
    }
}
Reading the code
@Document("orders")

This annotation from Spring Data MongoDB names the collection. Each OrderDocument is one document in orders.

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. BaseDocument is 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 enum OrderStatus. 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 LineDocument is shown in the section An embedded field named id.

static class PaymentDocument {

A static nested 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);
    }
Reading the code
@Component

The 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 as PAID. Every Java enum has this method.

OrderStatus.valueOf(document.status),

valueOf turns the stored name back into the constant. An unknown name throws IllegalArgumentException. So renaming an enum constant needs a migration of the stored data.

return Order.rehydrate(

rehydrate is the factory for a stored order. It applies no rules and raises no events, because loading is not a business action. New orders come from Order.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());
    }
Reading the code
public abstract class MongoAggregateRepository<A extends AggregateRoot<ID>, ID extends Identifier, D extends BaseDocument>

The class has three type parameters. A is the aggregate root, ID its typed identifier, and D its 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 typed OrderId is stored as its text.

return documents.findById(id.value()).map(mapper::toDomain);

findById returns an Optional. map converts the document into an aggregate only when one was found. mapper::toDomain is 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.save writes the document and sets its new version. The base class then rebuilds the aggregate from that document. So the caller gets a new Order with 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)));
    }
}
Reading the code
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");

Sort is 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 OrderMongoRepository has findByStatus.

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:

NeedHow Note meets itWhy storage needs it
a way to create a stored notethe constructor marked @PersistenceCreatorSpring Data calls it with the stored values, and it applies no rules
a version that can change@Version private Long version, not finalSpring Data writes the new version into the object after each save
nothing stored that is not data@Transient on the list of eventsthe events are published after the save, and never stored
a stable stored name for each fieldthe field names, which are the stored namesrenaming 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();
}
Reading the code
Document stored = mongo.findById(note.id(), Document.class, "notes");

This line reads the stored note as a raw org.bson.Document, not as a Note. A Document is 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 domainStored asWhy
OrderStatus, an enuma string, such as "PAID"the value is readable, and the enum class can move or be renamed
DeliveryMethod and ProductCode, value objectsa string, the codethe code is the business value, and the Java wrapper is not stored
OrderId, LineId and PaymentReferencea stringthe ID is text in every service that refers to it
Moneya Decimal128 amount and a string currencythe amount stays exact, and the currency stays with it
Instant, a point in timea BSON datea BSON date is a point in time in UTC, so it matches Instant
a calendar date, such as a birthdayan 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=decimal128
Reading the code
spring.mongodb.representation.uuid=standard

A UUID can be stored in more than one binary form, and standard names 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=decimal128

Boot 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.#
Means
The driver has no default form for a UUID, and no setting chose one.
Fix
Set 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 asksThe queryFound 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 Datathe field was dropped, and the line's id was null
a line id of 24 hexadecimal charactersstored as an ObjectId, not as a stringSpring'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");
}
Reading the code
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:

MongoDB .NET driver
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.");
}
Spring Data MongoDB
public abstract class BaseDocument {

    @Id
    private String id;

    @Version
    private Long version;
Reading the code
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 check MatchedCount itself.

@Id

This @Id is from org.springframework.data.annotation. It maps the field to _id.

@Version

This 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 primitive long. A null version 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:

CaseWhat Spring Data sentThe result
a Long version that is nullan insertstored with version 0, even when the ID was already set
a primitive long versionan insertstored with version 1, because 0 counts as never saved
a version that is seta full replacement, filtered on _id and the old versionthe version went up by one, and there was no $set
a stale versionthe same replacement, which matched nothingOptimisticLockingFailureException, and the stored document was unchanged
a mutable classsavethe same object came back, with the new version set on it
a record, or a class with final fields and no setterssavea new object came back, and the object passed in kept its old version
a failed save of a mutable objectsavethe 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#
Means
Someone saved the document after this copy was loaded. The stored document is unchanged.
Fix
Load the aggregate again, apply the change again, and save that. Never save the failed copy a second time.

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.

PredictSaving the same order a second time#
The test then saves original a second time. What happens?
Order original = newOrder();

Order saved = orders.save(original);
saved.pickLine(saved.lines().getFirst().id(), CLOCK);
Order savedAgain = orders.save(saved);
Show the answer
The save throws 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" }#
Means
Spring Data sent an insert, because the document had no version. A document with that ID exists already.
Fix
Save the copy that the last 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);
}
Reading the code
TransactionTemplate transactions = new TransactionTemplate(transactionManager);

A TransactionTemplate runs a block of code in a transaction. For the class, see the chapter Transactions in depth.

CountDownLatch firstHasWritten = new CountDownLatch(1);

A CountDownLatch lets one thread wait until another thread calls countDown(). 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."));
}
Reading the code
@ExceptionHandler(OptimisticLockingFailureException.class)

A stale version outside a transaction becomes 409 with the code CONCURRENT_UPDATE.

if (WriteConflicts.isWriteConflict(ex)) {

DataIntegrityViolationException also 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:

MongoDB .NET driver
IFindFluent<Note, Note> page = notes.Find(n => n.OrderId == orderId);

long open = await notes.CountDocumentsAsync(n =>
    n.OrderId == orderId && n.ActionRequired && n.ActionResolvedAt == null);
Spring Data MongoDB
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);
}
Reading the code
public interface NoteRepository extends MongoRepository<Note, String> {

MongoRepository brings the usual methods, such as save and findById. Note is the stored type, and String is the type of its ID.

Page<Note> findByOrderId(String orderId, Pageable pageable);

findBy starts a query, and OrderId names the field orderId. The Pageable adds the page and the sort. See the section Paging.

long countByOrderIdAndActionRequiredTrueAndActionResolvedAtIsNull(String orderId);

countBy returns a number instead of documents. The name has three conditions, joined by And. OrderId takes the parameter. ActionRequiredTrue asks for actionRequired to be true. ActionResolvedAtIsNull asks 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());
}
Reading the code
return PageRequest.of(query.page(), query.size(), sort);

PageRequest implements Pageable. It holds the page number, counted from 0, the page size, and the sort.

page.map(mapper::toDomain).getContent(),

Page.map converts 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. PageResult is 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);
            });
Reading the code
import static org.springframework.data.mongodb.core.query.Criteria.where;

A static import lets the code write where(...) instead of Criteria.where(...). C# has using static for the same purpose.

mongo.exists(query(where("_id").is(key)), COLLECTION)

where("_id").is(key) builds a Criteria, one condition. query(...) wraps it in a Query. exists asks whether any document in the collection matches.

mongo.insert(new Document("_id", key).append("processedAt", clock.instant()), COLLECTION);

new Document(...) builds a raw document. append adds a field and returns the same document. insert writes it into the named collection.

The research for this book measured the template's raw methods:

CallWhat happened
insert(document, "seed")the same Document came back, with an ObjectId in _id when it had none
insert(document) with no collection nameMappingException, 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 valuesmatched 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:

WayWhat to doChoose it when
handle the missing field on loadthe mapper treats a missing value as "none", or gives it a defaultthe field is optional in the domain, such as a note to the courier
migrate the stored documentsa migration sets the default in every old documentevery 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");
    }
}
Reading the code
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 unknown is no agent's ID, so any agent may resolve an old note.

mongo.updateMulti(

updateMulti changes 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 valueOf fails 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}
Reading the code
mongodb:

This key is under spring:, so the full name is spring.mongodb.uri. Boot 4 moved every connection key from spring.data.mongodb to spring.mongodb.

uri: ${MONGO_URI}

${MONGO_URI} reads the environment variable MONGO_URI. The deployment sets it from the secrets manager, because the string holds a password.

Gotcha

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:

ParameterValueWhy
tlstrueAmazon DocumentDB uses TLS by default, and the platform trusts the AWS certificate bundle
replicaSetrs0AWS recommends connecting as a replica set, so the driver follows the cluster's instances
readPreferenceprimaryevery read goes to the primary instance, so a read sees the write before it
retryWritesfalse, forced by the platformDocumentDB 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:

MongoDB .NET driver
MongoClientSettings settings = MongoClientSettings.FromConnectionString(connectionString);
settings.RetryWrites = false;
return new MongoClient(settings);
Spring Data MongoDB
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));
        }
    }
Reading the code
public class DocumentDbClientSettingsCustomizer implements MongoClientSettingsBuilderCustomizer {

MongoClientSettingsBuilderCustomizer is a Boot interface, in the package org.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=true in spring.mongodb.uri.

if (StringUtils.hasText(properties.getCaBundle())) {

hasText is true for a string that is not null and 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.

Pragmatic choice: Retryable writes off on every cluster

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.

Gotcha

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);
    }
}
Reading the code
try (InputStream pem = Files.newInputStream(pemFile)) {

This is a try-with-resources statement. It closes the stream at the end, like using in C#. Files.newInputStream opens the file at the Path.

CertificateFactory.getInstance("X.509").generateCertificates(pem);

CertificateFactory reads certificates in the X.509 format. generateCertificates reads every certificate in the file, not only the first. The AWS bundle holds many.

KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());

A KeyStore holds 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 TrustManagerFactory creates 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 SSLContext holds the TLS settings of a connection. init gives it the trust managers. The first null means that the service sends no client certificate.

throw new UncheckedIOException("Could not read the DocumentDB CA bundle " + pemFile, e);

IOException is a checked exception, and customize may not throw one. UncheckedIOException wraps 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:

WayHowWhat it gets rightWhy the platform does not use it
a Boot SSL bundlespring.ssl.bundle.pem.docdb.truststore.certificate and spring.mongodb.ssl.bundle=docdbconfiguration only, and Boot loads every certificate of the PEM fileevery service repeats two settings, and the platform cannot test them once
a JVM trust storea JKS file made with keytool, and -Djavax.net.ssl.trustStoreno code, and the way that AWS's Java guide describesevery host needs the file and its password, and the setting lives outside the service
the platform's customizerplatform.mongo.documentdb.ca-bundleone property, and one tested implementation for every serviceit 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.

Gotcha

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();
    }
}
Reading the code
@ServiceConnection

Boot 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 {
}
Reading the code
@DataMongoTest(properties = "platform.mongo.migrations.enabled=false")

@DataMongoTest is the test slice of Spring Boot for MongoDB. It starts the Spring Data repositories and MongoTemplate, 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.

Gotcha

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.

Check yourself
1
An expert asksThe order service stores its own IDs as strings. Why does the platform still set spring.mongodb.representation.uuid=standard?
The MongoDB driver has no default form for a UUID. Without the setting, a write of a UUID fails with 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.
2
A 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?
The Java driver ignores 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.
3
A service on Spring Boot 4 sets only spring.data.mongodb.uri. What happens?
Boot 4 ignores the old key without a warning. The client uses the default, localhost:27017. It works on a laptop with a local MongoDB, and fails everywhere else. The key is spring.mongodb.uri.
4
Why does an embedded line of the order store its ID as lineId, not as id?
Spring Data stores a field named 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.
5
TranslateWrite 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);
@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:

SettingValue in the shopWhat it does
spring.mongodb.uri${MONGO_URI}the connection string, and the Boot 4 key
spring.mongodb.representation.uuidstandard, from the platformthe binary form of a stored UUID
spring.data.mongodb.representation.big-decimaldecimal128, from the platformstores every BigDecimal as a Decimal128, and Boot's default is unspecified
spring.data.mongodb.auto-index-creationfalse, from the platformSpring creates no index at startup, because migrations create them
platform.mongo.documentdb.ca-bundlenone locally, a mounted file in AWSthe CA bundle that TLS trusts
platform.mongo.migrations.enabledtrue, and false in slice and wiring testsruns the migrations at startup

The Spring Data annotations that the service uses for storage:

AnnotationPackageWhat it does
@Document("orders")org.springframework.data.mongodb.core.mappingnames the collection of a class
@Idorg.springframework.data.annotationmaps a field to _id
@Versionorg.springframework.data.annotationadds the version check to every update
@Field(targetType = FieldType.DECIMAL128)org.springframework.data.mongodb.core.mappingstores a field as another BSON type, or under another name
@Transientorg.springframework.data.annotationleaves a field out of the stored document
@PersistenceCreatororg.springframework.data.annotationmarks the constructor that Spring Data uses to load

Migrations, indexes and seed data#

Storing and changing data · Chapter 17· 30 min read· Quick reference

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.

KindC#JavaNote
twistan index created in code with Indexes.CreateOneAsyncspring.data.mongodb.auto-index-creation=false, and indexes in migrationsSpring creates no index at startup, so every index is reviewed code in a migration Why
twista class that extends Migration, with an Up methoda @Component that implements MongoMigration, with id() and migrate(MongoTemplate)the platform finds every migration bean and runs the pending ones in ID order Why
twista timestamp in the name of each EF Core migrationIDs that sort in run order, such as P001 and V001the runner sorts the IDs as text, so platform migrations run first and every number is padded Why
newno equivalentMongoMigrationRunner, a SmartInitializingSingletonit runs after every bean exists and before the web server starts, and records each migration in schema_migrations Why
newno equivalenta lock document with a unique _id and an expiry timeonly one instance migrates, and a lock left by a crashed instance expires after the lock TTL Why
trapno equivalentan instance that waits for the lock, with no HTTP port openthe startup probe must allow for the whole lock TTL, or the orchestrator restarts the instance Why
newno equivalenta runner of about 150 lines, not Mongock or Flamingockas of October 2026, Mongock has no Boot 4 artifact, and Flamingock lists Spring Data MongoDB only up to 4.x Why
twistBuilders<Note>.IndexKeys.Ascending(...).Descending(...) with a Namenew Index().on(field, direction).named(name)one named index for each query, with a comment that says which query Why
twistCreateIndexOptions { 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
trapa changed EF Core migration that was applied alreadyan edited migration whose ID was recorded alreadythe runner skips it, so the change never reaches the environments that ran it Why
newno equivalentmigrations that are safe to run twice, and run without a transactiona crash between the change and its record runs the migration again Why
twistdatabase.CreateCollectionAsync in the MongoDB .NET driverV000CreateCollections, a migration that creates the service's collectionsDocumentDB cannot create a collection inside a transaction, so each one exists before the first use case Why
twistUpdateOneAsync with UpdateOptions { IsUpsert = true }SeedFiles.upsert, by type and codelookup values come from JSON files, are upserted by their natural key, and are never deleted Why
newno equivalenta unit test with a mocked MongoTemplate, and an integration test against MongoDBthe 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=false

The 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);
}
Reading the code
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:

EF Core migrations
public partial class CreateIndexes : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.CreateIndex(
            name: "notes_orderId_createdAt",
            table: "notes",
            columns: ["OrderId", "CreatedAt"]);
    }
}
The order service
@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"));
        // ...
    }
}
Reading the code
@Component

The 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 before V002. 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"));
    }
}
Reading the code
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:

IDOwnerWhat it does
P000-audit-log-collectionthe platform's audit logcreates the collection audit_log
P000-messaging-collectionsthe platform's outbox and inboxcreates the collections event_publication and processed_messages
P001-audit-log-indexesthe platform's audit logthe index audit_log_resource
P002-inbox-indexesthe platform's inboxthe TTL index processed_messages_ttl
P003-outbox-indexesthe platform's outboxthe index event_publication_incomplete
V000-create-collectionsthe order servicecreates the collections orders, notes and reference_data
V001-create-indexesthe order serviceone index for each query of the three features
V002-seed-reference-datathe order servicethe delivery methods and the note categories
V003-add-note-authorsthe order servicewrites an author into the notes that have none
V004-replace-open-actions-indexthe order servicereplaces 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#
Means
Two beans of type MongoMigration return the same ID, often after a copy and paste.
Fix
Give the newer migration the next free ID. Never change the ID of a migration that has run.

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);
}
Reading the code
@ConditionalOnProperty(name = "platform.mongo.migrations.enabled", havingValue = "true", matchIfMissing = true)

The runner exists unless the property is false. matchIfMissing = true means that a service without the property gets the runner.

ObjectProvider<MongoMigration> migrations

An ObjectProvider gives access to every bean of a type. orderedStream() returns them as a stream. The runner sorts them by ID itself. For ObjectProvider, see the chapter Wiring the service: the container in depth.

@Value("${platform.mongo.migrations.lock-ttl:PT15M}") Duration lockTtl)

@Value reads one setting. The text after the colon is the default. PT15M is the ISO 8601 form of 15 minutes, and Spring converts it into a Duration.

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;
}
Reading the code
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_migrations once. 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: false

With 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);
}
Reading the code
mongo.insert(new Document("_id", LOCK_ID)

The lock is one document with the fixed _id lock. Every collection has a unique index on _id. So only one insert can succeed, and the database decides which one.

.append("owner", owner)

owner is 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. lt means "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);
    }
}
Reading the code
} catch (InterruptedException e) {

InterruptedException is a checked exception. Another thread asked this thread to stop, for example during a shutdown.

Thread.currentThread().interrupt();

A method that throws InterruptedException first 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 of Thread asks 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.

Gotcha

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.

Gotcha

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: 60s

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

Pragmatic choice: The service migrates its own database at startup

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:

LibraryWhat the sources say, as of October 2026
Mongockthe latest version is 5.5.1, from March 2025, and no artifact for Spring Boot 4 exists
Mongockits README says that it is sunsetting, in maintenance mode, and reaches its end of life at the end of 2026
Flamingockthe successor of Mongock, under the Apache-2.0 licence, with version 1.4.5 from August 2026
Flamingockits Spring Boot integration says that it is compatible with Spring Boot 2.x to 4.x
Flamingockits target system for Spring Data MongoDB lists Spring Data MongoDB 3.1.x to 4.x
the shop's stackSpring 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 5What it marksIn the shop's runner
@ChangeUnit(id, order, author)a class that is one migration, where author may be left outa class that implements MongoMigration, with id()
@Executionthe method that makes the changemigrate(MongoTemplate mongo)
@RollbackExecutionthe method that undoes the changenothing, because a later migration repairs instead
@BeforeExecutionan optional method that runs outside the change unit's transaction, for changes that a transaction does not allownothing, because no migration runs in a transaction
@RollbackBeforeExecutionthe undo of @BeforeExecution, required when that method existsnothing
mongock.migration-scan-packagethe setting that names the packages with change unitsthe 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"));
}
Reading the code
mongo.indexOps("orders").createIndex(new Index()

indexOps returns the index operations of one collection. createIndex creates the index and returns its name. The method ensureIndex does the same, but it is deprecated.

.on("status", Sort.Direction.ASC).on("placedAt", Sort.Direction.DESC)

Each on adds 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 query findByStatus.

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

MongoDB .NET driver
await notes.Indexes.CreateOneAsync(new CreateIndexModel<Note>(
    Builders<Note>.IndexKeys.Ascending(n => n.OrderId).Descending(n => n.CreatedAt),
    new CreateIndexOptions { Name = "notes_orderId_createdAt" }));
The order service
// 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"));
}
Reading the code
.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())

expire sets the time after which the database deletes a document. The setting platform.messaging.inbox.retention holds it, 14 days by default. A run showed that expire became expireAfterSeconds in 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:

DetailWhat 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 indexDuplicateKeyException, 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 propertyEngine 5.0Engine 8.0Note
single field, compound, multikeyyesyesevery index of the shop is one of these kinds, and some add a property from the rows below
uniqueyesyesa missing field counts as null, so two documents without the field collide
TTLyesyeslisted for a single field and multikey only, so a compound TTL index is not listed
partialyesyesnot on 3.6 or 4.0, and only a few operators in the filter
sparseyesyesbut unique and sparse together on a nested field is not supported
hashed, wildcard, 2dnonouse 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.

PredictAn index added to V001 after it ran#
Production ran V001 last month. The developer adds a new index to V001, as above, and deploys. What happens in production?
Set<String> applied = appliedIds();
for (MongoMigration migration : migrations) {
    if (!applied.contains(migration.id())) {
        run(migration);
    }
}
Show the answer
Nothing. The new index is never created in production.

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:

ChangeResult
the same name, keys and options againnothing happened, and the name came back, so a migration can run twice
the same name, with other keysDataIntegrityViolationException, error 86, IndexKeySpecsConflict
the same keys, with another nameerror 85, IndexOptionsConflict, "Index already exists with a different name"
the same name and keys, with another TTLerror 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#
Means
A migration creates an index whose keys match an existing index, under another name.
Fix
Keep the existing name, or drop the old index by its name in a new migration and create the new one.

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));
    }
}
Reading the code
notes.getIndexInfo().stream().anyMatch(index -> index.getName().equals(OLD_INDEX));

getIndexInfo lists 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 actionRequired true.

.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. createIndex with 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#

MongoDB .NET driver
foreach (string name in new[] { "orders", "notes", "reference_data" })
{
    if (!(await database.ListCollectionNames().ToListAsync()).Contains(name))
    {
        await database.CreateCollectionAsync(name);
    }
}
The order service
/**
 * 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"));
    }
}
Reading the code
class V000CreateCollections extends CreateCollections {

CreateCollections is 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");
    }
}
Reading the 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 value DELIVERY_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:

MongoDB .NET driver
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 });
The platform
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);
    }
}
Reading the code
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 Criteria can hold each field only once. A run showed that a second condition on the same field throws InvalidMongoDbApiUsageException.

values.set(field, value);

Every field of the entry except the code becomes a $set. So the label and the flag active are written.

mongo.upsert(Query.query(key), values, collection);

upsert updates the matching document, or inserts one when none matches. A run showed one update command with upsert: true. The first call inserted, and the next calls updated.

String json = new ClassPathResource(classpathFile).getContentAsString(StandardCharsets.UTF_8);

ClassPathResource finds a file on the classpath, such as seed/delivery-methods.json inside the Java archive (JAR) of the service. getContentAsString reads it as text in UTF-8.

return Document.parse("{\"entries\": " + json + "}").getList("entries", Document.class);

Document.parse reads 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);

getContentAsString throws the checked IOException. The helper wraps it in the unchecked UncheckedIOException, so migrate needs no throws clause. 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.

Gotcha

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 V002 skips it. It runs V005, which reads the edited file.
  • A new environment runs V002 with the edited file, and then V005 with 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);
}
Reading the code
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);

InOrder checks the order of the calls. The platform's migration comes first, then V001, then V002.

The other unit tests cover the rest of the runner without a database:

TestWhat it proves
skipsTheMigrationsThatHaveRuna recorded ID is skipped, and only the pending migration is recorded
refusesTwoMigrationsWithTheSameIdthe runner fails at creation, with the ID in the message
givesUpWhenAnotherInstanceHoldsTheLockTooLonga lock that never goes away ends in "Timed out waiting for the migration lock"
releasesTheLockWhenAMigrationFailsa 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");
}
Reading the code
.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#
Means
Another instance held the migration lock for longer than platform.mongo.migrations.lock-ttl. Its migrations may still be running.
Fix
Look at the logs of the instance that holds the lock. Set the TTL longer than the slowest migration, and give the startup probe at least as long.

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.

Check yourself
1
An 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?
Two instances could both read "no lock" at the same moment, and both insert one. An insert with a fixed _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.
2
An expert asksWhy are lookup values upserted by their type and code, and never deleted?
An upsert by the natural key makes the migration safe to run twice, and an edited entry updates the stored value in place. A value is never deleted, because stored orders and notes still hold its code. An old value is marked "active": false. New data cannot use it, and old data can still show it.
3
Why does the migration P001-audit-log-indexes run before V001-create-indexes?
The runner sorts the IDs as text. 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.
4
Why does the shop use its own migration runner, as of October 2026?
Mongock has no artifact for Spring Boot 4, and it reaches its end of life at the end of 2026. Flamingock says that it supports Boot 4, but its Spring Data target lists Spring Data MongoDB only up to 4.x. Boot 4.1.2 uses 5.1.2. The runner is about 150 lines.
5
TranslateWrite 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" }));
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:

SettingDefaultWhat it does
platform.mongo.migrations.enabledtrueruns the migration runner at startup, and tests set false
platform.mongo.migrations.lock-ttlPT15Mhow long a migration lock lasts, and how long an instance waits for one
spring.data.mongodb.auto-index-creationfalse, set by the platformSpring creates no index from @Indexed at startup
platform.messaging.inbox.retentionP14Dthe TTL of the inbox index processed_messages_ttl

The indexes of the order service and the platform:

IndexCollectionFieldsServes
orders_status_placedAtordersstatus, placedAt descendingGET /orders?status=, newest first
notes_orderId_createdAtnotesorderId, createdAt descendingGET /orders/{orderId}/notes, newest first
notes_orderId_needingActionnotesorderId, actionResolvedAt, partial on actionRequired truethe shipping rule's count of open actions, since V004 replaced notes_orderId_openActions
reference_data_type_codereference_datatype, code, uniquethe lookup lists, and one code per list
audit_log_resourceaudit_logresourceType, resourceId, atthe history of one resource
processed_messages_ttlprocessed_messagesprocessedAt, TTLdeletes inbox keys after the retention
event_publication_incompleteevent_publicationcompletionDate, publicationDatethe outbox queries for unsent rows, oldest first

The calls of the Index builder in Spring Data MongoDB 5.1:

CallWhat 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#

Storing and changing data · Chapter 18· 25 min read· Quick reference

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.

KindC#JavaNote
newsession.StartTransaction() around every changeone transaction for each use case that changes datathe change, its outbox row and its audit record are committed together Why
trapno equivalenta MongoTransactionManager bean from the platformwithout it, @Transactional does nothing on MongoDB, and every write commits on its own Why
twistno equivalentspring.modulith.events.mongodb.transaction-management.enabled=falseSpring Modulith would register a transaction manager too, so the platform keeps one Why
twistsession.WithTransactionAsync(...) around the method body@Transactional on the use casea proxy starts the transaction, and every repository call joins it without a session parameter Why
trapTransactionScope around calls of the MongoDB driver@Transactional with a MongoTransactionManagerthe plain .NET driver ignores TransactionScope, so a scope that is not completed rolls nothing back Why
newno equivalenttransaction.execute(status -> ...) after the call to the stock serviceonly the lambda is transactional, so a slow HTTP call never holds a transaction open Why
twistpassing the same session to a second methodpropagation REQUIRED, the defaultthe inner method joins the running transaction, so the order and its notes commit together Why
trapcatching an exception of an inner call, and committingcatching an exception that an inner @Transactional method threwthe inner method marked the transaction rollback-only, so the commit throws UnexpectedRollbackException Why
twistno equivalentdomain exceptions that extend RuntimeExceptionevery broken rule rolls the transaction back, with no rollbackFor Why
newno equivalentMongoAuditLog and the outbox, both inside the use case's transactionan audit record or an event is stored only with the change it describes Why
trapno equivalenta publisher that throws outside a transactionan outbox row written outside a transaction is never sent, so the platform refuses to write it Why
trapWithTransactionAsync retries a TransientTransactionErrorSpring retries nothinga write conflict, error 112, reaches the caller as DataIntegrityViolationException Why
trapno equivalenta transaction on Amazon DocumentDBas of October 2026, it may run one minute, write less than 32 MB, and create no collection Why
twistno equivalentMongoDB as a one-node replica set, locally and in testsMongoDB supports transactions only on a replica set Why
newno equivalentone aggregate per transaction, except an order with its first notesa second aggregate in one transaction is a sign to use an event, unless one user action creates both Why
twista mocked IClientSessionHandlea real TransactionTemplate with NoOpTransactionManagera 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 caseHow it gets its transactionWhy this form
OrderService.placeTransactionTemplate, after the stock callonly part of the method may be transactional
pickLine, ship, cancel, addNote, resolveAction@Transactional on the methodthe whole method is the change
OrderService.recordPayment@Transactional, joining the inbox's transactionthe inbox key and the payment commit together
NoteService.addNotes@Transactional, joining placethe order and its first notes commit together
get, list, countNeedingActionno transactiona 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);
    }
Reading the code
@AutoConfiguration(beforeName = {

Spring Boot processes this class before the two Boot classes it names. A run showed that Boot adds its TransactionTemplate bean 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) {

MongoTransactionManager is the transaction manager of Spring Data MongoDB. MongoDatabaseFactory gives it the database. Boot creates the factory from the connection settings.

A run for this book showed both states on Boot 4.1.2:

QuestionWith no managerWith a MongoTransactionManager
TransactionManager beansnoneone, named transactionManager
TransactionTemplate beansnoneone, which Boot adds
Is the service a proxy?noyes
isActualTransactionActive() inside @Transactionalfalsetrue
What the driver senteach insert on its own, as a retryable writestartTransaction with the first write, then commit or abort
An unchecked exception after an insertthe document stayedthe document was rolled back
A bean that needs a PlatformTransactionManagerthe startup failedthe 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#
Means
A bean needs a transaction manager, but none exists. On MongoDB, Spring Boot creates none.
Fix
Declare a 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:

TypeWhat it isWho uses it in the service
TransactionManageran interface with no methods, the parent of every kind of transaction managerthe platform's condition, so any kind of manager counts as present
PlatformTransactionManagerthe interface for blocking code, with getTransaction, commit and rollbackTransactionTemplate and @Transactional on ordinary methods
ReactiveTransactionManagerthe interface for reactive codenothing 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=false
Reading the code
spring.modulith.events.mongodb.transaction-management.enabled=false

With true, Spring Modulith registers a MongoTransactionManager and a TransactionTemplate. 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:

MongoDB .NET driver
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;
});
The order service
/** 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
}
Reading the code
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.

@Transactional

A proxy around OrderService starts 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); // load

The repository takes no session. MongoTemplate finds 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.

Gotcha

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 -> {
Reading the code
StockAvailability availability = stock.reserve(orderId, items); // outside the transaction

The 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 -> {

execute begins a transaction, runs the lambda, and commits. It returns the value of the lambda. status is the TransactionStatus, 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:

CaseWhat happened
the defaultspropagation REQUIRED, and the default isolation and timeout
execute with a valuethe 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 callbackthe transaction rolled back, and the same exception reached the caller
a checked exception, thrown in the callbackthe transaction rolled back, and the caller got UndeclaredThrowableException
an execute inside another executethe 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.

Gotcha

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#
Means
A method that joined the transaction threw, and the caller caught the exception and returned normally. Spring could not commit.
Fix
Let the exception leave the transaction. If a part must fail on its own, give it its own transaction, and only for work that does not share the caller's documents.

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 {
Reading the code
public abstract sealed class DomainException extends RuntimeException

Every domain exception is a RuntimeException, so every broken rule rolls the transaction back. No method needs rollbackFor.

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#
Means
Code published domain events on a thread with no transaction. The outbox row would never be sent.
Fix
Publish from a use case that runs in a transaction, with @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:

CaseWhat MongoDB 8.0.34 answeredWhat Spring threw
two transactions insert the same _id, and the first has not committederror 112, WriteConflict, at once, with the label TransientTransactionErrorDataIntegrityViolationException
the second transaction started before the first committed, and wrote after iterror 112, WriteConflictDataIntegrityViolationException
the first committed before the second transaction beganerror 11000, a duplicate key, with no labelDuplicateKeyException
a write outside any transaction, against an open transactionthe write waited until the other transaction committed, and then failed with 11000DuplicateKeyException
two transactions update the same versioned documenterror 112, before the version checkDataIntegrityViolationException

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;
    }
Reading the code
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 mongo

This is a type pattern. When the cause is a MongoException, it is bound to the variable mongo. C# writes the same test as cause 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.

Gotcha

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 conflictWhat the service doesWhat happens next
an agent, over HTTPthe platform's handler answers 409, with the code CONCURRENT_UPDATEthe agent reloads the order and tries again
a message from SQSthe inbox throws ConcurrentDeliveryException, and the transaction rolls backSQS 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.'#
Means
Another transaction wrote the same document first. MongoDB aborted this transaction, and Spring translated the error to DataIntegrityViolationException.
Fix
Answer 409 and let the caller reload, or run the whole transaction again for an error with the label 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:

LimitWhat AWS statesWhat it means for the order service
engine versiontransactions need engine 4.0 or laterthe shop's clusters run engine 8.0
elastic clustersno ACID transactions at allthe service needs an instance-based cluster
durationa transaction may run for one minuteno HTTP call inside a transaction, as in place
sizethe transaction log must be less than 32 MBone order with a few notes is far below it
document locksa write lock on a document times out after one minute, and this cannot be configureda long transaction can block other writers for that long
sessionsa session times out after 30 minutesMongoTransactionManager opens a session when a transaction begins
new collectionsa transaction cannot create a collection, or query one that does not existevery collection must exist before its first transaction
cursorsno cursors within a transactionread single documents or small results inside a transaction
causal consistencynot supported, with error 303Spring opens a causally consistent session, which was never tested on a cluster
isolationa lower read concern is upgraded to snapshot, and a lower write concern to majoritythe 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.

Gotcha

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);
            }
        }
    }
}
Reading the code
if (!mongo.collectionExists(collection)) {

collectionExists asks 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: 30
Reading the code
command: ["--replSet", "rs0", "--bind_ip_all"]

MongoDB starts as a member of the replica set rs0. --bind_ip_all lets 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=true for 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.
    }
}
Reading the code
public class NoOpTransactionManager extends AbstractPlatformTransactionManager {

AbstractPlatformTransactionManager is 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.

Check yourself
1
An 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?
The commit fails with 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.
2
An 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?
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.
3
What does spring.modulith.events.mongodb.transaction-management.enabled=false do in the platform?
It stops Spring Modulith from registering its own MongoTransactionManager and TransactionTemplate. The default is true. The platform's mongo package already registers the manager, so exactly one place creates it.
4
On Amazon DocumentDB, why must a collection exist before the first transaction that writes to it?
AWS states that a transaction cannot create a collection, or query one that does not exist, as of October 2026. On a new environment, the first save inside a transaction would fail. MongoDB 8.0 allows it, so local tests do not show the problem. Create the collections first, for example in a migration.
5
TranslateWrite 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.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 attributeValue in the shopWhat it does
the MongoTransactionManager beanfrom PlatformMongoAutoConfigurationmakes @Transactional and TransactionTemplate start MongoDB transactions
spring.modulith.events.mongodb.transaction-management.enabledfalse, from the platformSpring Modulith registers no second transaction manager
@Transactional propagationREQUIRED, the defaultjoins a running transaction, or starts one
@Transactional rollbackFornot setan unchecked exception rolls back, and every domain exception is unchecked
@Transactional isolationnot setDocumentDB upgrades the read concern of a transaction to snapshot
TransactionTemplatethe bean that Boot addsREQUIRED, and the default isolation and timeout

The exceptions of a failed transaction, and where the service handles each:

ExceptionCauseHandled by
OptimisticLockingFailureExceptiona stale @Version outside a transactionthe platform's handler, as 409
DataIntegrityViolationException with error 112a write conflict inside a transactionthe platform's handler as 409, or the inbox as a redelivery
DuplicateKeyExceptionan insert of an existing _id or unique keythe inbox as a redelivery, and otherwise a 500
UnexpectedRollbackExceptiona caught exception of a joined methodnothing, because the service never catches one inside a transaction
TransactionSystemExceptiona commit that failednothing, so the caller gets a 500

Calling other services#

Talking to other services · Chapter 19· 35 min read· Quick reference

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.

KindC#JavaNote
newno equivalentthe stock call before the transactionthe call runs before the transaction, so a slow stock service never holds a database transaction open Why
twistan IStockChecker interface in the application layerStockChecker in application/portthe use case asks in its own types, so a unit test replaces the stock service with a mock Why
twista Refit interfacean @HttpExchange interfaceSpring builds a proxy that turns each method call into an HTTP request Why
twistrecords for the other service's JSONReservationRequest and ReservationResponse in client/dtotheir 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
newno equivalentStockAdapter and its static translate methodthe adapter is the anti-corruption layer, and an answer it does not know is a bug that it reports Why
twistIHttpClientFactory and a named clientHttpClientFactory.create(type, properties)the platform builds every client the same way, with timeouts, tracing and a token Why
trapno equivalentRestClient.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
twistHttpClient.Timeoutconnect-timeout and read-timeout in clients.stock.*the JDK client limits the connection, and the request factory limits the wait for the reply Why
twistno equivalentspring.http.clients.read-timeoutthe Boot 4 keys have a plural clients, and the singular keys are deprecated since 4.0.0 Why
twista configuration class per typed clientStockClientConfig with two @Bean methodseach client binds its own settings prefix and creates its bean in one small class Why
twista DelegatingHandler that adds a tokentoken-registration-id in the client's settingsthe platform adds the service's own client-credentials token to every call Why
newPolly's retry and circuit breaker strategiesResilientCalls, with one registry for eacha retry repeats a short failure, and a breaker stops calls to a service that keeps failing Why
trapMaxRetryAttempts = 2 in PollymaxAttempts = 3 in Resilience4jResilience4j counts the first call, so 3 attempts are only 2 retries Why
twistMinimumThroughput = 10 in PollyminimumNumberOfCalls = 10the breaker computes no failure rate before 10 calls, whatever the size of its window Why
trapno equivalentrecordException(...) alonean exception that the predicate does not record counts as a success, so ignoreExceptions keeps 4xx out of the rate Why
newno equivalentisTransient: ResourceAccessException or HttpServerErrorExceptiononly timeouts, failed connections and 5xx answers are retried, and a 4xx reaches the adapter unchanged Why
twistAddRetry, then AddCircuitBreakerRetry.decorateSupplier(retry, CircuitBreaker.decorateSupplier(...))the retry is outside the breaker, so the breaker counts every attempt Why
newno equivalentDownstreamServiceException, answered with 503a call that fails after its retries, or that the breaker blocks, means "dependency unavailable" Why
newno equivalentcallWithRetry for idempotent calls, and call for the othersa POST that timed out may have worked, so only a call with an idempotency key is retried Why
newno equivalentthe order ID as the Idempotency-Key headerthe ID is chosen before the call, so a repeated reservation holds the stock once Why
twista pipeline for each named clientone circuit breaker for each dependency namea failing stock service never blocks the calls to another service Why
twistAddStandardResilienceHandler()ResilientCalls, written once in the platform with Resilience4j coreplain Java, so it works the same on every Boot version and needs no starter Why
twistPolly's ResiliencePipelineBuilderthe core decorators of Resilience4jboth wrap one call in a retry and a breaker, and both put the retry outside Why
twistPolly's AddRetry@Retryable from Spring Framework 7it retries, but it has no circuit breaker, and maxRetries counts only the retries Why
twistWireMock mappings, which work for any languagethe stock stubs in local-environmenta stub with a lower priority number wins, so special products override the default answer Why
twista test with a fake HttpMessageHandlerStockAdapterTest against the JDK's HttpServerthe 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);
    });
}
Reading the code
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 transaction

stock is 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.place then decides what the answer means. For the TransactionTemplate, see the chapter Transactions in depth.

One call to the stock service passes through five pieces. Each piece has one job:

APPLICATION OrderService.place the use case, in the order service's words StockChecker, the port reserve(OrderId, items) returns StockAvailability CLIENT CODE StockAdapter builds their request, translates their answer PLATFORM ResilientCalls.callWithRetry retry outside, circuit breaker inside StockHttpClient, generated by Spring the stock service POST /stock/reservations Bearer token, Idempotency-Key
The pieces of one stock call. OrderService calls the StockChecker port. StockAdapter implements the port and calls ResilientCalls, which wraps the call to StockHttpClient in a retry and a circuit breaker. StockHttpClient sends POST /stock/reservations with a bearer token and an Idempotency-Key header.

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#

C#
public interface IStockChecker
{
    Task<StockAvailability> ReserveAsync(OrderId order, IReadOnlyList<OrderItem> items);
}
Java 25
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);
}
Reading the code
StockAvailability reserve(OrderId order, List<OrderItem> items);

The method takes and returns types of the order service. OrderId, OrderItem and StockAvailability belong to the domain model. Nothing here says HTTP, JSON or sku.

The stock service keeps the hold until it sees {@code OrderPlacedV1}, or releases it

The 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#

Refit
public interface IStockHttpClient
{
    [Post("/stock/reservations")]
    Task<ReservationResponse> ReserveAsync([Header("Idempotency-Key")] string idempotencyKey,
                                           [Body] ReservationRequest request);
}
Spring HTTP interface
@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);
}
Reading the code
@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#

C#
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Skip)]
public record ReservationResponse(string Result, List<string> ShortSkus);
Java 25
@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());
        };
    }
}
Reading the code
class StockAdapter implements StockChecker {

The class is package-private, and @Component makes it a bean. Spring injects it wherever a StockChecker is 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 ProductCode becomes the text that the stock service calls a sku.

() -> http.reserve(order.value(), request));

The call is passed as a lambda, and ResilientCalls decides when to run it. Its type is Supplier<ReservationResponse>. See the section The platform's HttpClientFactory.

return switch (response.result()) {

This is a switch expression on a string. Each case with -> gives the value of the whole switch, as an arm of a switch expression does in C#.

default -> throw new IllegalStateException("Unknown stock reservation result: " + response.result());

A default arm 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::new is a method reference to the constructor. It turns each sku into a ProductCode of 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.

Note

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);
}
Reading the code
public <T> T create(Class<T> clientType, HttpClientProperties properties) {

A generic method. Class<T> is the interface to implement, such as StockHttpClient.class, and the method returns a T.

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());

JdkClientHttpRequestFactory lets Spring's RestClient send 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()))

RestClientAdapter lets the proxy factory send requests through the finished RestClient.

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

TypeWhat it doesWhere its settings come from
HttpServiceProxyFactoryturns the interface into a proxy, one request for each method callthe annotations of the interface
RestClientsends the request, writes and reads JSON, runs the interceptorsBoot's builder, plus the base URL and the token interceptor
JdkClientHttpRequestFactoryhands each request to the JDK's HttpClient, with a read timeoutthe 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:

JavaC#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);
}
Reading the code
ObjectProvider<RestClient.Builder> restClientBuilders,

An ObjectProvider asks the container for a bean each time it is called, not once. For ObjectProvider, see the chapter Wiring the service: the container in depth.

return new HttpClientFactory(restClientBuilders::getObject, tokenManager);

restClientBuilders::getObject is a method reference, used as the Supplier. Each call to get() 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:

PredictThree ways to build a RestClient#
b1 is Boot's RestClient.Builder bean. Each line sends one request to the recording server. Which of the three requests carry a traceparent header?
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
Only the request through 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.

Gotcha

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#

C#
builder.Services.AddHttpClient("stock", c =>
{
    c.BaseAddress = new Uri(stockUrl);
    c.Timeout = TimeSpan.FromSeconds(3);
});
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-service
Reading the code
base-url: ${STOCK_SERVICE_URL}

The URL comes from an environment variable, so each environment names its own stock service.

read-timeout: 3s

The 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);
Reading the code
@Validated

Spring validates the object when it binds the settings. A missing base-url stops the service at startup, not at the first call.

private URI baseUrl;

java.net.URI holds 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 3s binds to a Duration.

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:

SituationTimeout that firesWhat the caller got
the address cannot be reached, here 10.255.255.1connect timeout, after 507 msResourceAccessException, caused by HttpConnectTimeoutException
the server is reached, and answers after 3 secondsread timeout, after 1071 msResourceAccessException, 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 keyDeprecated key, since 4.0.0What it does
spring.http.clients.connect-timeoutspring.http.client.connect-timeoutthe time to wait for a connection, for every client Boot builds
spring.http.clients.read-timeoutspring.http.client.read-timeoutthe time to wait for an answer, for every client Boot builds
spring.http.clients.imperative.factoryspring.http.client.factorywhich HTTP library the blocking clients use
spring.http.serviceclient.<group>.read-timeoutnonethe 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);
    }
}
Reading the code
@ConfigurationProperties("clients.stock")

On a @Bean method, 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 stockClientProperties matches the bean name. With a second client, two beans of type HttpClientProperties exist, and the name chooses one.

@Configuration(proxyBeanMethods = false)

No method of this class calls another @Bean method, 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:

FileExampleWhat it holds
the portapplication/port/StockCheckerthe question, in this service's types
the HTTP interfaceclient/StockHttpClientthe other service's endpoints, as annotated methods
the DTOsclient/dto/ReservationRequest, ReservationResponsetheir JSON, in their words
the adapterclient/StockAdapterthe call through ResilientCalls, and the translation
the configurationclient/StockClientConfigthe 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#

C#
builder.Services.AddHttpClient("stock")
    .AddHttpMessageHandler<ClientCredentialsTokenHandler>();
Java 25
/**
 * 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());
}
Reading the code
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 retries

Three 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:

StateWhat happens to a callWhen the state changes
CLOSEDthe call runs, and its result is countedthe failure rate reaches 50% of the last 20 calls, after at least 10 calls
OPENthe call does not run, and CallNotPermittedException is thrown at oncethe wait of 30 seconds ends
HALF_OPENa few test calls run, 10 by defaultthe 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:

PredictA stock service that always answers 503#
The retry has maxAttempts = 3. How many requests reach the stock service before the adapter gives up?
@Test
void retriesServerErrorsThenReportsTheServiceAsUnavailable() {
    status = 503;
    body = "{}";

    assertThatThrownBy(() -> adapter.reserve(OrderId.newId(), TWO_MUGS)).isInstanceOf(DownstreamServiceException.class);
Show the answer
3 requests: the first call and 2 retries. The test's next line checks it with 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 farStateFailure rate that the breaker reported
8CLOSED-1.0, which means not computed yet
9CLOSED-1.0
10OPEN100.0
the next callOPENthe call did not run, and CallNotPermittedException was thrown
Gotcha

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.

PredictA breaker that records only transient errors#
transientFailure accepts only ResourceAccessException and HttpServerErrorException. Fifteen calls each throw a 404. What does the breaker count?
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
15 successful calls and 0 failed calls. The state is 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:

FailureException from RestClientTransientRetried, and counted by the breaker
connect timeout, refused connection, read timeoutResourceAccessExceptionyesyes
503, 502, 500 and every other 5xxHttpServerErrorExceptionyesyes
400, 401, 404 and every other 4xxHttpClientErrorExceptionnono, and the breaker ignores it
an answer that the adapter does not knowIllegalStateException, from translatenono, 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#

Polly 8
var pipeline = new ResiliencePipelineBuilder()
    .AddRetry(new RetryStrategyOptions { ShouldHandle = transient, MaxRetryAttempts = 2 })
    .AddCircuitBreaker(new CircuitBreakerStrategyOptions { ShouldHandle = transient })
    .Build();
Java 25
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);
}
Reading the code
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 decorateSupplier wraps a Supplier in another Supplier. 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.

RETRY: UP TO 3 ATTEMPTS CIRCUIT BREAKER: COUNTS EACH ATTEMPT http.reserve(...) one HTTP request open: CallNotPermittedException, which the retry does not repeat
The order of the decorators in callWithRetry. The retry wraps the circuit breaker, which wraps the HTTP call. The breaker counts each of the 3 attempts. When it is open, CallNotPermittedException is thrown, and the retry does not repeat it.

The order changes what the breaker counts. A run for this book compared the two orders, with 3 attempts:

OrderWhat the breaker counted for one call that always failed
Retry(CircuitBreaker(call)), as in the platform3 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);
    }
}
Reading the code
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 catch with when (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:

DependencyUsed forWhen it is down
stock serviceplacing an orderonly POST /orders answers 503, and everything else works
token issuerchecking tokens, and the stock call's own tokenrequests get 401, and placing an order gets 503
SNSpublishing eventsevents wait in the outbox and are resent
SQSpaymentspayments 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 callIdempotentUse
GET of a product, or of a priceyescallWithRetry
POST with an idempotency key that the other service honoursyescallWithRetry
PUT that replaces a whole resourceusually yescallWithRetry, after checking the other service's contract
POST without a key, such as sending an emailnocall

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.

Pragmatic choice: The stock hold before the transaction

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();
Reading the code
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:

PointPolly 8.8.0Resilience4j 2.4.0, as the platform uses it
retry countMaxRetryAttempts = 2 makes 3 callsmaxAttempts = 3 makes 3 calls
retry defaultsa constant delay of 2 seconds, without jitter3 attempts, with a fixed wait of 500 ms
breaker windowa time, SamplingDuration, 30 seconds by defaulta count of calls, here the last 20
breaker minimumMinimumThroughput, 100 by defaultminimumNumberOfCalls, 100 by default and 10 in the platform
breaker defaultsFailureRatio 0.1, BreakDuration 5 secondsa threshold of 50%, and 60 seconds open
orderthe first strategy added is the outermostthe 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:

ChoiceHow a call is protectedWhat it does well
Resilience4j core, as in the platforma 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 methodthe 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 @EnableResilientMethodsno 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:

  1. 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.
  2. 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.
  3. No proxy. The annotations of the starter and @Retryable work 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 on ResilientCalls always works.
  4. The choice is in the code. callWithRetry and call are 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"] }
  }
}
Reading the code
"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 sku is OUT-OF-STOCK. The stub matches when the path finds one.

The local environment runs WireMock in Docker, with four stubs for the stock service:

StubPriorityAnswer
a line with the product OUT-OF-STOCK1200 INSUFFICIENT_STOCK, so the agent gets 422
a line with the product SERVICE-DOWN1503, so the retries run and the agent gets 503
any reservation with a token and an Idempotency-Key10200 RESERVED
any reservation without a token20401

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));
}
Reading the code
server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);

com.sun.net.httpserver.HttpServer is 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:

TestWhat it proves
translatesAReservationIntoOurModelRESERVED becomes allAvailable(), and the unknown field reservationId is ignored
translatesMissingStockWithTheProductsINSUFFICIENT_STOCK becomes unavailable(...) with the product codes
sendsTheServicesTokenAndTheOrderIdAsTheIdempotencyKeythe request carries Bearer token-1 and the order ID as the key
twoDifferentUsersShareOneServiceTokentwo agents cause one token request
retriesServerErrorsThenReportsTheServiceAsUnavailablea 503 is tried 3 times, then becomes DownstreamServiceException
refusesAnAnswerItDoesNotKnowthe 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.

Check yourself
1
An 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?
The order ID is the idempotency key. The use case chooses it before the call, and every attempt sends it in the 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.
2
An expert asksWhy does the platform build each client from Spring Boot's injected RestClient.Builder, and ask for a new one for each client?
Boot's builder carries the tracing setup, so each request sends a 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.
3
TranslateWrite 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}")
};
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:

SettingPlatform defaultResilience4j defaultWhat it does
retry.max-attempts33the attempts in total, including the first call
retry.initial-interval200ms500 ms, fixedthe wait before the first retry
retry.multiplier2.0none, because the default wait is fixedhow much each later wait grows, with a random spread
circuit-breaker.failure-rate-threshold5050the failure rate in percent that opens the breaker
circuit-breaker.sliding-window-size20100how many recent calls the rate is computed over
circuit-breaker.minimum-number-of-calls10100no rate is computed before this many calls
circuit-breaker.wait-duration-in-open-state30s60 show long the breaker refuses calls before test calls

The settings of one client, under clients.<name>:

SettingDefaultWhat it does
base-urlnone, and requiredthe root URL of the other service, checked at startup
connect-timeout2sthe time to wait for a connection
read-timeout5sthe time to wait for the answer once connected, 3s for the stock service
token-registration-idnonethe Spring Security client registration whose token every call carries

The exceptions of a call, and where each one ends:

ExceptionThrown byThe caller gets
ResourceAccessExceptionRestClient, for a timeout or a failed connection503, after the retries
HttpServerErrorExceptionRestClient, for a 5xx answer503, after the retries
CallNotPermittedExceptionthe open circuit breaker503, at once
HttpClientErrorExceptionRestClient, for a 4xx answerwhatever the adapter decides
IllegalStateExceptionthe adapter, for an unknown answer500

Publishing events: the outbox#

Talking to other services · Chapter 20· 31 min read· Quick reference

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.

KindC#JavaNote
newno equivalentthe event stored in the change's transaction, then senta direct send to SNS can lose an event or announce a change that rolled back Why
twista record that implements IDomainEventa record that implements DomainEventa domain event is internal and may change with the model, so it never leaves the service as it is Why
twista versioned message class, such as OrderShippedV1a record that implements IntegrationEventan integration event is a contract with other teams, so it changes carefully Why
twista switch expression with type patternsswitch (event) { case OrderShipped shipped -> ... }one mapper per feature decides which domain events leave, and in which shape Why
newno equivalentOutboxDomainEventPublisherthe platform publishes each domain event and its integration events through Spring's event bus Why
trapno equivalentpublishEvent outside a transactionthe outbox row is stored but never sent, so the platform's publisher refuses with IllegalStateException Why
newthe outbox table of MassTransit or NServiceBusSpring Modulith's event publication registryone document for each event and transactional listener, written in the publisher's transaction Why
twistno equivalentspring.modulith.events.completion-mode=deletea sent row is deleted, so the collection holds only what is still to be sent Why
twistPublish(message) through the bus outbox@Externalized(OrderTopics.ORDER_EVENTS) on the recordSpring Modulith sends the event to the SNS topic after the commit, on another thread Why
trapno equivalentthe topic name order-eventsSpring Cloud AWS finds the ARN by calling CreateTopic, so the role needs sns:CreateTopic and a typo creates a topic Why
twistMessageId set by the buseventId, created once in the mappera resend keeps the ID, so a consumer's inbox can detect the duplicate Why
newno equivalentthe event ID and the order ID in every log linethe SNS message carries no trace ID by default, so flows are followed by their IDs Why
newno equivalentOutboxResender and IncompleteEventPublicationsa scheduled job sends again the rows that are older than five minutes and still not sent Why
trapno equivalenta row left RESUBMITTED by an instance that diedthe resender skips it for ever, unless the staleness monitor is turned on, as the platform does Why
twistno equivalentShedLock's lock in MongoDBevery instance schedules the resender, but only one runs it at a time Why
newno equivalent@ApplicationModuleListenerit runs after the commit in its own transaction, and its outbox row brings a retry after a failure Why
twista MediatR INotificationHandler@EventListenerit runs at once on the publisher's thread, inside its transaction, with no outbox row Why
newno equivalentat least once, never for a rolled-back changean event can be sent twice, so every consumer must be idempotent Why
twistUsingAmazonSqs against a local emulatorthe journey test with Floci, and the outbox collection empty at the endturning externalisation off in a test also stops the outbox rows Why
newno equivalentthe externaliser of Spring Cloud AWS 4.1it is built on a class that Spring Modulith 2.1 deprecated for removal, so drain the outbox before that upgrade Why
twistAddMongoDbOutbox(o => o.UseBusOutbox())the platform's outbox beans, from one dependencyMassTransit 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.

ONE TRANSACTION, ON THE REQUEST THREAD orders the order, now SHIPPED event_publication OrderShippedV1, and the listener that sends it after the commit ANOTHER THREAD, OUTSIDE THE TRANSACTION the externaliser sends the event then the row is deleted (completion-mode=delete) SNS topic order-events the send failed: the row stays OutboxResender sends it again after 5 minutes
The outbox in the order service. Inside one transaction, the use case saves the order and Spring Modulith writes a row to event_publication. After the commit, another thread sends the event to the SNS topic order-events and deletes the row. If the send fails, the row stays, and OutboxResender sends it again.

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;
}
Reading the code
List<DomainEvent> raised = order.pullEvents();

The aggregate collected its events while its methods ran, such as OrderShipped from ship. pullEvents returns them and empties the list. For how the aggregate collects events, see the chapter The domain model in plain Java.

events.publish(raised);

events is the port DomainEventPublisher. 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#

C#
public record OrderShipped(OrderId OrderId, DateTimeOffset OccurredAt) : IDomainEvent;
Java 25
/** 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:

QuestionDomain eventIntegration event
Who reads itcode in this serviceother services, owned by other teams
Its typesvalue objects of the domain, such as OrderIdplain types, such as String and Instant
Its namethe business fact, OrderShippedthe fact and a version, OrderShippedV1
When it may changewhenever the model changesonly by rules that keep consumers working
Where it is declaredorder/domain/eventorder/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#

C#
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
};
Java 25
@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();
        };
    }
}
Reading the code
return switch (event) {

A switch on an object, with a type pattern in each arm. It is like a switch expression 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 to shipped, and builds the integration event from it. newEventId() is a static method of IntegrationEvent, imported with a static import.

default -> Optional.empty();

Every other event stays inside the service. Java needs the default here, because DomainEvent is 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 Optional means "this event does not leave the service".

The order feature raises five domain events. The mapper publishes three of them:

Domain eventLeaves the service asWhy
OrderPlacedOrderPlacedV1the stock service keeps its hold on the products when it sees it
OrderShippedOrderShippedV1other services act on a shipment
OrderCancelledOrderCancelledV1other services must undo what the order started
LinePickednothingno other service needs to know which line was picked
PaymentRecordednothingthe 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);
    }
}
Reading the code
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 publisher holds Spring's ApplicationEventPublisher. 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::stream turns an empty Optional into 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:

PredictAn externalised event published with no transaction#
OrderPlacedV1 carries @Externalized(&quot;order-events&quot;). No transaction is active. What does the outbox hold afterwards, and does the event reach SNS?
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
The outbox holds a row with status 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>
Reading the code
<artifactId>spring-modulith-events-api</artifactId>

The annotations and interfaces that code uses, such as @Externalized, @ApplicationModuleListener and IncompleteEventPublications.

<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 @Externalized events 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. @ApplicationModuleListener and the SNS sender are transactional listeners. A plain @EventListener is 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. OrderPlaced has a listener that counts orders, so it gets a row for that listener. OrderPlacedV1 gets 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:

FieldWhat it holds
_ida UUID for the publication
publicationDatewhen the event was published, inside the transaction
listenerIdthe listener that must receive the event, as a method signature
eventthe event itself, as an embedded document, not as a JSON string
statusPUBLISHED, PROCESSING, COMPLETED, FAILED or RESUBMITTED
completionDatewhen the listener finished, empty until then
completionAttemptshow many times a listener tried, 1 at first
lastResubmissionDatewhen 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:

  • _id is a universally unique identifier (UUID) in the standard binary form, which mongosh prints as UUID(...). That form comes from the platform's setting spring.mongodb.representation.uuid=standard.
  • The event is an embedded document with its own _class, the full name of the record. Its Instant became 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 OrderPlacedV1 has no other transactional listener.
  • The row has no completionDate field at all, because the send never completed. With completion-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=true
Reading the code
spring.modulith.events.completion-mode=delete

The default is update, which keeps each completed row with its completion date. delete removes it. archive moves it to event_publication_archive. In a run with delete, completed rows vanished, and failed or unsent rows stayed.

spring.modulith.events.mongodb.transaction-management.enabled=false

The default is true. Spring Modulith then registers its own MongoTransactionManager. The platform's mongo package registers one already, so this line keeps one manager.

spring.modulith.events.republish-outstanding-events-on-restart=false

This 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=true

The default too. Externalisation is the step that sends @Externalized events 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) {
    }
}
Reading the code
@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 word sku stays 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.

Gotcha

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:

  • SnsTemplate uses a CachingTopicArnResolver around a DefaultTopicArnResolver. A service may replace them with a resolver bean of its own.
  • DefaultTopicArnResolver passes a value that starts with arn: through. For a name, it calls CreateTopic. That call returns the ARN of a topic that exists already.
  • The result is cached for each name.
Gotcha

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();
    }
}
Reading the code
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 UUID as 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 OrderShippedV2 next to V1, until every consumer has moved.
  • Plain types only, such as String and Instant. A consumer in another language can read them.
  • IDs and facts, and no personal data beyond what a consumer needs. OrderPlacedV1 carries 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 eventId into 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);
}
Reading the code
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 IncompleteEventPublications sends 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.

Gotcha

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=PT15M
Reading the code
spring.modulith.events.staleness.resubmitted=PT15M

A row that has been RESUBMITTED for 15 minutes goes back to FAILED. 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 RESUBMITTED too. If the monitor marked them FAILED, 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:

PredictTwo listeners for two domain events#
Each event is published inside a transaction that then commits. For each listener, how many rows appear in the outbox, and when and on which thread does it run?
@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
The @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 runsat once, inside publishEventafter the commit
Which threadthe publisher's threada thread of Spring's task executor, such as task-1
Which transactionthe publisher's transaction, if anya new one of its own
An outbox rownoyes, one for each event
After a failurethe use case fails toothe 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.

Note

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 whenWhat the consumers see
the use case throws, and the transaction rolls backno event, because the row rolled back with the change
the process dies before the commitno event, for the same reason
SNS is down after the committhe event, later, when the resender sends it
the process dies after the send, before the row is marked completethe event twice
two instances resend in one momentthe 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());
Reading the code
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 is OrderShippedV1, 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:

TestWhat it proves
OutboxDomainEventPublisherTestthe refusal outside a transaction, and each domain event followed by its integration event
OrderIntegrationEventMapperTestwhich events leave, their plain types, and that internal events stay inside
OutboxResenderTestthe resend with the five-minute age, and the skip when the lock is taken
OutboxResenderLockITtwo 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.

Gotcha

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.

Pragmatic choice: An outbox sender on a deprecated class

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#

MassTransit 8
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));
});
Java 25
@AutoConfiguration(after = PlatformMongoAutoConfiguration.class)
@ConditionalOnProperty(name = "platform.messaging.enabled", havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(MessagingProperties.class)
public class PlatformMessagingAutoConfiguration {
Reading the code
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:

LibraryLicenceNote
MassTransit v9commercial, source-availablea paid licence is needed once annual revenue exceeds one million US dollars
MassTransit v8Apache 2.08.5.12 came out on 21 October 2026, and an archived notice ends official maintenance after 2026, with no exact date
NServiceBuscommercial, free for developmentits 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.

Check yourself
1
An expert asksWhy can the order service not send OrderShippedV1 to SNS right after it saves the order, even from an after-commit callback?
The database and SNS share no transaction. Sending before the commit announces a change that may roll back. Sending after the commit loses the event if the process dies between the commit and the send. The outbox writes the event in the change's transaction, and sends it later, so the change and the event are stored together.
2
An expert asksWhy does the mapper create the eventId, and not the code that sends the event?
The mapper runs inside the use case's transaction, so the ID is stored in the outbox row with the event. Every resend reads the same row and sends the same ID. A consumer's inbox then recognises the duplicate. An ID made at send time would differ on each resend, and every duplicate would look new.
3
An instance of the order service dies just after the resender claimed an outbox row. What happens to the row, and what brings it back?
The row stays 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.
4
TranslateWrite 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 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:

SettingPlatform valueLibrary defaultWhat it does
spring.modulith.events.completion-modedeleteupdatewhat happens to a row once its listener has run
spring.modulith.events.mongodb.transaction-management.enabledfalsetruewhether Spring Modulith registers its own MongoDB transaction manager
spring.modulith.events.republish-outstanding-events-on-restartfalsefalsewhether each instance resends every unfinished row at startup
spring.modulith.events.externalization.enabledtruetruewhether @Externalized events are sent, and get their rows
spring.modulith.events.staleness.resubmittedPT15Mnot set, so no monitorafter this time, a RESUBMITTED row moves back to FAILED
spring.modulith.events.staleness.check-intervalnot setone minutehow often the staleness monitor checks
platform.messaging.outbox.resend-enabledtruenone, a platform settingwhether the resender and its scheduling exist
platform.messaging.outbox.resend-intervalPT1Mnone, a platform settingthe pause between two resend runs
platform.messaging.outbox.resend-older-thanPT5Mnone, a platform settingthe age a row must reach before it is resent
platform.messaging.outbox.lock-at-most-forPT5Mnone, a platform settinghow long a dead instance can hold the lock

The statuses of an outbox row in Spring Modulith 2.1:

StatusMeaning
PUBLISHEDwritten with the change, and not yet handled
PROCESSINGa listener is running for the row at this moment
COMPLETEDthe listener finished, and with completion-mode=delete the row is gone
FAILEDthe listener threw, so the resender may take the row
RESUBMITTEDthe resender took the row, and a dead instance can leave it here

Consuming events: SQS and the inbox#

Talking to other services · Chapter 21· 27 min read· Quick reference

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.

KindC#JavaNote
newno equivalentthe inbox, keyed by consumer and event IDa standard queue delivers at least once, so each event is handled once by the record of its ID Why
twistIMessageHandler<PaymentReceivedV1> from AWS.Messaging@SqsListener("${messaging.queues.payment-received}")Spring Cloud AWS polls the queue, converts the JSON body, and calls the method Why
twista record for the other service's eventPaymentReceivedV1, this service's own copyit holds only the fields the service uses, and ignores the others Why
trapno equivalenta subscription without raw message deliverythe record is built from the SNS envelope with every field null, and the message is deleted Why
newno equivalentObjects.requireNonNull in the record's compact constructora wrong message fails, is retried, and ends in the dead-letter queue, where someone sees it Why
twistMessageProcessStatus.Success()a listener method that returns normallythe container acknowledges on success, and an acknowledgement deletes the message Why
trapthe framework extends the visibility timeoutthe visibility timeout of the queue, 30 secondsthe container deletes in batches and extends nothing, so a short timeout delivers a message twice Why
twistMessageProcessStatus.Failed()an exception from the listenerthe message is not deleted, comes back after the timeout, and moves to the dead-letter queue after 5 receives Why
trapno equivalentthe default queue-not-found strategy, CREATEa missing queue is created with no dead-letter queue, so the platform sets fail Why
newthe inbox of MassTransit's consumer outboxInbox.process(consumer, event, handler)in one transaction it skips a known key, or runs the handler and inserts the key Why
trapno equivalentthe consumer name order-service.payment-receiveda new name makes every old event look new, so the name never changes Why
twistMongoCommandException with code 112, in the .NET driver tooDataIntegrityViolationException with a write conflict, code 112two copies inside two transactions collide before a commit, so the inbox catches both errors Why
twistno equivalenta TTL index on processedAt, 14 daysMongoDB deletes old keys by itself, and 14 days is the longest that SQS keeps a message Why
newno equivalentInboxMessageHandler, with a final process and an abstract handleevery listener gets the inbox, the log context and the system actor the same way Why
twista thin consumer classhandle builds value objects and calls one use casethe listener holds no business rule, and the use case joins the inbox's transaction Why
twistno equivalentOrder.recordPayment ignores a payment that is not laterthe domain is idempotent too, a second defence when the inbox key is gone Why
twistILogger.BeginScopeLogContext.with(...) in the base classevery log line of a message carries the event ID and the order ID Why
twistan integration test against a real queuethe journey test sends the payment twice through Flocithe order is paid once, and a message that is not the event reaches the dead-letter queue Why
twistan SQS FIFO queue, used through AWSSDK.SQSa FIFO queue and topicexactly-once processing within a window, at a lower throughput, and the order service does not need it Why
twistAddAWSMessageBus with AddSQSPollerSpring Cloud AWS's listener containerboth 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")));
Reading the code
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#

AWS.Messaging
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();
    }
}
Spring Cloud AWS
/** 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);
}
Reading the code
@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:

HeaderWhat it holds
Sqs_Msa_ApproximateReceiveCounthow many times SQS has delivered this message, the first delivery included
Sqs_QueueName and Sqs_QueueUrlthe queue the message came from
Sqs_ReceiptHandlethe handle that a delete or a visibility change needs
Sqs_VisibilityTimeoutthe visibility timeout of the receive
Sqs_RawMessageIdthe 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:

PredictAn SNS envelope on a queue that expects the event#
The listener expects PaymentReceivedV1. The queue delivers the SNS envelope, with the event as a string inside Message. What does the listener receive, and what happens to the message?
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
}
Reading the code
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/null

With 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");
}
Reading the code
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.requireNonNull throws a NullPointerException with 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.

Note

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 methodWhat the container doesWhat SQS does
returns normallydeletes the message, in a batchnothing more, because the message is gone
throws an exceptionnothing, so no delete and no visibility changemakes 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:

DefaultValueWhy it matters
messages per poll10one receive call returns up to 10 messages
poll timeout10 seconds, which is long pollingan empty queue costs few requests
messages in flight per queue10at most 10 messages of one queue are handled at once
listener shutdown timeout20 secondsthe time that running handlers get when the service stops
message attributesAllevery 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.

Gotcha

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
}
Reading the code
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. VisibilityTimeout is 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();
Reading the code
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:

  1. The handler throws, and the inbox's transaction rolls back. The order is unchanged, and no inbox key is written.
  2. The container does not acknowledge, so the message stays in the queue.
  3. After the visibility timeout, SQS delivers it again. The header Sqs_Msa_ApproximateReceiveCount grows by one.
  4. After 5 receives, SQS moves the message to order-service-payment-received-dlq.
  5. 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=fail

The comment names the danger. The property queue-not-found-strategy takes a value of the type QueueNotFoundStrategy.

Gotcha

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;
    }
}
Reading the code
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 as Action<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 _id in processed_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.

SQS message PaymentReceivedV1 ONE TRANSACTION exists consumer:eventId in processed_messages? no handler: OrderService.recordPayment joins insert { _id: key, processedAt } yes: skip commit, then the container deletes the message an exception: roll back both, and SQS delivers the message again
The inbox in one transaction. The inbox checks processed_messages for the key consumer:eventId and skips a known event. Otherwise the handler runs the use case, and the key is inserted. The commit stores both, and the container deletes the message. A failure rolls back both, and SQS delivers the message again.

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:

CandidateWhat the research found
Spring Modulith 2.1.2an outbox with a MongoDB store, and no inbox, which its source and its issues do not mention
Namastack Outbox 1.11.0an outbox for JDBC, JPA and MongoDB, with no inbox, and a guarantees page that asks for idempotent handlers
Spring Integration 7.1.2IdempotentReceiverInterceptor with a MetadataStore such as MongoDbMetadataStore, made for the message handlers of Spring Integration
small starters on GitHub with 2026 commitsan 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:

PredictTwo deliveries of one event, in two transactions at once#
Both deliveries pass the existence check, and both handlers have run. Which exception does the losing delivery's insert of the key cause?
@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
A write conflict, MongoDB's error 112, which Spring translates to 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);
Reading the code
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 copy

SQS 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"));
}
Reading the code
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();
    }
}
Reading the code
public abstract class InboxMessageHandler<T extends IntegrationEvent> {

A generic abstract class. T is the event type, and it must be an integration event, so it has an eventId.

protected final void process(T event) {

final means that no subclass can override the method. So no listener can skip the inbox. It is like a method without virtual in C#.

SecurityContextHolder.clearContext();

The work runs with no user, as the system actor. The finally block restores the thread's earlier context.

inbox.process(consumer, event, this::handle);

this::handle is a method reference to the subclass's handle, passed as the inbox's Consumer.

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.

Gotcha

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.

Pragmatic choice: Every listener goes through the inbox

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:

CaseThe inboxThe domain
the same message twice, within 14 daysskips it by its keyignores it, because paidAt is not later
the same payment with a new eventId, from a bug in the payment servicedoes not recognise itignores it
an older payment that arrives after a newer onedoes not recognise it, because the ID is newignores it
the same message after the inbox key expireddoes not recognise itignores 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:

TestLevelWhat it proves
PaymentReceivedV1Testunit, no Springunknown fields are ignored, and an envelope fails with eventId
PaymentReceivedListenerTestunit, with Mockitothe event becomes value objects and one use case call
InboxTestunit, with Mockitoa new event runs and records its key, and a known one is skipped
InboxITMongoDB in Dockera repeat runs once, a failure leaves nothing, and a concurrent copy is a write conflict
OrderJourneyITthe whole service, MongoDB and Flocia 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: false

The 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"}'
Reading the code
docker run --rm --network local-dev

The 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:

PointFIFO queueStandard queue and inbox
duplicatesremoved by SQS, within a window of 5 minutesskipped by the inbox, within 14 days
throughput300 transactions per second for each API action, or up to 3,000 messages with batches of 10not limited by the FIFO quota
orderkept within a message groupbest effort, so the domain handles a late message
setupa FIFO dead-letter queue, and a message group ID on every message of a FIFO topicstandard queues and topics, with no group ID
the sender's outboxa resend after 5 minutes is not removed as a duplicatethe 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>();
});
Reading the code
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:

PointAWS.Messaging 1.6.0Spring Cloud AWS 4.1.0
the handlerIMessageHandler<T>, which returns MessageProcessStatusa method with @SqsListener, which returns or throws
successMessageProcessStatus.Success()the method returns, and the message is deleted in a batch
failureMessageProcessStatus.Failed() leaves the message in the queuean exception leaves the message in the queue
visibility timeoutextended while a handler runsnot extended, so size the queue's timeout
duplicatesyour own inbox, or MassTransit'sthe 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.

Check yourself
1
An expert asksThe order service has an inbox. Why does Order.recordPayment still ignore a payment that is not later than the last one?
The inbox recognises only the same 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.
2
An expert asksWhy can a queue with a visibility timeout of one second deliver every successful message twice in a Spring Cloud AWS service?
The container deletes successful messages in batches, every second or every 10 messages. It does not extend the visibility timeout while the handler runs. If the timeout ends before the batch delete, SQS makes the message visible again, and it is delivered a second time. Size the timeout for the slowest handler plus about one second.
3
A developer renames the listener class, and its consumer name changes with it. What happens to messages that are still in the queue?
The inbox key starts with the consumer name, so no old key matches the new name. Every message that was handled before the deploy, and is delivered again, runs a second time. The name must never change.
4
TranslateWrite 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();
    }
}
@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 valueWhereValueWhat it does
messaging.queues.payment-receivedapplication.ymlorder-service-payment-receivedthe queue that @SqsListener reads
spring.cloud.aws.sqs.queue-not-found-strategythe platform's messaging.propertiesfailstops the service when the queue is missing
spring.cloud.aws.sqs.enabledthe wiring test profilefalseturns off the whole SQS auto-configuration
platform.messaging.inbox.retentionMessagingProperties14 dayshow long the inbox keeps each key
VisibilityTimeoutthe queue, in init.sh30 secondshow long a received message stays hidden
maxReceiveCountthe redrive policy, in init.sh5the receives before SQS moves a message to the dead-letter queue
RawMessageDeliverythe subscription, in init.shtruethe message body is the event, with no SNS envelope

The SQS limits that the chapter uses, from the AWS documentation:

LimitValue
visibility timeout30 seconds by default, 12 hours at most
message retention4 days by default, 14 days at most
FIFO deduplication window5 minutes
FIFO throughput300 transactions per second for each API action, or 3,000 messages with batches of 10
message attributes with raw deliveryat most 10

Logging, tracing and health#

Running the service · Chapter 22· 30 min read· Quick reference

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.

KindC#JavaNote
newno equivalentthe five tools, and who reads eachlogs, traces, metrics and probes come from the platform, and the audit log from each use case Why
sameILogger<T> and {Named} placeholdersan SLF4J Logger and {} placeholdersthe logging basics are taught in the chapter Actuator and observability
twistcatch (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.jsonlogging.level.com.acme: DEBUGa level for one package and every package below it, set in the local profile only Why
twistAddJsonConsole() in Program.csJSON (ECS) logs switched on by the platformevery service writes JSON with no setting, and the local profile turns it off for readable logs Why
twistthe members of the JSON console formatterlogging.structured.json.exclude and .renameBoot changes the members of its built-in JSON formats with settings, not with code Why
twistlogger.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
newa middleware that calls BeginScopePathVariableMdcInterceptorthe interceptor copies orderId from the URL into the MDC, so no controller writes logging code Why
trapa value added by code that runs before the controlleran MDC value set in a HandlerInterceptorno interceptor runs for a 405, so its log lines lack the value, and a servlet filter sees every request Why
twistusing (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
trapnested scopes with the same key stackMDC.putCloseable inside another value for the keyclosing the inner value removes the key, so the outer value is lost, and LogContext puts it back Why
trapa scope flows into Task.Runthe MDC stays on its threada new thread or a pool thread sees no MDC values unless code copies them Why
twista record's generated ToString()a record's toString(), overridden to print nothing personalboth languages print every member by default, so a value object with personal data overrides it Why
newno equivalentPersonalDataMaskingCustomizerthe platform masks members such as email, and e-mail addresses in messages, in every JSON line Why
twistAddOpenTelemetry().WithTracing(...)spring-boot-starter-opentelemetrythe starter adds tracing with no code, and traceId and spanId appear in every log line Why
twista sampler set in codemanagement.tracing.sampling.probabilityBoot samples 10% by default, and the platform samples every request Why
trapno trace without samplinga traceId in the logs of an unsampled requestthe trace ID is in the MDC and the logs even when the trace is not exported Why
trapAddOtlpExporter() only when you call itthe starter pushes metrics to localhost:4318with no collector, the log shows a warning, so the platform switches the metrics export off Why
trapnew HttpClient() sends traceparentonly the injected RestClient.Builder sends traceparenta client built with the static RestClient.builder() starts a new trace in the next service Why
twistmeter.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
twistthe CloudWatch exporter of OpenTelemetrymicrometer-registry-cloudwatch2 from Spring Cloud AWSBoot has no CloudWatch registry of its own, so the service exports nothing until a deployment adds one Why
twistMapHealthChecks with a tag predicatethe readiness group with readinessState,mongoreadiness includes the database and liveness does not, so a database outage stops traffic but restarts nothing Why
twistevery endpoint you map is reachableexposure.include=health,info and show-details=neverthe platform adds info to the endpoints of Boot 4.1 and restates its other defaults in one file Why
twistHostOptions.ShutdownTimeout of 30 secondsspring.lifecycle.timeout-per-shutdown-phase=20sgraceful shutdown is already the default in Boot 4.1, and the platform shortens the wait Why
newan audit row added before SaveChangesAsync()AuditLog.record(action, type, id) in the use casea business record in the database, written in the same transaction as the change, and never a log line Why
twistno equivalentWireMock started with --verbosethe 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:

ToolWhat it answersWho reads itKept for
log lines, in JSONwhat happened in this request, step by stepdevelopers and supportdays or weeks
a tracewhere the time went, across servicesdevelopersdays
metricshow many orders, how many errors, how fastoperations and the businessmonths
health probescan this instance take traffic nowthe orchestratornot kept
the audit logwho changed which order, and whensupport, auditors, the businessas 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>
Reading the code
<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:

C#
public class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> log)
{
    public void Downstream(DownstreamServiceException ex) =>
        log.LogWarning(ex, "Dependency {Dependency} is unavailable", ex.Dependency);
}
Java 25
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);
Reading the code
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: DEBUG

The 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")));
}
Reading the code
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.console to another format in its application.yml.

Map.of("logging.structured.format.console", "ecs")));

ecs stands 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 logs

With 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"}}
Reading the code
"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 name trace.id.

The three formats differ in their member names:

FormatA context value orderId=42 appears asService name
ecs"orderId":"42" at the top level"service":{"name":"order-service"}
logstash"orderId":"42" at the top levelnot 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:

SettingEffect in the run
logging.structured.json.exclude=log.logger,ecs.versionremoved the members, but left empty objects such as "ecs":{}
logging.structured.json.rename.orderId=order.idwrote "order.id":"42"
logging.structured.json.customizer=a class nameran 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.

CallWhat 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, noteId

The 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 requestThe attribute in preHandleWhat reaches the MDC
a path with variables, such as GET /orders/42a map, {orderId=42}orderId=42
a path with no variables, such as POST /ordersan empty mapnothing
a path with no controller, a 404null, because the handler for static files took the requestnothing

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.

PredictA 405 for a path with an orderId#
The interceptor above is registered. A client sends DELETE /orders/42, but the path allows only GET. Spring MVC answers 405. Does a line that is logged while Spring MVC handles this request carry orderId=42?
@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
No. For a 405, no method of the interceptor runs at all.

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);
            }
        });
    }
}
Reading the code
public final class LogContext implements AutoCloseable {

AutoCloseable does the same as IDisposable in C#. Its one method is close().

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);
    }
}
Reading the code
Map<String, String> context = new HashMap<>(logContext(event));

logContext is 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 its close(). 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#:

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"
}
Java 25
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.

Gotcha

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 ranWhat MDC.get("orderId") returned
the calling thread42
a new virtual threadnull
a task on a thread poolnull
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:

LanguageWhat printing the record wrote
C#CustomerContact { Name = Jo Bloggs, Email = jo@example.com }
JavaCustomerContact[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"));
    }
Reading the code
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 mask method 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.PersonalDataMaskingCustomizer

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

Gotcha

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

The 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 42

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

Pragmatic choice: Every request is sampled

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:

PredictThe OpenTelemetry starter with no collector#
A Boot 4.1 service adds this starter and sets no OTLP setting. No collector runs. Does anything about metrics appear in its log?
<!-- 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
Yes. It logs "Publishing metrics for OtlpMeterRegistry every 1m to http://localhost:4318/v1/metrics", and later a WARN "Failed to publish metrics to OTLP receiver", with "Connection refused".

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=false

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

Note

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 builttraceparent sentTrace in the called service
from Boot's injected RestClient.Builder00-7d38...1265-5a72...dd38-02the same trace ID as the caller
RestClient.create(base)nonea new trace ID
the static RestClient.builder()nonea 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();
    }
Reading the code
registry.counter("shop.orders", "event", "placed");

One metric named shop.orders, with a tag event. Each tag value is its own series. The tag has three values, so the number of series stays small.

@ApplicationModuleListener

This 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:

NeedArtifactNote
the auto-configurationio.awspring.cloud:spring-cloud-aws-starter-metricslatest 4.2.0, and the service uses Spring Cloud AWS 4.1.0
the registry itselfio.micrometer:micrometer-registry-cloudwatch2the 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:

C#
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") });
mongo.properties
# 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,mongo
Reading the code
management.endpoint.health.group.readiness.include=readinessState,mongo

A health group is a named set of health checks. This line puts two checks into the readiness group. readinessState is the readiness state that Boot keeps for the application. mongo is the health check that Boot provides for MongoDB.

Predicate = _ => false

In ASP.NET Core, liveness runs no check at all. A .NET run added an unhealthy mongo check. The path /health/live answered 200 Healthy. The path /health/ready answered 503 Unhealthy.

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/healthlivenessreadiness
none503 DOWN200 UP200 UP
readiness.include=readinessState,stock503 DOWN200 UP503 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=never
Reading the code
management.endpoint.health.probes.enabled=true

This adds the two probe paths. It is already the default in Boot 4.1.

management.endpoints.web.exposure.include=health,info

Only these two endpoints are reachable over HTTP. Boot's default is health alone, so the platform adds info.

management.endpoint.health.show-details=never

The 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: 12
Reading the code
exec 3<>/dev/tcp/localhost/8080

Bash treats the path /dev/tcp/host/port as 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' >&3

The command writes a minimal HTTP request to the connection.

grep -q UP <&3

grep reads the answer and succeeds only if it contains UP.

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:

C#
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(20));
core.properties
# 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=20s

Graceful 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 complete

GracefulShutdown 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:

FieldWhere it comes fromExample
actionthe use caseORDER_SHIPPED
resourceTypethe use caseorder
resourceIdthe use casethe order's ID
actorCurrentActor, the token's subject or systemagent-1
atthe Clock beanthe time of the change
traceIdthe MDC, when tracing has onethe 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"));
    }
}
Reading the code
@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 AuditLog bean 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_log itself, 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.

Check yourself
1
The 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?
Yes. The trace ID is put into the MDC for every request, sampled or not, so the log lines and the problem responses carry it. Sampling decides only whether the trace is exported. The traceparent header shows it in its last flag, -02 for not sampled and -03 for sampled.
2
An expert asksWhy does the order service's liveness probe leave out the database, while its readiness probe includes it?
A failed liveness probe makes the orchestrator restart the instance. If liveness included the database, a database outage would restart every instance, and the restarts would fix nothing. Readiness only stops traffic. So during an outage, no requests arrive, and the instances keep running until the database is back.
3
An expert asksWhy is the audit record written to the database in the use case's transaction, and not as a log line?
The audit answers who changed what, and the business needs that answer for as long as the data exists. Logs are kept for weeks and can be lost. A log line can also say that a change happened when the transaction then rolled back. A record in the same transaction exists exactly when the change exists.
4
TranslateWrite 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);
}
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:

SettingBoot defaultThe platform or the serviceWhat it does
logging.structured.format.consolenone, so plain textecs, unless platform.logging.structured=falsewrites JSON log lines in ECS
platform.logging.structuredtruefalse in the local profile and in teststhe platform's switch for JSON logs
platform.logging.mdc-path-variablesnoneorderId, lineId, noteIdpath variables copied into the MDC
logging.structured.json.customizernonePersonalDataMaskingCustomizermasks personal data in JSON lines
logging.level.com.acmenot setDEBUG in the local profilethe level of the shop's own code
management.tracing.sampling.probability0.11.0the share of requests whose traces are exported
management.opentelemetry.tracing.samplerparent-based-trace-id-rationot setfollows the caller's sampling decision
management.opentelemetry.tracing.export.otlp.endpointnoneset by a deploymentwhere traces are sent
management.otlp.metrics.export.enabledtruefalsepushes metrics over OTLP
management.otlp.metrics.export.urlnone, so localhost:4318set by a deploymentwhere metrics are sent
management.endpoint.health.probes.enabledtruetrue, restatedadds the liveness and readiness paths
management.endpoint.health.group.readiness.includereadinessStatereadinessState,mongoputs the database into readiness
management.endpoints.web.exposure.includehealthhealth,infothe endpoints reachable over HTTP
management.endpoint.health.show-detailsnevernever, restatedshows only UP or DOWN
server.shutdowngracefulgraceful, restatedlets requests in flight finish
spring.lifecycle.timeout-per-shutdown-phase30s20sthe longest wait during shutdown

Caching and scheduling#

Running the service · Chapter 23· 19 min read· Quick reference

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.

KindC#JavaNote
newno equivalentlookup lists read on every orderthe delivery method of each new order is checked against a list that changes only with a deployment Why
twistbuilder.Services.AddMemoryCache()@EnableCaching and spring-boot-starter-cachethe annotation switches the caching proxy on, and the starter brings the cache manager Why
twistan IMemoryCache with no size limitConcurrentMapCacheManagerwith no cache library present, Boot keeps each cache in a ConcurrentHashMap that it makes on first use Why
newcache.GetOrCreateAsync(type, ...) in a repository class@Cacheable("reference-data") on the Spring Data repository methodthe cache is a storage concern, and its callers are other beans, so every call passes the proxy Why
trapa field of a cached objecta public field read through the proxythe proxy's own fields are never set, so the read returns null, and code must call methods Why
twistthe key passed to GetOrCreatethe method's one argumentone argument is the key itself, and two arguments make a SimpleKey of both Why
newAbsoluteExpirationRelativeToNow on each entryno expiry and no evictionthe lists change only with a deployment, and a deployment restarts every instance Why
twistcache.Remove(key)@CacheEvict, or spring.cache.type=caffeine with a specan eviction reaches one instance only, so data that changes at run time needs an expiry Why
twistHybridCache, two levels in one APICaffeine or Redis behind @CacheableBoot has no two-level cache provider, so a service chooses one store Why
trapa BackgroundService that the service registers@EnableScheduling inside the platform's auto-configurationscheduling is on in every service that has the resender, so its own @Scheduled methods run too Why
twistnew PeriodicTimer(TimeSpan.FromMinutes(1))@Scheduled(fixedDelayString = "${...:PT1M}")the interval is an ISO-8601 duration from a setting, with a default after the colon Why
traplock (gate) { }, which waits for its turnthe resender's ShedLock lockan instance that finds the lock taken skips its run, and does not wait or run later Why
twistHangfire or Quartz.NETQuartz, or JobRunrlibraries that keep jobs in a database, for jobs whose runs must not be lost Why
newno equivalenta job calls the use cases as the system actorthe 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");
    }
}
Reading the code
items.findByTypeOrderByCodeAsc(type)

items is the feature's Spring Data repository. Both methods call the same repository method with the type, such as DELIVERY_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:

C#
builder.Services.AddMemoryCache();
Java 25
@Configuration(proxyBeanMethods = false)
@EnableCaching
class ReferenceConfig {
}
Reading the code
@EnableCaching

This 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 @Bean methods, so it needs no proxy of its own. false tells 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);
}
Reading the code
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.

Gotcha

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();
}
Reading the code
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();

caches is the CacheManager bean. The entry under the key DELIVERY_METHOD exists, 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:

C#
public Task<List<ReferenceItem>?> FindByType(string type) =>
    cache.GetOrCreateAsync(type, _ => store.FindByTypeAsync(type));
Java 25
@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:

ArgumentsThe key
one, such as "delivery"the argument itself, a java.lang.String
two, such as "delivery" and "en"SimpleKey [delivery, en]
noneSimpleKey []

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.

Pragmatic choice: A cache with no expiry and no size limit

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:

ChoiceHowWhat it guarantees
evict on change@CacheEvict on the method that changes the datathis instance is fresh at once, and the other instances are not
expire after a timespring.cache.type=caffeine with an expireAfterWrite in its specevery instance is fresh within that time
one shared cacheRedis behind @Cacheableevery 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 expireAfterWrite is 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:

.NETSpringNote
IMemoryCache@Cacheable with the default map, or with Caffeineone process, and Caffeine adds size limits and expiry
HybridCacheno single providerHybridCache 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);
    }
}
Reading the code
@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.

@EnableScheduling

The 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 @Scheduled method 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.

Gotcha

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: false

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

Pragmatic choice: The platform switches scheduling on

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#

C#
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);
        }
    }
}
Java 25
@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);
}
Reading the code
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.

PredictTwo instances reach the resender at the same time#
Two instances of the order service run this method at the same moment. Instance A takes the lock first and is still working. What does instance B do?
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
B skips the run. It does not wait for the lock, and it does not run later.

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:

ValueIn the platformWhat it protects against
lockAtMostForlock-at-most-for, PT5M by defaultan instance that dies while it holds the lock, because the lock is freed after this time
lockAtLeastForhalf the resend intervalclocks 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.

Gotcha

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.NETJava
a job that may skip a turn, once across instancesa BackgroundService with a lock of your own@Scheduled with ShedLock
jobs kept in a databaseHangfire, which stores each recurring job in persistent storageJobRunr, which keeps jobs in your database and has a dashboard
a scheduler with a cluster of nodes and a shared job storeQuartz.NET, clustered with its AdoJobStoreQuartz, 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.

Check yourself
1
An expert asksWhy does the order service put @Cacheable on the repository method and not on the service method that lists the values?
The cache saves database reads, so it is a storage concern, and the service reads the same way without it. And @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.
2
An 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?
Each instance keeps its own cache in its own memory. The admin request reaches one instance, so only that instance evicts the entry. The other two keep the old list. An expiry, such as Caffeine's expireAfterWrite, bounds how long they keep it. A shared cache such as Redis, or no cache, makes every instance fresh at once.
3
The order service writes no @EnableScheduling. What switches scheduling on, and which setting switches it off again?
The platform's messaging auto-configuration carries @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.
4
TranslateThe 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 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:

SettingDefaultIn the order serviceWhat it does
spring.cache.typethe first provider found, else simplenot set, so simplechooses the cache store
spring.cache.caffeine.specnonenot usedsize and expiry for every Caffeine cache
platform.messaging.outbox.resend-enabledtruefalse in the teststhe resender, and scheduling with it
platform.messaging.outbox.resend-intervalPT1Mnot changedhow often the resender runs
platform.messaging.outbox.resend-older-thanPT5Mnot changedonly older publications are resent
platform.messaging.outbox.lock-at-most-forPT5Mnot changedthe longest one instance holds the lock
platform.messaging.enabledtruenot changedthe whole outbox and inbox, with scheduling
spring.task.scheduling.pool.size1not changedthe scheduler's threads

Writing the platform library#

Running the service · Chapter 24· 25 min read· Quick reference

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.

KindC#JavaNote
newShop.Platform, a NuGet packageshop-platform, in five packagesdomain, core, mongo, messaging and test, and each package is one area that a service gets Why
newa shared Domain library with Money and OrderIdno business code and no domain model in the platformservices share contracts, never domain classes, because shared models tie releases together Why
twistbuilder.Services.AddShopPlatform() in Program.csan entry in AutoConfiguration.importsBoot reads the file from every jar at startup, so the service calls nothing Why
trapno equivalenta misspelt name of the imports fileBoot reports nothing and loads none of the library's classes Why
newno equivalenteight auto-configuration classeseach one sets up one area, and each states its conditions and its order Why
twistno equivalenta WebMvcConfigurer beana library adds to Spring MVC by declaring a bean, not by calling a method Why
traptwo AddSingleton calls: the last one winsa default and a provider, with no order between themSpring registers both beans, and a service that asks for one gets NoUniqueBeanDefinitionException Why
twistno equivalentbefore, after, beforeName and afterName on @AutoConfigurationthey order the auto-configurations, and a name that matches no class is not reported Why
newno equivalent@ConditionalOnMissingBean, @ConditionalOnProperty, @ConditionalOnWebApplication, @ConditionalOnClasseach condition decides whether a bean or a whole class is created Why
twistservices.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
newConfiguration.Sources.Insert(0, defaults)one defaults file for each package, added last by an EnvironmentPostProcessorthe platform's defaults have the lowest precedence, so application.yml always wins Why
twistno equivalentspring.factories for the EnvironmentPostProcessorthe imports file is only for auto-configurations, so this one class is still registered the old way Why
twistno equivalentplatform.messaging.enabled=false, with three Spring propertiesa service with no events switches the outbox, the inbox and Spring Cloud AWS off together Why
newa shared test helper package@PlatformWebMvcTest, @PlatformDataMongoTest, test containers and ArchitectureRulesthe library ships its test support, so every service tests the same way Why
twistno equivalenttest support in the library's main source, not in its testsa service can use only main code, so the library compiles against the test libraries without passing them on Why
newa package version on NuGetsemantic versions, with services at most one minor version behinda breaking change comes only in a major version, with upgrade notes Why
newone NuGet package per concernone library, with its packages kept apartone dependency and one version, and messaging can be switched off Why
newno equivalentbuild the library with the first servicesuntil 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:

PackageWhat a service gets
com.acme.platform.domainAggregateRoot, Entity, Identifier, ValueObject, DomainEvent, DomainEventPublisher, Repository, Guard, PageQuery, PageResult and the three exception kinds, in plain Java
com.acme.platform.coreproblem 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.mongothe 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.messagingthe 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.

Pragmatic choice: Money written in each service, not shared

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

The 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:

C#
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;
    }
}
PlatformCoreAutoConfiguration.java
@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.

Gotcha

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:

ClassApplies whenOrderBeans
PlatformCoreAutoConfigurationalwaysafter Boot's OAuth2 client setupClock, CurrentActor, HttpClientFactory, ResilientCalls
PlatformWebAutoConfigurationin a servlet web applicationno orderGlobalExceptionHandler, and a WebMvcConfigurer for the MDC interceptor
PlatformSecurityAutoConfigurationin a servlet web applicationbefore Boot's security chainsthe SecurityFilterChain, with method security on
PlatformOpenApiAutoConfigurationwhen springdoc's OpenAPI class is on the classpathno orderan OpenApiCustomizer with the shared parts
PlatformMongoAutoConfigurationalwaysbefore Boot's MongoDB and transaction setupMongoTransactionManager, the DocumentDB customizer, the migration runner
PlatformMongoAuditAutoConfigurationalwaysafter PlatformMongoAutoConfigurationMongoAuditLog, AuditLogIndexes
PlatformMongoWebAutoConfigurationin a servlet web applicationno orderOptimisticLockingExceptionHandler
PlatformMessagingAutoConfigurationunless platform.messaging.enabled is falseafter PlatformMongoAutoConfigurationthe 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();
    }
Reading the code
@AutoConfiguration(afterName = "org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration")

@AutoConfiguration marks the class as an auto-configuration. It does what @Configuration does, and it also takes ordering attributes. afterName makes 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 @ConfigurationProperties beans and binds them from the settings, such as platform.resilience.retry.max-attempts. For binding, see the chapter Configuration in depth.

@ConditionalOnMissingBean

Without a type, the condition takes the method's return type. So this bean is created only when no CurrentActor bean 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);
            }
        };
    }
}
Reading the code
@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 WebMvcConfigurer bean 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:

PredictA default and a provider with no order between them#
A service asks Spring for the one TrapGreeter bean. What happens?
@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
Spring throws 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:

AttributeTakesUse it when
beforeclassesthe other class is always on the library's compile classpath
afterclassesthe same, for a class that must come first
beforeNameclass names as stringsthe other class is in a module that may be missing, or the library should not compile against it
afterNameclass names as stringsthe 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);
    }
Reading the code
"org.springframework.boot.transaction.autoconfigure.TransactionAutoConfiguration"})

Boot's transaction setup creates a TransactionTemplate once 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 bean transactionTemplate. 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.

Gotcha

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:

ConditionThe bean or class is created whenUsed by the platform for
@ConditionalOnMissingBeanno bean of the type exists yet, and the type is the return type when none is namedevery default that a service may replace, such as Clock and AuditLog
@ConditionalOnPropertya setting has the given value, compared without case, and matchIfMissing decides when it is absentthe migrations, the messaging and the resender, which a service can switch off
@ConditionalOnWebApplicationthe application has the given web type, such as SERVLETthe error handler, the security chain and the 409 handler
@ConditionalOnClassa class is on the classpaththe 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);
}
Reading the code
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. With false, it was absent. With TRUE in capitals, it was present.

ObjectProvider<MongoMigration> migrations

An ObjectProvider asks for beans when the code needs them. orderedStream() returns every MongoMigration bean, sorted by @Order. For ObjectProvider, 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.

C#
var services = new ServiceCollection();
services.AddSingleton<TimeProvider>(new FixedTime());
services.AddShopPlatform();
Java 25
/** 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:

TypeThe platform's defaultA reason to replace it
ClockClock.systemUTC()a test that needs a fixed time
CurrentActorSecurityContextCurrentActora service that reads the actor from somewhere else
AuditLogMongoAuditLog, in the collection of the platforman audit store outside the service's database
SecurityFilterChainthe resource server chaina service with other rules, such as URL rules
TransactionManagerMongoTransactionManagera service that does not use MongoDB
DomainEventPublisherOutboxDomainEventPublishera service with another way to publish
GlobalExceptionHandlerthe platform's problem responsesrarely, 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,mongo

Each 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.PlatformDefaultsEnvironmentPostProcessor

The 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:

PropertySwitches off
platform.messaging.enabled=falsethe platform's outbox publisher, the inbox and the resender
spring.cloud.aws.sns.enabled=falsethe SNS client of Spring Cloud AWS
spring.cloud.aws.sqs.enabled=falsethe SQS client and the listener containers
spring.modulith.events.externalization.enabled=falsethe 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:

ClassWhat a service's test gets
@PlatformWebMvcTesta controller slice with the platform's security, errors and MDC
@PlatformDataMongoTesta persistence slice against MongoDB in Docker, with the platform's transaction manager and migrations off
MongoTestContainerConfigurationMongoDB 8.0.34 as a one-node replica set, connected with @ServiceConnection
AwsTestContainerConfigurationthe Floci emulator for SNS and SQS
TestJwtDecoderConfigurationa decoder that refuses every real token, for tests that use jwt()
NoOpTransactionManagera transaction manager that does nothing, for unit tests of a use case
ArchitectureRulesthe 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 domain or test packages 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.

Pragmatic choice: Platform code inside a service, for a while

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.

Check yourself
1
An 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?
Spring processes the service's own configuration before the auto-configurations. So when the platform's condition is checked, the service's 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.
2
An expert asksWhy does a shared domain library tie the release cycles together, when a shared OpenAPI file or event schema does not?
A domain class changes with the rules of the service that owns it. Every other service must then take a new version of the library, or disagree about the concept. A contract changes only when the API or the event changes. A consumer that does not read a new field needs no change.
3
An expert asksThe platform's Clock bean carries @ConditionalOnMissingBean, but its bean platformMdcWebMvcConfigurer does not. Why?
A service may have many 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.
4
Why is PlatformDefaultsEnvironmentPostProcessor registered in spring.factories, and not in the imports file?
The imports file names auto-configurations only. An environment post-processor must run before any bean exists, so Boot finds it under the key org.springframework.boot.EnvironmentPostProcessor in META-INF/spring.factories.
5
TranslateWrite 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);
@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:

PropertyDefaultMeaning
platform.logging.structuredtrueJSON (ECS) console logs, and set it to false locally
platform.logging.mdc-path-variablesnonethe path variables copied into the MDC
platform.security.public-paths/actuator/health/**, /actuator/infothe paths that need no token, which are the health probes by default
platform.resilience.retry.max-attempts3the calls in total, so up to 2 retries
platform.resilience.retry.initial-interval200msthe first wait, and later waits grow, with random spread
platform.resilience.retry.multiplier2.0how much each wait grows, compared with the one before it
platform.resilience.circuit-breaker.failure-rate-threshold50the failure rate, in percent, that opens the breaker
platform.resilience.circuit-breaker.sliding-window-size20the number of calls that the rate is computed over
platform.resilience.circuit-breaker.minimum-number-of-calls10no rate is computed before this many calls
platform.resilience.circuit-breaker.wait-duration-in-open-state30show long the breaker stays open
clients.<name>.base-urlnonethe base URL of one HTTP client
clients.<name>.connect-timeout2sthe connect timeout of one HTTP client
clients.<name>.read-timeout5sthe read timeout of one HTTP client
clients.<name>.token-registration-idnonethe client registration whose token each call carries
platform.mongo.documentdb.ca-bundlenonethe AWS certificate bundle for TLS
platform.mongo.migrations.enabledtrueruns the migrations at startup, and tests switch it off
platform.mongo.migrations.lock-ttlPT15Mhow long a migration lock lasts, longer than the slowest migration
platform.messaging.enabledtrueswitches the outbox and the inbox on or off together
platform.messaging.outbox.resend-enabledtruethe scheduled resender, and scheduling with it
platform.messaging.outbox.resend-intervalPT1Mhow often the resender looks for events that were not sent
platform.messaging.outbox.resend-older-thanPT5Monly events older than this are resent
platform.messaging.outbox.lock-at-most-forPT5Mthe longest time one instance holds the resender lock
spring.modulith.events.staleness.resubmittedPT15Ma row stuck in RESUBMITTED for this long goes back to FAILED, so keep it longer than lock-at-most-for
platform.messaging.inbox.retentionP14Dhow long processed event IDs are kept, with a TTL index

Testing in depth#

Testing, shipping and changing it · Chapter 25· 49 min read· Quick reference

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.

KindC#JavaNote
newa test project for each layer, by habiteleven kinds of test, each with an owner and a costeach kind proves one thing, and most kinds need no Spring and no Docker Why
twist[Trait("Category", "Integration")] with dotnet test --filtera class name that ends in Test, or in IT for a test that needs Dockermvn verify runs every *Test class, and mvn verify -Pit adds every *IT class Why
newa domain test that starts a hosta plain JUnit test of the aggregate, with no Spring and no mocksif a domain test needs Spring, the code under test is in the wrong place Why
twista TimeProvider passed to the codeClock.fixed(NOW, ZoneOffset.UTC) in TestDataevery time in a test is known, and each test starts from an aggregate with no events Why
twistMoq, Verify and Times.Never()Mockito, verify, never() and verifyNoInteractionsthe use case runs with mocks of its ports and the real domain, and proves that nothing was saved Why
twistMockSequence with strict mocksInOrder and ArgumentCaptorthe test checks the order of the steps, and reads the ID that the use case chose Why
newno equivalenta real TransactionTemplate with NoOpTransactionManagerthe transactional lambda runs in a unit test, and nothing touches a database Why
twistWebApplicationFactory with ConfigureTestServices@PlatformWebMvcTest(OrderController.class)one annotation starts Spring MVC with the platform's security and error handling Why
trapno equivalenta plain @WebMvcTest in a service that uses the platformthe platform's error handler is missing, so a missing order throws in the test instead of answering 404 Why
twistassertions on an HttpResponseMessageMockMvc with status(), header() and jsonPath()one chain of calls checks the status, the headers and the problem body Why
twista test authentication handlerjwt() from spring-security-testit puts a caller into the security context, and never calls the token decoder Why
twistWireMock.Net in a client testa JDK HttpServer on port 0, with fast retriesthe real client, its JSON, its token and its retries run, and no retry waits 200 ms Why
twista unit test of a message handlera mapper test, and a listener test with an inbox that passes the event throughthe listener only translates, so its test replaces the inbox with an answer that calls the handler Why
trapno equivalentnew MongoDBContainer(image) in Testcontainers 2the new class starts no replica set until withReplicaSet() is called, so every transaction fails Why
twista 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
newno equivalentone test of optimistic locking for each aggregateeach aggregate proves that a stale copy is refused, in the style that its feature uses Why
newWebApplicationFactory<Program>, which starts the hostApplicationWiringTest, with nothing to connect toevery bean and setting is checked in seconds, with no Docker Why
twistWebApplicationFactory<Program> with containersOrderJourneyIT, with MongoDB and Floci in Dockerone journey runs through the whole service, and the test checks the audit log and the outbox too Why
twistno equivalentOpenApiContractTestthe build fails when the committed contract differs from the code Why
twistArchUnitNETArchitectureRules, in the platform's test packagethe design rules fail the build, and every service calls the same rules Why
newno equivalenta custom ArchConditionit states a rule that the fluent sentences cannot state, such as "features meet only through services" Why
newno equivalentthe guardrail rulesfive rules catch the mistakes that a review misses most often Why
twistno equivalentFreezingArchRule.freeze(rule)an existing service records its old violations, and only new ones fail the build Why
newno equivalentarchitecture rules that are optional or requiredif a team adopts only one rule, it is "a domain package is plain Java" Why
twistunit tests of a shared NuGet packagethe platform library's own teststhe library tests its code with small classes declared inside each test Why
newno equivalentwhat to test first in an older servicestart with the tests that cost least and protect the most, then add more where you change code Why
samePlaywright tests owned by QAPlaywright tests owned by QAend-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:

KindExampleWhat startsOwnerTime for one classCommand
domain unitOrderTest, 15 testsnothingthe feature's developer0.05 smvn verify
application unitOrderServiceTest, 7 testsnothing, with Mockito mocksthe feature's developer0.5 smvn verify
API sliceOrderControllerTest, 23 teststhe web layer and the platform's web beansthe feature's developer6.1 smvn verify
HTTP clientStockAdapterTest, 6 testsan HTTP server from the Java librarythe developer who adds the client0.4 smvn verify
messaging unitPaymentReceivedListenerTest, 1 testnothingthe feature's developer0.05 smvn verify
mappingNoteMappingTest, 2 teststhe MongoDB converter, with no databasethe feature's developer0.2 smvn verify
wiringApplicationWiringTest, 2 teststhe whole service, connected to nothingthe service team4.0 smvn verify
contractOpenApiContractTest, 1 test, and PublishedEventSchemaTest, 5 teststhe whole service for the API, and only the JSON mapper for the eventsthe service team2.0 s and 0.5 smvn verify
architectureArchitectureTest, 10 testsnothing, ArchUnit reads the compiled classesthe platform team writes the rules, and each service calls them1.0 smvn verify
persistenceOrderPersistenceAdapterIT, 6 testsMongoDB in Dockerthe feature's developer9.7 smvn verify -Pit
journeyOrderJourneyIT, 7 teststhe whole service, MongoDB and Floci in Dockerthe service team16.6 smvn verify -Pit
end-to-endPlaywright tests across servicesevery service and the local environmentthe QA team, in its own repositorynot measured hereQA'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 Docker

The 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);
        }
Reading the code
@Nested

JUnit runs the tests of an inner class as a group. The report shows OrderTest, then Shipping, 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. containsExactly passes 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 checkWhat it checksWhere the service uses it
assertThat(x).isEqualTo(y)equal by equalsalmost every test
assertThatThrownBy(() -> ...).isInstanceOf(T.class)the code throws this typeevery rule that refuses a change
.hasMessage("...") or .hasMessageContaining("...")the exception's message, whole or in partValueObjectTest, for the text of each refusal
assertThatCode(() -> ...).doesNotThrowAnyException()the code throws nothingShippingPolicyTest, the allowed case
containsExactly(a, b)exactly these elements, in this orderthe raised events
singleElement().satisfies(x -> ...)exactly one element, which passes the checks insidethe one line of a new order
extracting(Order::id)a list of one value from each elementthe page of orders in the persistence test
isInstanceOfSatisfying(T.class, x -> ...)this type, then the checks inside on itthe mapper test of the published events
hasToString("...")the result of toString()CustomerContact hides its personal data
hasValue(3)the value of an AtomicIntegercounting 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#

C#
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;
}
TestData.java
/** 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;
    }
Reading the code
public static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);

Clock comes from java.time. A fixed clock always returns the same instant. The domain receives the clock as a parameter, such as order.ship(CLOCK), so every time that a test sees is NOW. TimeProvider has 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 class in 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);
Reading the code
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:

C# 12, Moq
[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();
}
Java 25
@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);
}
Reading the code
when(stock.reserve(any(), any())).thenReturn(

when(...).thenReturn(...) stubs a method, like Setup(...).Returns(...) in Moq. any() matches every argument, like It.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 save was called at all. It does the same as Times.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 ID
Reading the code
InOrder order = inOrder(referenceData, stock, orders, events, audit, notes);

An InOrder object checks calls across these mocks in the order of the verify lines. 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 equals TWO_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.
    }
}
Reading the code
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 {};
}
Reading the code
@WebMvcTest

The new annotation carries @WebMvcTest. So a class with @PlatformWebMvcTest is a web slice, as if it carried @WebMvcTest itself. 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")

@AliasFor passes the value of this attribute to @WebMvcTest. So @PlatformWebMvcTest(OrderController.class) means @WebMvcTest(controllers = OrderController.class). Without the alias, Spring would not know that value belongs to the inner annotation.

Class<?>[] value() default {};

An attribute named value is 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;
Reading the code
@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.

@MockitoBean

This puts a Mockito mock of OrderService into 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.

Gotcha

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 atWith a plain @WebMvcTest
the security chains in the contextBoot's oauth2SecurityFilterChain, not the platform's chain
the controller advices in the contextnone
GET /orders/{id} for a missing orderperform threw a ServletException, caused by OrderNotFoundException
Gotcha

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());
}
Reading the code
mvc.perform(post("/orders").with(AGENT).contentType(MediaType.APPLICATION_JSON).content("""

post builds 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 andExpect adds one check. The checks come from the class MockMvcResultMatchers, with static imports.

.andExpect(jsonPath("$.errors.length()").value(3))

JSONPath is a small language for finding values in JSON. $ is the whole document, and length() 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 field is customer.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:

TestThe answer it expects
invalidFieldsAreAllListed400, with every invalid field in errors
anInvalidIdInThePathIsA400ThatNamesTheField400, with the field orderId
aMissingOrderIsA404WithItsCode404, with the code ORDER_NOT_FOUND
aBrokenBusinessRuleIsA422WithItsCode422, with the code ORDER_NOT_PAID
aStaleVersionIsA409409, with the code CONCURRENT_UPDATE
aWriteConflictInsideATransactionIsAlsoA409409, with the same code, for MongoDB's write conflict
anUnavailableDependencyIsA503503, when the stock client reports the service as unavailable
thePageSizeIsLimited400, with the field size, for a page of 1,000
aRequestWithoutATokenIsA401ProblemBeforeAnyController401, with the WWW-Authenticate header
aCallerWithoutTheWriteScopeIsA403AndNothingRuns403, and the use case is never called
anUnexpectedErrorIsA500WithoutItsMessage500, 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));
}
Reading the code
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 sets status on its own thread, and the server reads it on the other. volatile makes 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"));
}
Reading the code
doAnswer(call -> {

doAnswer stubs 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 process is the handler, a Consumer. 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 holds 12.50 in 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();
    }
}
Reading the code
@TestConfiguration(proxyBeanMethods = false)

This is a configuration class for tests only. A test adds it with @Import.

@Bean

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

@ServiceConnection

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

PredictA MongoDB container without withReplicaSet()#
Spring Boot 4 uses Testcontainers 2. A developer removes .withReplicaSet() from this configuration, because the container seems to work without it. Then a persistence test saves an order inside a transaction. What happens?
@Bean
@ServiceConnection
MongoDBContainer mongoDbContainer() {
    return new MongoDBContainer(IMAGE).withReplicaSet();
}
Show the answer
The transaction fails with 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 {
}
Reading the code
@DataMongoTest(properties = "platform.mongo.migrations.enabled=false")

@DataMongoTest is 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");
    }
}
Reading the code
@SpringBootTest

This 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 wiring loads application-wiring.yml from 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.uri without a warning, and would connect to localhost:27017 instead. 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:1

Each value stops one kind of connection. For example, serverSelectionTimeoutMS=100 makes a query give up after 100 milliseconds:

SettingWhy it lets the service start without a network
spring.mongodb.uri on port 1, with serverSelectionTimeoutMS=100the driver connects only when a query runs, and a query would give up after 100 ms
migrations enabled: falsethe migrations would write to MongoDB at startup
issuer-uri empty, jwk-set-uri on port 1Spring fetches keys only for a real token, and no test sends one
sqs enabled: falsethe SQS listeners would start polling a queue
credentials access-key and secret-keythe AWS client needs some credentials to be created
resend-enabled: falsethe outbox resender would read MongoDB on a schedule
clients.stock.base-url on port 1the 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 {
Reading the code
@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

@SpringBootTest gives 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. Resources is 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")));
Reading the code
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");
Reading the code
mongo.find(query(where("resourceId").is(orderId)).with(Sort.by("at")),

query and where are 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.

Pragmatic choice: The stock service is mocked in the journey test

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);
}
Reading the code
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.getBoolean reads a system property, not a string. mvn verify -Dopenapi.update=true sets 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);
}
Reading the code
.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 *Test with 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);

check throws an AssertionError that 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");
}
Reading the code
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 in com.fasterxml. So the rule names both.

"com.acme.platform.core..", "com.acme.platform.mongo..")

The domain may use the platform's domain package, 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:

RuleWhat it stops
domainIsPlainJavaa 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
apiDoesNotUsePersistenceOrClientsa controller that reaches past the application layer
controllersDoNotUseRepositoriesa controller that skips the use case and its rules
everyEndpointHasAPermissionan endpoint that anyone with a token can call
documentsOnlyInPersistenceOrModela stored document outside a persistence or model package
httpClientsOnlyInClienta declarative HTTP client outside a client package
entitiesHaveNoPublicSettersan 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");
}
Reading the code
.should(new ArchCondition<>("use other features only through their application package") {

An ArchCondition is 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. JavaClass describes the class from its bytecode. events collects 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 is Order. 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");
}
Reading the code
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:

StepWhat happened
the plain rule, on a domain class with @Componentthe rule failed
the frozen rule, first runit passed, and wrote a store of today's violations
the frozen rule, same codeit passed again
the frozen rule, after a new violation was addedit 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");
    }
}
Reading the code
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=false
Reading the code
freeze.store.default.path=src/test/resources/archunit_store

The 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=false

The 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-76d9759202a5
Reading the code
Every\ endpoint\ has\ a\ permission\ annotation=788271d0-89f0-404a-8ca9-76d9759202a5

The 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 codeIts tests
the inboxInboxTest, and InboxIT with a duplicate, a rollback and two copies of one event at once
the outbox publisherOutboxDomainEventPublisherTest, including the refusal outside a transaction
the outbox resender and its lockOutboxResenderTest, and OutboxResenderLockIT with two instances
the migration runnerMongoMigrationRunnerTest 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 MDCLogContextTest and PathVariableMdcInterceptorTest
the DocumentDB settingsDocumentDbClientSettingsCustomizerTest, including a missing certificate bundle
retries and the circuit breakerResilientCallsTest
the base classes of the domainAggregateRootTest, IdentifierTest and GuardTest
the current actor on another threadActorOnAnotherThreadTest, 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:

  1. 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.
  2. The architecture rules, frozen. They need no fixes today, and they stop new code from making the structure worse.
  3. 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.
  4. 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.
  5. One optimistic-lock test for each aggregate. A lost update is silent, so a missing version check is found only by a test.
  6. 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.

Check yourself
1
An 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?
The rule should depend only on the order and on plain values. If its test needs Spring, the rule reads a bean, a setting or a repository. That dependency belongs in the application service, which can pass the value in. Moving it keeps the rule testable in milliseconds and free of the framework. The architecture rule domainIsPlainJava catches the same mistake in the build.
2
Why does the order service test its controllers with @PlatformWebMvcTest and not with a plain @WebMvcTest?
A plain slice loads none of the platform's auto-configurations. It has Boot's security chain, not the platform's, and no exception handler. So a missing order throws in the test instead of answering a 404 problem. The platform's annotation imports its security, error handling and log interceptor, and a decoder for tests.
3
An 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?
The real template runs the lambda as production does, so the steps inside run in order with no stub to write. Spring also reports an active transaction, so code that refuses to run outside one still runs. A mock would need an answer that calls the callback, in every test class. That answer would repeat what the template does.
4
TranslateWrite 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();
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:

KindClassesCommand
domain unitOrderTest, ValueObjectTest, ShippingPolicyTest, NoteTestmvn verify
application unitOrderServiceTest, NoteServiceTestmvn verify
API sliceOrderControllerTest, NoteControllerTest, ReferenceDataControllerTestmvn verify
HTTP clientStockAdapterTestmvn verify
messaging unitOrderIntegrationEventMapperTest, PaymentReceivedListenerTest, PaymentReceivedV1Testmvn verify
mappingNoteMappingTestmvn verify
wiringApplicationWiringTestmvn verify
contractOpenApiContractTest, PublishedEventSchemaTestmvn verify
architectureArchitectureTest, ArchitectureRulesCatchViolationsTest, FrozenRuleTestmvn verify
persistenceOrderPersistenceAdapterIT, NoteRepositoryIT, NoteMigrationsITmvn verify -Pit
journeyOrderJourneyITmvn verify -Pit

The platform's test package, and what each class is for:

ClassWhat it gives a service
PlatformWebMvcTesta web slice with the platform's security, error handling and log interceptor
TestJwtDecoderConfigurationa token decoder that refuses every real token, for slices
PlatformDataMongoTesta persistence slice with the platform's MongoDB setup and a container
MongoTestContainerConfigurationMongoDB 8.0.34 in Docker, as a replica set, with @ServiceConnection
AwsTestContainerConfigurationFloci 2.3.0 in Docker, with Spring Cloud AWS pointed at it
NoOpTransactionManagera transaction manager that does nothing, for unit tests
ArchitectureRulesthe shop's design rules as ArchUnit rules

Running locally and shipping#

Testing, shipping and changing it · Chapter 26· 26 min read· Quick reference

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.

KindC#JavaNote
newa docker-compose.yml in each solutiona shared local-environment repositoryone set of local dependencies for every service and for QA, started once Why
newno equivalentmongo --replSet rs0, and rs.initiate in the healthcheckMongoDB runs a transaction only on a replica set, so even one local node is started as one Why
twistWireMock.NetWireMock 3.13.0 in Docker, with stubs chosen by priority and bodya stub with a lower priority number wins, so a special product overrides the default answer Why
newLocalStackFloci 2.3.0LocalStack needs an account since March 2026, so the shop uses a free emulator with the same API Why
newno equivalentan aws-init container that runs init.shthe topics, queues, dead-letter queues and subscriptions exist before any service starts Why
twista local token issuer of your choicemock-oauth2-server 6.1.3, with one client ID for each kind of callera client ID stands for a kind of user, and its token carries that user's scopes Why
newdocker compose up for the dependencies, then dotnet rundocker compose up -d, then mvn package && docker compose up --buildtwo commands start everything, with no manual step Why
trapASPNETCORE_ENVIRONMENT=Development in launchSettings.jsonthe local profile from the IDE, with localhost in place of the container namesa MongoDB URI with replicaSet=rs0 fails from the host, because the member is named mongo:27017 Why
twistcurl with a bearer tokenthe same curl, with a token from the local issuerthe first request of a new developer, copied from the README Why
newno equivalentthe rules of the local environmenta stub, a topic or a queue arrives in the same change as the code that needs it Why
twista Dockerfile that copies the publish outputa layered Spring Boot jar, extracted with --launcher and started with JarLauncherthe rarely changing layers come first, so a code change rebuilds only the last layer Why
twista USER line in the Dockerfileuseradd --system --uid 10001, then USER 10001the 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_OPTIONSthe JVM's default heap is 25 percent of the container's memory, but the .NET default is 75 percent Why
newno equivalent-XX:+ExitOnOutOfMemoryErrorthe JVM exits with code 3 at the first OutOfMemoryError, so the orchestrator restarts it Why
newa HEALTHCHECK with curl, which the aspnet:10.0 image does not containa healthcheck that opens /dev/tcp with bash, on the readiness probethe Java and the .NET runtime images have no curl, and ready means that the database is reachable Why
newno equivalentthe DocumentDB certificate bundle, mounted when the container runsthe image is the same in every environment, and holds no certificate Why
newno equivalentan exact version for every imageeveryone 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-environment
Reading the code
name: local-environment

This 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-dev

A 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:

ContainerImagePurposeHost port
mongomongo:8.0.34stands in for DocumentDB, as a single-node replica set27017
wiremockwiremock/wiremock:3.13.0stubs of the HTTP services the shop calls8089
flocifloci/floci:2.3.0SNS and SQS, with the API of LocalStack4566
aws-initamazon/aws-cli:2.27.0creates the topics, queues and subscriptions, then stopsnone
mock-oauth2ghcr.io/navikt/mock-oauth2-server:6.1.3issues tokens8090
YOUR MACHINE curl, a browser, the IDE use localhost and host ports DOCKER NETWORK local-dev order-service 8080, from its own repository mongo:27017 wiremock:8080 floci:4566 mock-oauth2:8080 aws-init runs init.sh once, then stops host ports: 8080, 27017, 8089, 4566, 8090
The local environment. curl and the IDE on the host use localhost and the host ports. Inside the Docker network local-dev, the order-service container reaches mongo, wiremock, floci and mock-oauth2 by name. aws-init runs init.sh against floci once.

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: 30
Reading the code
command: ["--replSet", "rs0", "--bind_ip_all"]

--replSet rs0 starts the server as a member of a replica set named rs0. --bind_ip_all makes 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 the catch calls rs.initiate, with one member named mongo:27017. On every later run, rs.status() succeeds, and the check only reports health.

interval: 5s

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

Gotcha

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"]
Reading the code
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 mappings and large bodies from __files under this folder. Each service that is stubbed has a folder of its own, such as mappings/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"] }
  }
}
Reading the code
"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 sku is OUT-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:

RequestAnswerThe stub that matched
a line with MUG-001, a token and an Idempotency-Key200 RESERVED, with a new reservation IDthe default answer, priority 10
a line with OUT-OF-STOCK200 INSUFFICIENT_STOCKthe product OUT-OF-STOCK, priority 1
a line with SERVICE-DOWN503the product SERVICE-DOWN, priority 1
no token401, with the header WWW-Authenticate set to Bearera 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.

Gotcha

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:4566
Reading the code
entrypoint: ["/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: local

The 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:4566

This 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
}
Reading the code
until aws sns list-topics >/dev/null 2>&1; do sleep 1; done

The 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 with condition: service_healthy, and the Floci container has no healthcheck.

queue() {

This defines a shell function named queue. Inside it, $1 is 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 text prints 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"
Reading the code
--attributes RawMessageDelivery=true >/dev/null

Raw 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_idSubjectScopes
agentlocal-agentorders.read orders.write
read-onlylocal-read-onlyorders.read
order-serviceorder-servicestock.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: true
Reading the code
docker compose up -d

-d runs the containers in the background, so the terminal is free.

mvn package && docker compose up --build

mvn package builds the jar, and runs the unit tests. The Dockerfile copies that jar, so it must exist first. --build builds the image again from the new jar.

SPRING_PROFILES_ACTIVE: local

This environment variable switches on the profile local, so Spring Boot also reads application-local.yml. It is like ASPNETCORE_ENVIRONMENT in ASP.NET Core.

external: true

This 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 mongo and wiremock by name.

The profile local holds the container names:

spring:
  mongodb:
    uri: mongodb://mongo:27017/orders?replicaSet=rs0

The 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:8089

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

PredictThe local MongoDB URI from the IDE#
A developer runs the service from the IDE with only this change to the local profile, keeping replicaSet=rs0. MongoDB runs in Docker, and port 27017 is open on the host. What happens when the service starts?
--spring.mongodb.uri=mongodb://localhost:27017/orders?replicaSet=rs0
Show the answer
The service never opens its HTTP port. The log repeats 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 hostResult
mongodb://localhost:27017/orders?replicaSet=rs0no HTTP port after 45 seconds, and an UnknownHostException for mongo in the log
mongodb://localhost:27017/ordersreadiness UP after 13 seconds
mongodb://localhost:27017/orders?directConnection=truereadiness 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 } ] }'
Reading the code
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 variable TOKEN. jq -r .access_token takes 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:

RequestAnswer
the token, as the client agenta token with iss http://localhost:8090/default, sub local-agent, aud order-service and the scope orders.read orders.write
GET /reference-data/DELIVERY_METHOD200, with the four delivery methods in code order, SECOND_CLASS_POST among them with active false
POST /orders201, with {"orderId": "...", "noteIds": ["..."]}
POST /orders with a read-only token403, with the title Forbidden and a traceId
POST /orders with OUT-OF-STOCK422, with the code PRODUCTS_UNAVAILABLE
POST /orders/{orderId}/notes/{noteId}/resolve, by the same agent422, 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.
Pragmatic choice: One shared local environment for all services

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"]
Reading the code
FROM eclipse-temurin:25.0.4.1_1-jre AS extract

The first stage only unpacks the jar. A Java Runtime Environment (JRE) is enough for that, because nothing is compiled here. AS extract names the stage, so the second stage can copy from it.

COPY target/order-service-*.jar app.jar

The image is built from the jar, not from the source code. So mvn package runs first, on the developer's machine or on the build server.

RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted

Spring Boot's tool splits the jar into four folders under extracted. --launcher keeps Spring Boot's launcher classes, so the result starts with a class name instead of a jar.

COPY --from=extract /build/extracted/dependencies/ ./

Each COPY makes 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's main method.

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 10001
Reading the code
RUN useradd --system --uid 10001 --no-create-home app

useradd creates a system user named app, with the fixed user ID 10001 and no home folder.

USER 10001

Every 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:

runtimeconfig.template.json
{
  "configProperties": {
    "System.GC.HeapHardLimitPercent": 75
  }
}
Dockerfile
# 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"
Reading the code
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=50 -XX:+ExitOnOutOfMemoryError"

The Java Virtual Machine (JVM) reads the environment variable JAVA_TOOL_OPTIONS when it starts, and adds its options. A run printed Picked up JAVA_TOOL_OPTIONS and showed both flags set.

-XX:MaxRAMPercentage=50

The 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:+ExitOnOutOfMemoryError

At the first OutOfMemoryError, the JVM stops the process.

Gotcha

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: 60s
Reading the code
exec 3<>/dev/tcp/localhost/8080

In bash, a redirection to the path /dev/tcp/host/port opens 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' >&3

The command writes a minimal HTTP request to the connection.

grep -q UP <&3"]

grep reads the answer, and succeeds only if it contains UP. So the check passes only when the service says that it is ready.

start_period: 60s

For 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:

ImageVersionWhere
mongo8.0.34the local environment, and MongoTestContainerConfiguration
wiremock/wiremock3.13.0the local environment
floci/floci2.3.0the local environment, and AwsTestContainerConfiguration
amazon/aws-cli2.27.0the local environment, and the README's payment command
ghcr.io/navikt/mock-oauth2-server6.1.3the local environment
eclipse-temurin25.0.4.1_1-jrethe 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.

Check yourself
1
An expert asksWhy does the order service keep its dependencies in a shared local-environment repository instead of in its own compose file?
Every service and the QA team need the same MongoDB, stubs, queues and token clients. One repository holds one copy of each, started once. A change to a stub is made once, and the Playwright tests run against the same setup as the developers. The cost is a second checkout, and the rule that a change adds its stub or queue in the same piece of work.
2
The local MongoDB runs with --replSet rs0. Why, when there is only one node?
MongoDB runs a transaction only on a replica set, and the service saves an order and its notes in one transaction. A replica set with one member is enough. The healthcheck initiates it on the first start.
3
An expert asksWhy does the image set -XX:+ExitOnOutOfMemoryError?
After an 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.
4
TranslateThis .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
  }
}
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:

TaskCommand
start the shared dependenciesdocker compose up -d, in local-environment
start the order service in Dockermvn package && docker compose up --build, in order-service
run the service from the IDEthe README's program arguments, with localhost and directConnection=true
get a token as an agentcurl -s -X POST localhost:8090/default/token -d grant_type=client_credentials -d client_id=agent -d client_secret=any
open Swagger UIhttp://localhost:8080/swagger-ui.html, in the local profile only
stop everythingdocker compose down, in each repository

The host ports of the local environment:

PortContainerInside the network
27017mongomongo:27017
8089wiremockwiremock:8080
4566flocifloci:4566
8090mock-oauth2mock-oauth2:8080
8080order-serviceorder-service:8080

The JVM under the service: memory#

Testing, shipping and changing it · Chapter 27· 23 min read

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.

KindC#JavaNote
twista memory limit on the container, counted on the whole processthe same limit, which the JVM cannot see comingthe kernel counts resident pages, sends SIGKILL at the limit, and no Java code runs afterwards Why
trapthe .NET heap takes 75 percent of a container by default, and that works-XX:MaxRAMPercentage=75 looked like the same thingthe limit counts the whole process, the JVM keeps about 220 MB outside the heap, and 384 plus 220 is more than 512 Why
twistthe GC heap, and the rest of the processthe Java heap, and the regions outside itmetaspace, the code cache, thread stacks and direct buffers have their own sizes and their own limits Why
twistpointer-bump allocation into generation 0pointer-bump allocation into a thread-local buffer in edenboth runtimes allocate by moving a pointer, and both keep young objects apart from old ones Why
twistworkstation GC, or server GC with one heap per CPUSerial, G1 or ZGC, chosen by the JVM from the container's CPUs and memorya container with one CPU gets Serial, and the service's pauses are then its business Why
twistOutOfMemoryException, and a dump with dotnet-dumpOutOfMemoryError, and a heap dump with jcmd GC.heap_dumpthe JVM reaches its own heap limit first only if the container's limit leaves room for it Why
newno equivalentexit code 137, OOMKilled true, and nothing in the logthe kernel killed the process at the container's limit, and no Java code ran afterwards Why
newno equivalentMaxDirectMemorySize, which defaults to the maximum heapdirect buffers are capped by the JVM at another heap's worth, outside the heap Why
twistdocker stats, and the kernel's cgroup filesNative Memory Tracking, read with jcmdthe 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 load

Each 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 itCommittedWhat is in it
Java Heap117 MB, of 384 reservedevery object, from a request's DTO to the cached reference data
Metaspace90 MBthe metadata of every loaded class, more than 20,000 of them in the service
Code58 MBthe machine code that the compilers made, and the interpreter's own
Symbol19 MBthe names of classes, methods and fields, shared by the whole JVM
Class16 MBthe compressed class space, which holds the class pointers that objects carry
Shared class space14 MBthe JDK's own classes, loaded from the class data sharing archive
Native Memory Tracking8 MBthe account itself
Thread7 MB, of 56 reservedthe stacks of the platform threads, 1 MB reserved each and only the touched pages committed
everything elseabout 6 MBGC 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:+UseSerialGC

MaxHeapSize 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.909ms

Read 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:

CollectorWhen the JVM picks itHow it pausesThe price
Serialone CPU, or less than 1,792 MBone thread does all the work, and every collection stops every threadpauses grow with the live data
G1two or more CPUs and at least 1,792 MBregions 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 pausesome memory for its bookkeeping, and CPU for the concurrent work
ZGConly with -XX:+UseZGCall the expensive work concurrently, with pauses under a millisecond that do not grow with the heap, generational since Java 24throughput, 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.056ms

The 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.449ms

Each 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 space

The 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: true

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

Pragmatic choice: Half the container for the heap

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:

ToolCommandWhat it costs
the container's viewdocker stats, or memory.current in the cgroupnothing
the JVM's accountjcmd 1 VM.native_memory summary, after -XX:NativeMemoryTracking=summary5 to 10 percent of CPU while tracking is on
the growth since a momentjcmd 1 VM.native_memory baseline, then summary.diffthe same
the heap's shapejcmd 1 GC.heap_infonothing
the GC log-Xlog:gc*:file=/tmp/gc.loga file that grows, rotated by default at 20 MB
the class histogramjcmd 1 GC.class_histogramone full collection
a heap dumpjcmd 1 GC.heap_dump /tmp/heap.hprofone full collection, and a file the size of the heap
the long-lived objectsa Flight Recorder recording with path-to-gc-roots=truea 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.

Check yourself
1
A 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?
The container's limit counts the whole process. The heap may grow to 384 MB. The service keeps about 220 MB outside the heap, in metaspace, the code cache, the class data and the thread stacks. The sum passes 512 MB before the heap is full, and the kernel kills the process with 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.
2
An 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?
The two limits race. The JVM throws 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.
3
A service's heap is flat, but its resident memory grows until the container kills it. Which two JVM accounts show where the memory went?
Native Memory Tracking, read with 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.
4
TranslateTranslate 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
  }
}
The same percentage, as a JVM flag in the image's JAVA_TOOL_OPTIONS.
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=60 -XX:+ExitOnOutOfMemoryError"
The JVM reads the flag at startup, and 60 percent of the container's limit becomes the maximum heap. Check first that the service's non-heap memory fits in the other 40 percent.

The JVM under the service: code, threads and the compiler#

Testing, shipping and changing it · Chapter 28· 35 min read

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.

KindC#JavaNote
twistCIL and metadata in a PE assembly, not verified for type safety at run timebytecode and a constant pool in a class file, verified when the class is linkedboth runtimes load a class the first time it is needed, and both keep the source's names in the binary Why
twista struct lives inline, and a class carries a 16-byte header on x64every object carries a 12-byte header, and a record is a classcompressed class pointers make the header 12 bytes and not 16, and compact headers make it 8 when switched on Why
twisttwo tiers, quick JIT and then the optimising JIT, with tiered PGOfive tiers, the interpreter, three forms of C1 and then C2the interpreter runs the first 200 calls, C1 the next thousands, and C2 compiles the method after 5,000 Why
trapReadyToRun code ships in the app, so the first minute is finenothing ships but bytecode, so the first minutes belong to the compilersthe run made 17,848 compilations in its first minute on one CPU, and the ahead-of-time cache is the answer Why
twistguarded devirtualisation, from the profile of tier 0inlining behind a class check, and deoptimisation when the check failsthe run had 516 deoptimisations, and none of them was in the service's own code Why
newno log of the stops-Xlog:safepoint, one line per stop with the time to reach itevery thread stops at a poll, for the collector and for a thread dump alike, and the slow request waited for one Why
twista thread is an operating-system thread, and async/await frees it while it waitsa platform thread is the same, and a virtual thread is an object that the JVM mounts on a carrierthe code stays blocking, and the thread under it is taken away while it waits Why
trapawait inside lock is a compile errorsynchronized inside a virtual thread pinned its carrier until Java 24Java 25 no longer pins on synchronized, and the recording of the service on virtual threads showed no pinned thread Why
twistdotnet-trace and dotnet-countersFlight Recorder, started with one flag and read with the jfr toolthe 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: areturn

The 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=209183Kb

The 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                              8

unstable_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 total

The 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:

OperationCountWhat asked for the stop
SerialCollectForAllocation176a young collection, because a TLAB could not be refilled
CollectForMetadataAllocation4a collection because metaspace needed room, during and just after startup
SerialGCCollect1a full collection, which the code cache asked for
GC_HeapInspection1the class histogram of scene 2, taken after the timed minute
PrintThreads and FindDeadlocks3 eachthe thread dump that the recording takes once a minute
ClassLoaderStatsOperation3class loader statistics for the recording
JFR operations4the recording's own bookkeeping
SetNotifyJvmtiEventsMode1the 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 ms

The 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 ms

The 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:

ThreadsCountWho made them
http-nio-8080-exec-1 to 1010Tomcat, ten kept ready even when idle, from a pool of up to 200
http-nio-8080-Acceptor and Poller2Tomcat, to accept connections and to wait for bytes on them
task-1 to 88the application task executor of Spring Boot, for @Async and the event listeners
scheduling-11the task scheduler, for @Scheduled
cluster-* and cluster-rtt-*2the MongoDB driver, to watch the server and to measure its round trip
aws-java-sdk-NettyEventLoop-1-0 and -12the AWS SDK, for its asynchronous HTTP client
sqsListenerEndpointContainer#0 ...3Spring Cloud AWS, to poll the SQS queue and to run the listener
HttpClient-1-*4the JDK HTTP client, for the calls to the other services
JFR Recorder Thread and JFR Periodic Tasks2the recording
Reference Handler, Finalizer, Common-Cleaner, Signal Dispatcher, and others18the 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.

Pragmatic choice: Tomcat on platform threads

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           4291

A 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:

QuestionCommand, inside the container, where the service is process 1Cost
is a recording running?jcmd 1 JFR.checknothing
start one for the next ten minutesjcmd 1 JFR.start duration=10m filename=/dumps/now.jfr settings=profileabout two percent
write what the running recording has so farjcmd 1 JFR.dump filename=/dumps/sofar.jfra file, and one safepoint
what is every thread doing?jcmd 1 Thread.dump_to_file -format=json /dumps/threads.jsonno 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 commandsnothing
what did the compilers do?jfr view compiler-statistics now.jfr, on your machinenothing in the container
what stopped the world?jfr view vm-operations now.jfr and jfr view safepoints now.jfrthe same
which bets failed?jfr view deoptimizations-by-site now.jfrthe same
who is pinned?jfr view pinned-threads now.jfrthe 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.

Check yourself
1
A 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.
Every method starts in the interpreter and is compiled only after hundreds of calls, so the code itself is slow at first. And the compiler threads share the CPU with the request threads while they compile thousands of methods, some of them several times. The fix is an AOT cache from a training run, which carries the loaded classes and the method profiles. Add a warm-up of each instance before it takes full traffic.
2
An expert asksWhy does the JVM throw away compiled code and run a method in the interpreter again, when the compiled code was correct?
The code was correct only for the cases the profile had seen. C2 inlines a call behind a check of the receiver's class, and compiles a branch never taken as a jump out. When a new class arrives or the branch is taken, the check fails. The JVM then rebuilds the interpreter frames from the compiled frame and continues there, and usually recompiles with the new profile. This is deoptimisation, and it is how the compiler is allowed to bet.
3
In 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?
The safepoint log from -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.
4
Tomcat 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?
The 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.
5
TranslateTranslate 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);
Java keeps the blocking call and changes the thread under it. Each task runs on a virtual thread, and the executor waits for all of them when it closes.
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#

Testing, shipping and changing it · Chapter 29· 26 min read· Quick reference

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.

KindC#JavaNote
newno equivalenta reading order for the partread the why first, then one request through the layers, then the rest as reference Why
newdotnet new from a team templatea new service from the parent POM, the platform library and seven stepsthe parent and the library give every service the same build, security, errors and logs Why
newno equivalentthe checklist for a new featuredecide that it is a feature and choose its level before you write code Why
newno equivalentchanging an older service feature by featuremove 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 methodthe permission goes on the controller method, and the use case carries none Why
newa new column with a default value in an EF Core migrationa field in the value object, the aggregate, the document, the mapper and the DTOsold documents lack the new field, so the mapper or a migration decides its value Why
newno equivalenta query method, a Spring Data method and a named index in a new migrationevery query needs an index, and only a migration creates one Why
newno equivalenta changed seed file and a new migration that upserts ita migration that has run anywhere is never edited, so the change is a new one Why
twista Refit interface, registered with AddRefitClienta port, an @HttpExchange client, an adapter, settings, a stub and a testthe other service stays behind a port, and its client never leaves the client package Why
newno equivalenta domain event, a V1 record with @Externalized, a mapper case, a schema and a topican event leaves the service only when the mapper says so, and its schema is a contract Why
newan IMessageHandler<T> from AWS.Messaginga record of the service's own and a listener that extends InboxMessageHandlereach consumed event is processed once, through the inbox, with its own queue and dead-letter queue Why
newno equivalenta new feature with its own packagesother features use it only through its application service Why
trapno equivalentan upgrade of Spring Boot or the platforma renamed property or class name still compiles and is then ignored, so run the wiring test after every upgrade Why
newno equivalentthe review checklist for a pull requestthe template asks five domain questions, and the reviewer adds ten technical ones Why
newno equivalent"why we do it this way", in short answerseach rule of the service has a reason, and a chapter that explains it Why
twista primary constructor in C# 12an explicit constructor, with no Lombokolder 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 names

Each 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:

  1. Why, and the shape. Read these three chapters:
  2. One request through the layers. Follow POST /orders from the controller to the database, in these chapters:
  3. The rules. Read these two chapters:
  4. The rest, as reference. Read the chapter on security, messaging, configuration or testing when you first change that part.
  5. 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:

  1. 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.
  2. 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.
  3. The first migration. One named index for each query, in V001.
  4. 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 in contracts/events/ for each event that the service publishes.
  5. 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.
  6. 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.
  7. 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 folder docs/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>
Reading the code
<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-service

The 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:

  1. 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.
  2. 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.
  3. Start in the domain, with plain JUnit. Write the aggregate and its tests first. They need no Spring and no database.
  4. 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.
  5. The migration. A new migration with the indexes for the feature's queries.
  6. 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:

  1. 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.
  2. 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.
  3. Move its rules into the domain, with tests. Each rule that moves gets its plain JUnit test.
  4. 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=false

Commit 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:

LayerFileWhat changes
domainorder/domain/model/Order.javaa method cancel(Clock) that checks the rule and raises OrderCancelled
domainorder/domain/event/OrderCancelled.javathe new domain event
domainorder/domain/exception/OrderFinishedException.javathe exception for a shipped or cancelled order, if it is new
domain testOrderTest.javaa nested group Cancelling, with the allowed case and each refusal
applicationorder/application/OrderService.javaa method cancel(OrderId) with @Transactional, which saves, publishes and audits
sharedshared/AuditActions.javathe name ORDER_CANCELLED
apiorder/api/OrderController.javaa method with @OrdersWrite and @PostMapping
api testOrderControllerTest.javathe success, each refusal with its status and code, and the 403 without the scope
contractcontracts/openapi.yamlthe drift test fails until you run mvn verify -Dopenapi.update=true and commit the file
event, if publishedOrderCancelledV1, a case in the mapper, its schemaonly 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();
}
Reading the code
@OrdersWrite

The 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 @ApiResponse line, 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:

LayerFileWhat changes
domainCustomerContact.java, or a new value object PhoneNumberthe field and its check, in the compact constructor
domainOrder.javaplace and rehydrate take the value, and the getter returns it
domain testValueObjectTest.java, OrderTest.javathe check of a valid and an invalid value
persistenceOrderDocument.javaa plain field, such as customerPhone
persistenceOrderDocumentMapper.javathe field in both directions, toDocument and toDomain
apiPlaceOrderRequest, OrderResponsethe field in the DTOs, with validation
apiOrderController.java or OrderApiMapper.javathe field in the translation
contractcontracts/openapi.yamlregenerated and committed
old datathe mapper, or a new migrationwhat 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:

LayerFileWhat changes
domainorder/domain/repository/OrderRepository.javaa method in the domain's types, such as findByDeliveryMethod
persistenceOrderMongoRepository.javaa Spring Data method on the document, if a derived query is enough
persistenceOrderPersistenceAdapter.javathe port's method, which calls the Spring Data method and maps the page
migrationa new migration, such as V005a named index for the new filter and sort
applicationOrderService.javaa method that returns a page of views
apiOrderController.javaa GET method with @OrdersRead, or a new filter on the list
teststhe adapter IT, the service test, the controller testthe 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:

LayerFileWhat changes
applicationorder/application/port/PriceChecker.javathe port, in the domain's types
clientorder/client/PricingHttpClient.javaan interface with @HttpExchange, one method for each endpoint
clientorder/client/dto/records for the other service's request and response
clientorder/client/PricingAdapter.javaimplements the port, calls the client through ResilientCalls, and translates
clientorder/client/PricingClientConfig.javabinds HttpClientProperties to clients.pricing and creates the client with HttpClientFactory
settingsapplication.yml and application-local.ymlclients.pricing.base-url, from an environment variable and from WireMock
settingsapplication.ymla client registration, such as pricing-service, and clients.pricing.token-registration-id, so each call carries a token
local environmentwiremock/mappings/pricing-service/a default stub, and stubs for the special cases
testPricingAdapterTest.javathe 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:

LayerFileWhat changes
domainorder/domain/event/OrderCancelled.javathe domain event, raised by the aggregate
messagingorder/messaging/event/OrderCancelledV1.javathe integration event, a record with @Externalized and plain types
messagingOrderIntegrationEventMapper.javaa case that turns the domain event into the integration event
contractcontracts/events/OrderCancelledV1.jsonthe JSON schema, for the consumers
local environmentaws-init/init.shthe topic, if the event goes to a new one
testOrderIntegrationEventMapperTest.javathe mapping, field by field
testPublishedEventSchemaTest.javathe domain event in publishedDomainEvents, so the JSON is checked against its schema
design notedocs/design/order.mdthe 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:

LayerFileWhat changes
messagingorder/messaging/PaymentReceivedV1.javathe service's own record of the event, with only the fields it uses
messagingorder/messaging/PaymentReceivedListener.javaextends InboxMessageHandler, with @SqsListener on a method that calls process
applicationOrderService.javathe use case that the listener calls, with no permission check
settingsapplication.ymlmessaging.queues.payment-received, the queue name
local environmentaws-init/init.shthe queue, its dead-letter queue and the subscription with raw delivery
contractcontracts/events/consumed-PaymentReceivedV1.jsonthe part of the producer's schema that this service relies on
testsPaymentReceivedV1Test, PaymentReceivedListenerTestthe JSON, and the translation into the use case
design notedocs/design/order.mdthe 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);
}
Reading the code
@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 handle once 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 style

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

Gotcha

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:

  1. Read the migration guide of the new Spring Boot version, and search the code for every key and class that it renames.
  2. Run mvn verify. The wiring test starts the whole service, and checks the results that depend on such names. Its test usesTheConfiguredMongoUri fails 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.
  3. Run mvn verify -Pit, against real MongoDB and Floci.
  4. 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:

QuestionWhy it mattersWhere it is explained
Is each class in the right feature and the right layer?a class in the wrong place pulls its dependencies with itthe 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 responsesthe 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 rerunsthe 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 DocumentDBthe 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 workingthe 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 schemathe chapter on contracts
Does each new listener extend InboxMessageHandler?the inbox processes a repeated message oncethe chapter on the inbox
Do the logs leave out personal data?a log line is kept and searched long after the requestthe 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 onethe 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 seethe 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:

QuestionShort answerChapter
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#

C# 12
[ApiController]
[Route("orders")]
public class OrdersController(IOrderService orders, OrderApiMapper mapper) : ControllerBase   // a primary constructor
{
}
OrderController.java
@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 formThe form in this partWhy it changed
Lombok annotations for constructors and accessorsexplicit constructors, and records for valuesthe code shows every line, and records need no Lombok
spring.data.mongodb.urispring.mongodb.uriSpring Boot 4 reads the new key and ignores the old one without a warning
@Retry and @CircuitBreaker annotations, with resilience4j settings in YAMLthe platform's ResilientCalls, with Resilience4j used directlythe platform gives every service the same retry rules, and tests them once
Mongock change unitsthe platform's own migration runnerMongock has no Spring Boot 4 artifact, as of October 2026
ArchUnit tests with @AnalyzeClasses and @ArchTest fieldsplain JUnit tests that call the platform's rulesthe rules ship in a library, take parameters, and need no extra test engine
a container in a static @Container fielda container as a bean in a shared test configurationthe container is declared once for every test class and service
mongo:5.0, wiremock:latest, or Floci with the tag latestan exact version of each image, the same in every placeeveryone runs the same thing, and an upgrade is a reviewed change
a separate query class and command class for each featureone application service for each area of a featurethe service has one entry point for each use case, with no CQRS split it does not need
an exception handler in each featurethe platform's one handlerevery error has the same problem shape, with a traceId
a shared folder in each service, until a library existsthe platform library from the startshared 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.

Check yourself
1
An 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?
The runner records each migration's ID once it has run. 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.
2
Which files does the order service touch to consume a new event from another service?
A record of the event with only the fields it uses, and a listener that extends 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.
3
An expert asksWhy does an upgrade of Spring Boot need the wiring test, when every unit test passes?
Some names are strings that the compiler cannot check, such as property keys and the class names in 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.
4
TranslateThe 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
{
}
@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:

WordWhat it meansChapter
aggregatea group of objects that changes as one, inside one transactionthe chapter on aggregates
aggregate rootthe one entity of an aggregate that outside code may usethe chapter on aggregates
entityan object with an identity that stays the same while its data changesthe chapter on the domain model
value objectan object defined only by its values, such as Money, and never changedthe chapter on the domain model
domain eventa record of something that happened in the domain, such as OrderShippedthe chapter on the domain model
integration eventa versioned event that leaves the service, such as OrderShippedV1the chapter on the outbox
domain servicea class for a rule that spans aggregates, such as ShippingPolicythe chapter on where rules go
application servicethe class with the use cases, which runs the steps in orderthe chapter on use cases
portan interface that the domain or application owns, for storage or another servicethe chapter on use cases
adapterthe class that implements a port with a real technologythe chapter on storage
anti-corruption layerthe code that translates another service's model into this onethe chapter on calling other services
viewa read-only shape of an aggregate that a use case returnsthe chapter on use cases
DTOa data transfer object, the request or response shape of the APIthe chapter on the web layer
beanan object that the Spring container creates and wiresthe chapter on the container
auto-configurationbeans that a library adds when its conditions holdthe chapter on the platform library
proxya generated subclass that runs code around a method, such as a transactionthe chapter on the container
optimistic lockingrefusing a save when the stored version changed after the loadthe chapter on storage
outboxstored events that are sent after the transaction commitsthe chapter on the outbox
inboxthe record of processed events, so a repeated one is skippedthe chapter on the inbox
idempotentgiving the same result when it runs twicethe chapter on calling other services
DLQa dead-letter queue, where SQS moves a message that failed too oftenthe chapter on the inbox
circuit breakera switch that stops calls to a failing service for a whilethe chapter on calling other services
migrationa numbered change to the database that runs oncethe chapter on migrations
seed datalookup values that a migration loadsthe chapter on migrations
MDCthe mapped diagnostic context, values added to every log line of a requestthe chapter on logging and health
trace IDthe ID that links the log lines and spans of one request across servicesthe chapter on logging and health
scopea word in a token that names one permission, such as orders.writethe chapter on security
replica seta group of MongoDB servers with one primary, which transactions needthe chapter on running locally
Testcontainersa library that runs a real dependency in Docker for a testthe chapter on testing
ArchUnita library that checks design rules on the compiled classesthe chapter on testing

The .NET names that the part maps, each with the Java form in the order service:

.NETThe order serviceNote
Program.csOrderServiceApplicationone class with a main method starts the service
NuGet feed of the companythe company's Maven artifact repositorythe parent POM and the platform library come from there
Directory.Build.propsthe parent POM, shop-platform-parentevery service inherits the build settings from it
Directory.Packages.propsdependencyManagement and BOMs in the parentversions are set once, so services write none
ASP.NET Core Web API controllers@RestController classes in the api packageseach method of the class maps one request to a use case
IOptions<T>@ConfigurationProperties classesa typed class bound from settings
Refit@HttpExchange interfacesSpring creates the client from the interface
EF Core migrationsMongoMigration beans and the platform's runnernumbered changes that run once, in order
an IMessageHandler<T>a listener that extends InboxMessageHandlerthe inbox adds processing once per event
WebApplicationFactory<Program>@SpringBootTestthe whole application in a test
ArchUnitNETArchUnitArchUnitNET is the C# fork of ArchUnit

DDD for a team: the playbook#

Testing, shipping and changing it · Chapter 30· 19 min read

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.

KindA usual .NET teamThe order service's teamNote
neweach developer designs what they builda small design group decides boundaries, aggregates, rules and workflowsthe decisions that are hardest to change are made by the most experienced people, with business experts Why
newdecisions in chat and in people's headsan ADR for each decision of a servicea decision written beside the code can be found, questioned and replaced later Why
newa ticket, and then the codea one-page feature design noteaggregates, states, rules with examples, events and partners are agreed before the code Why
newone style for every featuresimple, light or full, chosen in the design noteeach feature gets the least DDD that protects its rules Why
newany pattern that a developer likesallowed building blocks, and a short list that needs approvalevent sourcing, full CQRS, generic repositories, shared models and new sagas need the design group Why
newconventions on a wiki pageeight rules for every engineershort rules that each map to a test or a review question Why
twistan ArchUnitNET rule, if the team writes oneArchUnit rules from the platform library, in every servicethe build fails when code breaks a design rule Why
newa general code reviewa five-question pull request checklistthe reviewer asks the questions that the build cannot answer Why
newdotnet new webapi, then folders by kind of classa template feature to copya new feature starts with the right layout, tests and comments Why
trapa design that looks finishednine wrong decisions, each with its sign and its fixmost DDD failures are the same few decisions, and each one shows early Why
newno equivalenta workflow engine or a process manager for long flowsa flow across services and days keeps its state in one place Why
newa framework adopted by every team at oncea pilot service, training from its code, reviews and a weekly slotDDD 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.

DecisionWho decidesWhy
the boundaries of the servicesthe design group, with business expertsa wrong boundary means moving data between services later
the aggregates and what is inside each onethe design groupa wrong boundary means migrating stored documents
the business rules, and where each one goesthe design group, with business expertsa rule is only right if the business agrees with it
workflows across aggregates and servicesthe design groupa flow decides which data may disagree, and for how long
the level of a featurethe design group, in the feature design notethe level decides how much code the feature needs
everything inside those decisionsthe teamnames, 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.

Pragmatic choice: The light style as the default level

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 blockExample in the order serviceTaught in
aggregate root, entity, value object, typed IDOrder, OrderLine, Money, OrderIdthe chapter on the domain model
domain event, recorded by the aggregateOrderShippedthe outbox chapter
domain service named after a rule across aggregatesShippingPolicythe chapter on rules
repository port for one aggregate, with an adapterOrderRepository and OrderPersistenceAdapterthe chapter on storage
application service with steps only, returning viewsOrderService and OrderViewthe use-case chapter
integration event with one mapper per featureOrderShippedV1 and OrderIntegrationEventMapperthe contracts chapter
port and adapter for another serviceStockChecker and StockAdapterthe chapter on other services
the simple level, with no building blocksthe reference featurethe 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 Repository is 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.

  1. Change state only through a method of the aggregate. Never add a public setter to an entity. A test checks it.
  2. 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.
  3. Record an event for every change that matters to someone. The aggregate records it, and the save step publishes it.
  4. Refer to other aggregates by ID. Never hold another aggregate as a field.
  5. Use another feature only through its application service. Never use its repository, its entities or its domain package. A test checks it.
  6. Change one aggregate in a transaction, and call other services before it starts. An exception is written down as a decision.
  7. Use the business words. A class or method named after a business idea, never Manager, Processor or Helper.
  8. 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#

ArchUnitNET 0.13.4
IArchRule domainIsPlain = Types().That().ResideInNamespaceMatching(@"Shop\..*\.Domain")
    .Should().NotDependOnAnyTypesThat().ResideInNamespaceMatching(@"Microsoft\.EntityFrameworkCore.*")
    .Because("a domain namespace is plain C#");
The platform library
/** 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 permissions

A 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 decisionWhat it looks likeHow to prevent it
aggregates copied from old tablesone aggregate for each table of the old database, with the same columnsfind the aggregates from the rules and the events, in the design group, before looking at the old schema
an aggregate that is too biga list with no limit inside, frequent 409 responses, large documentsask for the maximum of every list, and move a list with no limit to its own aggregate
an aggregate that is too smallevery use case saves the same two aggregates, or a rule needs a lock across themmerge them when a rule must change both every time
rules drifting into servicesan if in an application service that a business expert would recognisethe review question about rules, and a test of each rule on the aggregate
a batch job or loader that bypasses the entitya script or job that writes documents directlyjobs call the use case, and the stored document class is not visible outside its package
forgotten eventsa state change with no event, found when another service misses itthe aggregate records the event in the method, and the save step always publishes
features reaching into each othera service that takes another feature's repository or entitythe architecture test, and calls only through the other feature's application service
jargonnames such as OrderManager, process, handle or updatethe design note names the methods in business words, and the review checks them
gold-platingevent sourcing, CQRS or a full model for a feature with no rulesthe 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.

Gotcha

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

Check yourself
1
Which decisions does the small design group make, and which does the team make?
The design group decides the service boundaries, the aggregates and the business rules, with business experts. It also decides the workflows and the level of each feature. The team decides everything inside those decisions.
2
What does a feature design note contain?
The aggregates and what is inside each one, and the states with the allowed changes. Each rule with an example. The events raised, and the features and services it talks to.
3
An expert asksWhy does the team fix the template when people keep making the same mistake?
A mistake that many people make comes from the way work starts, not from one person. A template that shows the right way stops the mistake for everyone. So does a test that fails for the wrong way, even for people who join later.
4
An expert asksWhy does a batch job that updates documents directly break a DDD service?
It skips the methods of the aggregate, so no rule is checked and no event is recorded. Other services never hear about the change. Every job must load the aggregate and call its method through the use case.
5
TranslateWrite this ArchUnitNET rule as an ArchUnit rule in Java: 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.
6
Name four building blocks that need the approval of the design group.
Event sourcing, full CQRS, generic repositories, a shared domain library, models shared between features, and sagas outside the chosen workflow approach. Any four of these.

The words#

Reasoning from first principles · Chapter 31· 17 min read

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#

Reasoning from first principles · Chapter 32· 19 min read

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#

What it says

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.

Why it holds

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.

When it stops holding

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#

What it says

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.

Why it holds

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.

When it stops holding

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#

What it says

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.

Why it holds

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.

When it stops holding

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#

What it says

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.

Why it holds

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.

When it stops holding

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#

What it says

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.

Why it holds

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.

When it stops holding

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#

What it says

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.

Why it holds

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.

When it stops holding

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#

What it says

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.

Why it holds

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.

When it stops holding

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#

What it says

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.

Why it holds

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.

When it stops holding

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#

What it says

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.

Why it holds

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.

When it stops holding

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#

What it says

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.

Why it holds

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 it stops holding

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#

What it says

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.

Why it holds

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.

When it stops holding

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#

What it says

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.

Why it holds

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.

When it stops holding

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#

What it says

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.

Why it holds

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.

When it stops holding

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#

What it says

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.

Why it holds

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.

When it stops holding

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#

What it says

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.

Why it holds

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.

When it stops holding

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#

What it says

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.

Why it holds

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.

When it stops holding

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#

What it says

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.

Why it holds

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.

When it stops holding

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#

What it says

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.

Why it holds

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.

When it stops holding

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#

What it says

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.

Why it holds

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.

When it stops holding

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.

  1. Say the question as a choice between two options.
  2. Find the rule or the data that the question is about. Ask who writes it, and when it must be true.
  3. Find the principle that decides it. Most questions need one principle. A hard question needs two principles that pull in different directions.
  4. 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#

Reasoning from first principles · Chapter 33· 24 min read

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?
#
A rule that must be true after every change, and the data that the rule needs. That data, saved in one transaction, is the aggregate.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Yes. Every entity belongs to exactly one aggregate. Many aggregates hold only one entity, the root, and that is normal.
Why

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

Where it shows

The note is an aggregate of one entity. See the section Why order and note are separate aggregates.

When the answer changes

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?
#
Not in the domain. Load the root and call its method. The root checks the rules that cover its parts.
Why

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.

Where it shows

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.

When the answer changes

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.

Can two aggregates share one entity?
#
No. Each aggregate refers to the other one by its ID, or keeps its own copy of the values that it needs.
Why

Decided by Every fact has one writer and The root guards the rule.

A shared entity has two roots that may change it. Each root guards its own rules, and each can break the rules of the other root. A save of one aggregate would also change the data of the other aggregate, outside its version.

So one aggregate owns the entity. Another aggregate that needs it holds the ID, or a copy of the few values that it uses.

Where it shows

A note holds the order's ID as text, and it never reaches the order. See the section Refer to another aggregate by its ID.

When the answer changes

A copy of a value is not a shared entity. An order keeps the customer's contact as a value object. If the customer later changes an e-mail address, the order keeps the old one. That is usually what the business wants.

Which entity of an aggregate is the root?
#
The one that the business names, and that outside code finds by its ID. Nobody refers to an order line without its order.
Why

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.

Where it shows

See the section The order aggregate, piece by piece.

When the answer changes

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?
#
As small as its "now" rules allow. Each extra piece slows every change, and adds conflicts between users.
Why

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.

Where it shows

The order holds at most 50 lines and its last payment. Its notes are outside. See the section The order aggregate, piece by piece.

When the answer changes

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?
#
The aggregate is probably too big. Unrelated changes share one version, so they make each other fail.
Why

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.

Where it shows

See the section Signs of an aggregate that is too big. The platform answers every conflict with 409.

When the answer changes

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?
#
Yes. Move the data with a migration, and keep each rule with a count or an event. It costs a migration, not a rewrite.
Why

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.

Where it shows

See the section How to split or merge an aggregate.

When the answer changes

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?
#
No. Load the whole aggregate. The root can check its rules only when it sees all the data they need.
Why

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.

Where it shows

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.

When the answer changes

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?
#
An object reference lets code load, change and save two aggregates as one. An ID keeps each aggregate loaded and saved on its own.
Why

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.

Where it shows

See the section Refer to another aggregate by its ID.

When the answer changes

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?
#
In the strict full style, yes, such as OrderRef. The order service keeps such IDs as text, and records that choice.
Why

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.

Where it shows

See the section A type for a foreign ID, or plain text.

When the answer changes

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?
#
In a domain service named after the rule. The use case gives it the data, often only a number.
Why

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.

Where it shows

ShippingPolicy.assertCanShip(order, notesNeedingAction) checks that no note needs action. See the section Worked example: no shipping while a note needs action.

When the answer changes

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?
#
Usually not. Vernon (2011) states the rule and names exceptions. The order service uses one, a new order with its first notes.
Why

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.

When the answer changes

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?
#
Through a domain event. The first aggregate commits. Then a listener changes the second aggregate in its own transaction.
Why

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.

When the answer changes

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?
#
The owner of each aggregate decides, after an event. Nothing deletes another aggregate in the same transaction.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Ask whether anyone must name it as it changes. A picked line is an entity. A replaced payment is a value object.
Why

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.

When the answer changes

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?
#
No. A change is a new value that replaces the old one. So no code can change a value behind the root's back.
Why

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.

Where it shows

The value objects of the order service are Java records, and their fields cannot be reassigned. See the section Value objects as records.

When the answer changes

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?
#
For the core feature, yes. The compiler then refuses a line ID where an order ID belongs.
Why

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.

When the answer changes

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?
#
No. Make a value object when the value has a rule or a format, such as money. Text with no rule stays text.
Why

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.

When the answer changes

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?
#
The request check helps the client. The value object's check protects every other path in, such as a message, a migration or another feature.
Why

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.

When the answer changes

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?
#
A domain service holds domain logic that fits no single entity or value object. An application service runs a use case's steps.
Why

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.

When the answer changes

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?
#
No. A feature with no rules needs no model. The anaemic model is wrong when entities exist, but their rules are elsewhere.
Why

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.

When the answer changes

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?
#
No. The use case makes the call and passes the answer in. The aggregate decides what the answer means.
Why

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.

When the answer changes

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?
#
Not from the system clock. Each method that needs the time receives a Clock, so a test can fix it.
Why

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.

Where it shows

See the section The time comes from an injected Clock.

When the answer changes

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?
#
No. Permissions are checked where a user's request enters. The aggregate checks only rules about one record, with the actor's ID passed in.
Why

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.

Where it shows

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.

When the answer changes

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?
#
No. One repository for each aggregate, for its root. Inner entities are loaded and saved with their root.
Why

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.

Where it shows

OrderRepository stores orders, and there is no repository for lines. See the section The repository port.

When the answer changes

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?
#
In the full style, no. It lets code reach past the root. Each aggregate gets one port with only the queries it needs.
Why

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.

Where it shows

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.

When the answer changes

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?
#
A few queries are fine. Many screen-shaped queries show that the reads want a model of their own.
Why

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.

Where it shows

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 the answer changes

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?
#
In a document database, one document for each aggregate. One write then saves the whole aggregate, with one version.
Why

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.

When the answer changes

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?
#
Yes, if it can change. Without one, two edits of the same copy both succeed, and one of them is lost without a word.
Why

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.

When the answer changes

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?
#
In the full style, a separate document class and a mapper. In the light style, the entity is the document.
Why

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.

When the answer changes

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?
#
The aggregate, inside the method that changes the state. The use case only publishes what the aggregate recorded.
Why

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.

Where it shows

See the section Domain events: collected by the aggregate.

When the answer changes

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?
#
In the order service, yes. Each method that changes an order records one, and an internal event costs one small record class.
Why

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.

Where it shows

LinePicked is recorded, although no other service receives it. See the table in the section Domain events: collected by the aggregate.

When the answer changes

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?
#
No. Give each feature the least DDD that still protects its rules. That is the full, light or simple level.
Why

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.

When the answer changes

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?
#
With a plain unit test. Build the aggregate, call its method, check the state and the events. Use a fixed clock and no mocks.
Why

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.

When the answer changes

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#

Reasoning from first principles · Chapter 34· 28 min read

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?
#
Customers choose you for it. It makes you different from competitors. Payments are critical, but they are rarely core.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Writers disagree. This book treats it as core work, because a small rule that wins customers is easy to lose. That is a choice.
Why

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.

Where it shows

The order service does not have such a rule. For the argument in full, see the Atlas chapter Problem space, solution space.

When the answer changes

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?
#
No. Buy it, or use an open-source product. Spend your own effort where you differ from competitors.
Why

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.

Where it shows

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.

When the answer changes

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?
#
No. Revisit it when the market, a vendor or the strategy changes. A chart from three years ago describes another company.
Why

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.

Where it shows

The order service has no such change yet. For the trap of classifying once, see the Atlas chapter Problem space, solution space.

When the answer changes

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?
#
No. A subdomain is a part of the business problem. A bounded context is a part of the solution, where one model holds.
Why

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.

Where it shows

The order service is one bounded context. It serves the ordering part of the shop. See the Atlas chapter Problem space, solution space.

When the answer changes

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?
#
Yes, inside the model. Classes and methods use the experts' words, such as ship and cancel, never update or process.
Why

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.

Where it shows

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.

When the answer changes

Adapters and technical code use technical words, such as document and response. The rule is for the model. When the experts use two words for one thing, choose one, and record the choice.

Two teams use the same word with two meanings. Do we need two models?
#
Yes, if the rules differ. Inside one context, agree on one word. Across contexts, each side keeps its word, and an adapter translates.
Why

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.

Where it shows

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.

When the answer changes

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?
#
No. Keep one glossary for each bounded context. A company-wide glossary forces one meaning and hides real differences.
Why

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.

Where it shows

The book has no glossary for each context. For the language of a context, see the Atlas chapter Drawing the borders.

When the answer changes

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?
#
No. A schema shows how data is stored, not what happens in the business. Start from the events and the words.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Experts from each business area, not only developers. Without them, the wall shows how the code works, not how the business works.
Why

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.

Where it shows

The order service starts after this work is done. See the Atlas chapter The workshop with the orange stickies.

When the answer changes

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?
#
Listen for words that change meaning, and look for rules that change together. A border goes where both point.
Why

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.

Where it shows

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 answer changes

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?
#
As big as one consistent language, and no bigger. Lines of code and numbers of classes are the wrong measure.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Yes, along a smaller context that is nested inside it. Start with one service for each context. One model never covers two contexts.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Yes, but only as separate modules with enforced borders. This is called a modular monolith. It is the cheapest form to change later.
Why

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.

Where it shows

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.

When the answer changes

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?
#
No. A team may own several contexts. But one context should have one owning team, or its model splits in two.
Why

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.

Where it shows

For how teams and contexts match, see the Atlas chapter You ship your org chart.

When the answer changes

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?
#
No. Each context models the customer that it needs. One context owns each fact, such as the address, and publishes it.
Why

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.

Where it shows

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.

When the answer changes

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?
#
It shows, for each border, which side decides and how the other side copes. So you know whom to ask before a change.
Why

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.

Where it shows

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.

When the answer changes

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.

Still arguedIs a shared kernel, a small model that two contexts own together, ever a good idea?
#
Writers disagree. This book avoids it, and shares contracts instead. That is a choice. If you use one, keep it tiny.
Why

Decided by Every fact has one writer and What changes together stays together.

Eric Evans, in Domain-Driven Design (2003), lists the shared kernel as a valid pattern. It fits two teams that work closely and share a small, stable core. This side uses the principle that what changes together stays together. The principle that every fact has one writer allows this case, when the shared part is small.

Vaughn Vernon, in Implementing Domain-Driven Design (2013), treats it as a last resort. In practice the kernel grows, the teams drift apart, and the kernel becomes the company-wide model that contexts exist to prevent. This side uses the principle that every fact has one writer. It doubts that the shared part stays small enough for that exception.

Both sides agree that it is safe only while it is small and owned together.

Where it shows

The shop's services share no business classes. They share contracts, and only technical base classes come from the platform. See the chapter Writing the platform library, and in it the section What never goes in the platform library.

When the answer changes

Two teams that always plan together and change the kernel rarely can use one. The cost is a meeting for every change, and a kernel that a reorganisation can orphan.

Should we translate the other side's model at the border, or use it as it is?
#
Translate when you do not control their model and its words differ from yours. Use it as it is when it already fits your rules.
Why

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.

Where it shows

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.

When the answer changes

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?
#
In the adapter that implements your port. The other side's data transfer objects stay in that package, and only your types leave it.
Why

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.

Where it shows

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.

When the answer changes

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?
#
No. Offer one documented interface for everyone. This is called an open host service. Each consumer adapts to it.
Why

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.

Where it shows

The order service publishes one API description and one schema for each event. See the section Why the service publishes contracts.

When the answer changes

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?
#
No. Publish a separate form made for the outside, and map to it. Then the inside can change every day.
Why

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.

Where it shows

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.

When the answer changes

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?
#
No. Share a schema, not code. Keep your own copy of only the fields that you read.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Ask for a customer and supplier relationship, where your needs enter their planning. If that fails, protect yourself with an anti-corruption layer.
Why

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.

Where it shows

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 answer changes

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?
#
When the two contexts must change together for one goal, and both teams can plan together. Review it when the shared goal ends.
Why

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.

Where it shows

The order service has no partnership. For the patterns, see the Atlas chapter The map of treaties.

When the answer changes

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?
#
When integration costs more than doing a little work twice. This is called separate ways, and it is a fair choice.
Why

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.

Where it shows

The order service has no such case. For the pattern, see the Atlas chapter The map of treaties.

When the answer changes

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?
#
The same names, but only the packages that the feature needs. A package name then means the same thing in every feature.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Mostly in names and drawings. All three keep the domain at the centre, and every dependency points toward it.
Why

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.

Where it shows

The order service uses the same ports and adapters, with fewer folders. See the section Why not adapter/in and adapter/out.

When the answer changes

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?
#
Writers disagree. The order service uses it only in its full-style feature. That is a choice made for each feature.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Features. Then one business change touches one folder. Layers go inside each feature.
Why

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.

Where it shows

The order service has the top-level packages order, note and reference. See the section Features at the top, never technologies.

When the answer changes

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?
#
Use slices for features with independent use cases. Keep one shared model when several use cases change the same aggregate.
Why

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.

Where it shows

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.

When the answer changes

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?
#
No. Add an interface at a real border, such as storage or another service, or where a second implementation exists.
Why

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.

Where it shows

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.

When the answer changes

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?
#
At the edge that owns them. Request records in the API package, the other service's records in the client package. The domain sees none.
Why

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.

Where it shows

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.

When the answer changes

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?
#
It is the price of letting each shape change alone. Remove a mapping when it only copies fields of the same name.
Why

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.

Where it shows

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.

When the answer changes

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#

What may a shared package inside one service hold?
#
Helpers that every feature uses in the same way and that hold no rule, such as permission annotations. Never business logic.
Why

Decided by What changes together stays together.

Code in a shared package changes for every feature at once. A helper with no rule changes rarely, so that is cheap. A business rule changes with one feature. In the shared package, it would tie every feature to that change.

Where it shows

The order service's shared package holds the permission annotations, the audit names and the OpenAPI description. See the section The shared package, and what stays out of it.

When the answer changes

Move a class out of the shared package when it starts to make a business decision. Also move it when one feature needs a change that the other features do not need.

Two features in one service call each other. Is that a problem?
#
Yes, a warning sign. They cannot change, be tested or move apart. Make one call one-way, use an event, or merge them.
Why

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.

Where it shows

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.

When the answer changes

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?
#
With a test that fails the build. A border that only code reviews protect is broken sooner or later.
Why

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.

Where it shows

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.

When the answer changes

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?
#
By business area, such as fulfilment or payment. Not into a read class and a write class.
Why

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.

Where it shows

The order service plans to split OrderService by area at about ten use cases. See the section Splitting a large application service.

When the answer changes

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#

Reasoning from first principles · Chapter 35· 34 min read

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?
#
An event says what already happened, and anyone may listen. A command asks one named receiver to act, and that receiver may refuse.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Yes. It is a command that looks like an event. Publish the fact, such as OrderShipped, and let the invoice service decide.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Only while one team owns the publisher and every consumer, and they deploy together.
Why

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.

Where it shows

The order service declares OrderShipped in order/domain/event. It declares OrderShippedV1 in order/messaging/event. See the section Domain events and integration events.

When the answer changes

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?
#
No. Publish an event only when another service needs it. Adding an event later is easy, and removing one is hard.
Why

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.

Where it shows

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 the answer changes

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?
#
No. Publish business facts, such as OrderShipped. A field change makes every consumer guess what happened in the business.
Why

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.

Where it shows

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.

When the answer changes

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?
#
The order service sends IDs and the facts its consumers need. It is a choice between two costs, and the field has not settled it.
Why

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.

Where it shows

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 answer changes

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?
#
Then you chose notification events. Choose that on purpose, or put the facts that the consumer needs into the event.
Why

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.

Where it shows

The Atlas shows the cost of thin events in the chapter Something happened.

When the answer changes

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?
#
Only what a consumer needs to do its job. Every copy must be protected, and deleted when the customer asks.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Its own event ID and the business ID, such as the order ID. Add a version from one writer when consumers must order events.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Until the last consumer of V1 has moved to V2. Consumers then still read V1 until no V1 message is left in any queue.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Keep a list of the consumers next to each schema. Let their expectations run as tests in your own build.
Why

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.

Where it shows

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.

When the answer changes

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?
#
No. Ignore fields that you do not know, and require only the fields that you use.
Why

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.

Where it shows

The order service's PaymentReceivedV1 ignores unknown fields, and its compact constructor refuses a missing one. See the section Tolerant readers.

When the answer changes

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?
#
Yes. The event is lost when your process dies between the save and the send, and no broker can prevent that.
Why

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.

Where it shows

See the section The dual-write problem. The order service uses the event publication registry of Spring Modulith as its outbox.

When the answer changes

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?
#
Both are correct. Change data capture reads the database's log, so nothing polls. But it needs more infrastructure, and it must read only the outbox.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Not in the order service. The platform refuses, because the event would be stored but not sent.
Why

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.

Where it shows

See the section Publishing outside a transaction.

When the answer changes

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?
#
After the commit, in most cases. Then it never acts on a change that was rolled back.
Why

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.

Where it shows

The order service counts shipped orders with @ApplicationModuleListener, which runs after the commit. See the section Reacting inside the service.

When the answer changes

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?
#
Not from end to end. You get at-least-once delivery, and an idempotent consumer turns it into exactly-once effect.
Why

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.

Where it shows

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.

When the answer changes

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?
#
No. Make the consumer ignore a late event, by comparing a version or a time that one writer set.
Why

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.

Where it shows

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.

When the answer changes

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?
#
After a fixed number of attempts, the queue moves it to a dead-letter queue. Someone must watch that queue.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Find the cause, fix the code or the data, then move the messages back. Never delete a message that nobody has read.
Why

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.

Where it shows

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.

When the answer changes

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?
#
No. The copy is read-only. To change the data, ask the owner, and wait for its event.
Why

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.

Where it shows

The Atlas shows a table with many writers in the chapter Nobody owns the product table.

When the answer changes

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?
#
In the use case and the domain. The listener only translates the message and calls one use case.
Why

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.

Where it shows

The payment listener turns the event into value objects and calls recordPayment. See the section A thin listener.

When the answer changes

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?
#
With events for facts, and with a call only when you need an answer now. The order service calls the stock service for that reason.
Why

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.

Where it shows

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.

When the answer changes

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?
#
The request works only when all four are up and fast. Ask which answers are needed now, and keep a copy of the rest.
Why

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.

Where it shows

The Atlas tells this story in the chapter The night everything deployed together.

When the answer changes

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?
#
Long enough for a slow but normal answer. All attempts together, with their waits, must end before your own caller gives up.
Why

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.

Where it shows

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 the answer changes

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?
#
Only failures that may pass by themselves, with a longer wait before each retry. Never retry a request that is wrong.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Yes. A retry handles a short failure. A circuit breaker stops calling a service that keeps failing, so callers fail fast.
Why

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.

When the answer changes

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?
#
One per service, in most cases. A service that is down is down for all its endpoints.
Why

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.

Where it shows

The platform keeps one breaker for each dependency name, such as stock. See the section One circuit breaker for each dependency.

When the answer changes

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?
#
A 503, which says that a later try may work. Do not name the failed dependency in the response.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Usually not. It has the coupling of a call, plus a broker, plus a reply that may never come.
Why

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.

Where it shows

See the Atlas chapter The payment taken twice.

When the answer changes

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?
#
No. Each service commits on its own. Make each step safe to repeat, and handle failure with a timeout or an undo step.
Why

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.

Where it shows

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.

When the answer changes

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?
#
When one flow changes data in several services, and a failed step must undo the earlier steps. One step with a timeout needs no saga.
Why

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.

Where it shows

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.

When the answer changes

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?
#
No. A rollback makes a change disappear. A compensation is a new business action, such as a refund, after others saw the first step.
Why

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.

Where it shows

The Atlas tells the story of a flow with undo steps in the chapter Who says the order is done?.

When the answer changes

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?
#
The order service has no long flow. The team picks one style when the first one appears. The field is still split.
Why

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.

Where it shows

See the section Long flows: a workflow engine or a process manager, and the Atlas chapter Who says the order is done?.

When the answer changes

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?
#
Every waiting state needs a timeout and a planned outcome. Without one, the flow waits for ever, and nobody is told.
Why

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.

Where it shows

The stock service releases a hold that no OrderPlacedV1 confirms, after a timeout. See the section Between services: no shared transaction.

When the answer changes

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?
#
No. It keeps only the state of the flow. Each service checks its own rules.
Why

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.

Where it shows

A process manager keeps the state of a sequence of steps. See the section Long flows: a workflow engine or a process manager.

When the answer changes

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?
#
For one or two flows, a process manager in your own code. For many long flows, a workflow engine. Choose once, for the team.
Why

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.

Where it shows

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.

When the answer changes

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?
#
No. Give the halfway state a name, such as "payment pending". The business usually has a word for it already.
Why

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.

Where it shows

See the Atlas chapter Who says the order is done?.

When the answer changes

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?
#
Ask the business what happens today when the rule is broken for a minute. A phone call or a refund means yes.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Show the result from the answer of the save, or tell the user that the change is coming. Never hide the delay.
Why

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

Where it shows

The Atlas shows this delay in the chapter Two models are cheaper than one.

When the answer changes

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?
#
The order service uses one model for both. That is a choice, and the field disagrees about how often a second model pays.
Why

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.

Where it shows

The order service reads an aggregate and turns it into a view. See the section One model for reads and writes: CQRS or not.

When the answer changes

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?
#
No. A read model can be fed by ordinary events, or built from the same database. Event sourcing is a separate decision.
Why

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.

Where it shows

See the Atlas chapter Two models are cheaper than one.

When the answer changes

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?
#
No. The read model may be out of date at exactly the moment that matters. Check the rule on the aggregate that owns the data.
Why

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.

Where it shows

See the Atlas chapter Two models are cheaper than one.

When the answer changes

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?
#
It must be possible to delete it and build it again from its source.
Why

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.

Where it shows

See the Atlas chapter Two models are cheaper than one.

When the answer changes

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?
#
Avoid it. Put the facts that the read model needs into the events, or keep a copy from other events.
Why

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.

Where it shows

See the Atlas chapter Two models are cheaper than one.

When the answer changes

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?
#
Keep a read model that the three services' events fill. Calling three services for each row does not work for a list.
Why

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.

Where it shows

See the Atlas chapter The report nobody could run.

When the answer changes

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?
#
One backend made for it, owned by the app's team. It makes the calls near the services and returns one answer shaped for the screen.
Why

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.

Where it shows

See the Atlas chapter The report nobody could run.

When the answer changes

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?
#
Each service publishes its facts, and a reporting store copies them. Never give reporting a user on every service's database.
Why

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.

Where it shows

See the Atlas chapter The report nobody could run.

When the answer changes

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?
#
The order service stores current state and keeps an audit log. That is a choice. Event sourcing pays where the business asks about history.
Why

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.

Where it shows

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.

When the answer changes

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?
#
No. Your stored events are your private history. Publish separate integration events for other services.
Why

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.

Where it shows

See the Atlas chapter The ledger never lies.

When the answer changes

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?
#
No. An audit log records what happened next to the state. In event sourcing, the events are the state.
Why

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.

Where it shows

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.

When the answer changes

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#

Reasoning from first principles · Chapter 36· 28 min read

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?
#
Start as one deployable with enforced modules, unless the team already knows the borders and has a platform for many services. That is a choice.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Each module owns its data. No module reads or writes the data of another module, even inside one database.
Why

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.

Where it shows

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.

When the answer changes

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 does one more service cost, apart from its code?
#
Its own pipeline, on-call rota, contract, network failures and tracing. This cost is called the microservices premium.
Why

Decided by Every boundary has a price.

Each service is a border, and every border has a price. Martin Fowler named this price the microservices premium in 2015. The premium is paid for every service, whether or not that service needed to be separate.

The premium gives independent deployment and team autonomy. It is worth paying only when those two things are worth more to you than the premium.

Where it shows

Inside one deployable, the order service would have no timeouts, retries or remote calls. Its outbox would be smaller, because only in-process listeners would read it, such as the one that counts orders. Logging, tracing and health checks would still exist. See the chapters Calling other services and Publishing events: the outbox.

When the answer changes

A border that a law or a security rule requires, such as one around card data, must be paid for whatever it costs. Then the premium is fixed, and the only question is how to keep it small.

What is a good reason to move a module into a service of its own?
#
A measured need, such as other modules blocking its releases, a different scaling need, or failures that must stay apart.
Why

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.

Where it shows

The Atlas writes such triggers down with the decision, in the chapter The consultant who said microservices.

When the answer changes

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?
#
No. Splitting a mess gives several messes that call each other over a network. Find the business borders first.
Why

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.

Where it shows

See the Atlas chapters Let's do microservices and The night everything deployed together.

When the answer changes

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?
#
When they always change and ship together, and one team owns both. The border is in the wrong place.
Why

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.

Where it shows

The Atlas merges two services in the chapter The consultant who said microservices.

When the answer changes

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?
#
Big enough that most changes stay inside it, and small enough that one team can own it. Lines of code are the wrong measure.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Usually not. Each real operation then calls several services. Split by what the business does, not by its nouns.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Give it its own data, and let other features call it only through its application service. A move then changes a call, not a model.
Why

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.

Where it shows

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.

When the answer changes

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?
#
They must deploy together, share a database schema, or fail as a chain. You pay for many services and get the coupling of one.
Why

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.

Where it shows

See the Atlas chapter The night everything deployed together.

When the answer changes

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?
#
Merge where services always change together. Where they change apart, remove the shared schema and the call chains. That is a judgement.
Why

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.

Where it shows

See the Atlas chapter The night everything deployed together.

When the answer changes

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?
#
Each service needs private data that others reach only through its API. A private schema is enough until scaling or failure needs a separate server.
Why

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.

Where it shows

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.

When the answer changes

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.

Can two services share one database "for now", to move faster?
#
Only with one owner for each table and a date to end it. A shared schema is a contract that nobody can change alone.
Why

Decided by Every fact has one writer and A published contract is hard to take back.

When two services read the same table, its columns are a published contract between them. A rename needs both teams, a meeting and a joint release.

When two services write the same table, each one can break the other's rules. "For now" has no end date unless someone writes one.

Where it shows

See the Atlas chapters Let's do microservices and Nobody owns the product table.

When the answer changes

During a migration from a monolith, a shared table is normal for a while. The plan then names the last reader of the old table, and the date it goes.

Is a service allowed to keep a copy of data that another service owns?
#
Yes, if the other service stays the owner and the copy is never edited. The copy is a cache of someone else's truth.
Why

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.

Where it shows

See the Atlas chapter Nobody owns the product table.

When the answer changes

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?
#
Technical code that means the same in every service, such as error responses, token checks and the outbox. No business concept, with one narrow exception.
Why

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.

Where it shows

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.

When the answer changes

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?
#
With semantic versions. A breaking change waits for a major version, and each service upgrades in its own release.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Together with the first two services. Code moves into the library when a second service needs it, and not before.
Why

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.

Where it shows

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.

When the answer changes

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?
#
One library while the company is small. Split it when services skip an area, or an area needs its own release rhythm.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Only for a breaking change. To remove, rename or change the meaning of a field, run the new version beside the old one.
Why

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.

Where it shows

For HTTP interfaces, see the Atlas chapter Can you see it?. For events, see the question fq-v1-v2-how-long.

When the answer changes

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?
#
You can ship a change to it today without deploying, migrating or telling any other service. If you cannot, the border is not real.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Deploying puts new code on the servers. Releasing lets it answer users. A feature flag separates them, so you switch back without a deploy.
Why

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.

Where it shows

See the Atlas chapter Strangling the monolith.

When the answer changes

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?
#
Not a broad suite. The order service tests its own journey alone. Whether a few journeys across services should stay is still argued.
Why

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.

Where it shows

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.

When the answer changes

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?
#
It checks that a producer still gives what its consumers rely on. It runs in the producer's build, before the change ships.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Replace them at your border. Test your client against a small local HTTP server, and replace the port in the larger tests.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Write a trace ID on every log line, and send it with every call. Put a business ID in every message.
Why

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.

Where it shows

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.

When the answer changes

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?
#
Failed orders. Alert on what a customer feels. Machine numbers help to explain an incident, but they are not the incident.
Why

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.

Where it shows

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

When the answer changes

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?
#
Where the user's request enters, such as the controller. The use cases behind it then serve internal callers without a second check.
Why

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.

Where it shows

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 the answer changes

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?
#
A token of its own. The other service then trusts our service, with only the scope it needs, and not every user.
Why

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.

Where it shows

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.

When the answer changes

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?
#
No. The ID is data about who acted there. It grants nothing here, because the message is not a user's request.
Why

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.

Where it shows

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

When the answer changes

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?
#
The system, under one fixed name. Never a made-up user, because the audit must tell a person from the service.
Why

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.

Where it shows

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.

When the answer changes

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?
#
It pushes hard. Borders form where teams talk least, so a context that two teams share soon holds two models.
Why

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.

Where it shows

See the Atlas chapter You ship your org chart.

When the answer changes

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?
#
As many as it runs well. Each member can say what each service does and how it last failed.
Why

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.

Where it shows

See the Atlas chapters You ship your org chart and The consultant who said microservices.

When the answer changes

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?
#
Draw the borders by language, then give each context one team, one context at a time. That is a choice.
Why

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.

Where it shows

See the Atlas chapter You ship your org chart.

When the answer changes

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?
#
Once several teams run services, yes. Without one, each team builds its own pipeline, messaging setup and dashboards.
Why

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.

Where it shows

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.

When the answer changes

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?
#
In a short decision note in the repository, with its reason and the sign that should make you look at it again.
Why

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.

Where it shows

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.

When the answer changes

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?
#
No. Replace it one capability at a time behind a single routing point. Each step then ships value and can be switched back.
Why

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.

Where it shows

See the Atlas chapter Strangling the monolith.

When the answer changes

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?
#
A part that changes often, causes pain and touches little else. Move the most tangled part last.
Why

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.

Where it shows

See the Atlas chapter Strangling the monolith.

When the answer changes

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?
#
When the old tables have no readers. The code moves in weeks. The data decides when the old system can be switched off.
Why

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.

Where it shows

See the Atlas chapter Strangling the monolith.

When the answer changes

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?
#
Run both for a while, use the old answer, and log every difference. Switch when every difference is explained.
Why

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.

Where it shows

Sam Newman describes the parallel run in his book of 2019. See the Atlas chapter Strangling the monolith.

When the answer changes

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?
#
Toward modules first, unless a capability already has a measured reason to be a service. That is a choice.
Why

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.

Where it shows

See the Atlas chapter Strangling the monolith.

When the answer changes

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#

Team guides · Chapter 37· 36 min read

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

  1. Put a business rule in the entity method that makes the change, not in a service.
  2. Change an entity's fields only through its own methods; no setters.
  3. A service method only runs the steps: load, check the version, get facts, call the entity, save, audit.
  4. Name classes, methods and errors with the business's own words.
  5. Put a calculation that spans several objects in a domain service, and only when it has worked examples.
  6. Give every ID and business value its own type; check its format once, where the value is created.

Part B. Aggregates

  1. 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.
  2. Store another aggregate's ID, never the object itself.
  3. Save one aggregate per transaction.
  4. Give every list inside an aggregate a maximum size.
  5. Refuse a save that was made from an old version of the record.
  6. Make every change safe to repeat: a repeated request adds or counts nothing twice.
  7. Store money as an exact decimal and time in UTC, and count days in UK dates.

Part C. Features, microservices and events

  1. Use another feature only through its application service, passing IDs and simple values.
  2. Check permissions in the controller, never in the service or the entity.
  3. Talk to another system only through one adapter class that translates its data into our types.
  4. Call another microservice before you open the transaction, never inside it.
  5. Retry a read; never retry a change.
  6. Save an event in the same transaction as the change it reports, and send it afterwards.
  7. Only add fields to a published event; to remove or rename one, publish a new version.
  8. Write every listener so a message that arrives late, twice or out of order does no harm.
  9. Change several microservices as a chain of steps joined by events, with a named step for failure.

Part D. Process

  1. Get the design note approved before you code a feature with business rules.
  2. Mark a feature Simple (data with no rules) or Rich (states and rules) in its design note.
  3. Fix a mistake with a safe step today and the proper fix by a date.
  4. Keep personal data out of logs, error messages and events.

Part E. Situations you will meet

  1. 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.
  2. Keep a rule across all records of a kind true with a unique index, and turn the index error into a business error.
  3. Create an entity through a named factory method that checks the rules and raises the event; keep the constructor private.
  4. Read for a screen through a query class on the collections; never build a screen out of aggregates.
  5. Search and reports are queries too; give a slow one its own read collection filled by events.
  6. 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.
  7. Never delete a business record; add a business action that marks it, with a reason.
  8. Run a time-based rule from a scheduled job that calls the same entity method, one record per transaction.
  9. When a process has its own steps and deadlines, give it its own aggregate.
  10. Change an aggregate's shape behind a flag, with a migration that uses the entity's own methods.
  11. Test the entity with no database, the service with a fake repository, and the repository alone against a real database.
  12. Show two aggregates on one screen with two save buttons; one button that saves two things is a design smell to raise.

Terms#

TermMeaning
MicroserviceA Spring Boot application deployed on its own, with its own DocumentDB database
FeatureOne business area inside a microservice, in its own package. Example: claims, notes
Business actionOne change the user asks for with one button, such as "close claim"
EntityA business object with its own ID. Its details change; its identity does not. Example: Claim
Value objectA value with no ID, defined by its contents, never changed after it is created. Example: Money
AggregateA 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
RepositoryThe one class that loads and saves one kind of aggregate. Example: ClaimRepository, a Spring Data MongoRepository
Application serviceA 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 serviceA rare class for a calculation that belongs to no single entity. Example: EntitlementCalculator
AdapterThe one class that talks to another microservice or system. Example: EligibilityClient
Domain eventA record that something happened inside our microservice, named in the past tense. Example: ClaimClosed
Integration eventA versioned event that other microservices receive. Example: ClaimClosedV1
OutboxA collection where an event is stored in the same transaction as the change it reports; a sender publishes it to the message broker afterwards
InboxA collection of processed message IDs, so a message delivered twice is handled once
Audit recordA 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 groupThe lead engineer, two or three senior engineers and the business experts
Design noteA 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 it

Spring: 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 typeChildrenWeekly entitlement
Standard0£84.80
Standard2£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 values

Three 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#

  1. Does it decide whether the entity may change, from its current state? Entity method (rule 1).
  2. Is it a calculation across several things, with worked examples? Domain service (rule 5).
  3. Does it load, save, call another microservice, start a transaction or write audit? Application service (rule 3).
  4. Does it check the format of one value? Value object (rule 6), or the factory method that creates the entity (rule 29).
  5. Does it check a code from another feature? That feature's application service (rule 14).
Controller HTTP in; checks permission Message listener messages in, via the inbox Other microservice e.g. eligibility Application service load → get facts → entity decides → save → audit. No business rules asks Adapter translates into our words Repository loads and saves one per aggregate Aggregate = one unit, saved together Claim (root): checks rules, raises events EvidenceItem an entity inside the root Value objects Money, ClaimantDetails Domain service EntitlementCalculator rare; saves nothing saves the whole aggregate events, on save Database claim + outbox entry, one transaction Outbox → message broker sends ClaimClosedV1 after the commit
How the building blocks handle one request: the application service runs the steps, and the aggregate decides. A controller or a message listener calls the application service. It asks other microservices through an adapter, loads and saves through the repository, and uses a domain service for a calculation. The Claim root checks the rules and raises events. The repository saves the whole aggregate, with its outbox entry, in one transaction, and the outbox sends the events to the message broker after the commit.

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.

ChildRule needs details?Maximum?Changes with parent?Meaningless alone?Decision
Evidence itemYes: closing needs every item verified10YesYesInside the claim document
NoteNo: closing needs only a countNoneNo: two caseworkers at onceNoOwn collection notes
PaymentNo: only the latestNoneNo: the payment microservice sends themNoOwn 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 evidence

The 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 change

A 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 time

Floating 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 code

When 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 sent

Or 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 place

The 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 V1

Every 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 findTodayProper fixNever
Two aggregates share a rule that must always be trueSave both in one transaction; add the exception to the design noteMerge them; a migration moves the children inside the parentKeep the rule true with events
An aggregate gives frequent conflicts or keeps growingKeep the version check; make the screen say "reload"Move the part that changes often into its own collectionTurn off the version check
A rule sits in a service, controller or listenerTicket for this sprintMove it into the entity method; add a testCopy the if elsewhere
A feature uses another feature's repositoryRecord today's violations so only new ones fail the buildAdd a method on the other feature's application serviceTurn the build check off
A change sent no eventAdd the missing event to the entity methodA one-off job sends the missed events, agreed with receiversLet other teams read our database
Messages are in the dead-letter queueFind the cause with the trace ID and fix itRe-send them; the inbox skips duplicatesDelete them before you understand them
Our data and another microservice's disagreeUse both audit logs to find which side is rightA job that reports differences; fix through entity methodsA job that silently overwrites one side
Data was repaired by writing to the databaseFind the affected recordsRepair through entity methods or a reviewed migrationRepair 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 second

Both 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 _id

Aggregates 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 documents

The 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:

  1. Release 1: the new Note document and repository exist; new notes go to notes; reads check both places behind a feature flag.
  2. A migration job, one claim per transaction (rule 9), creates the Note aggregates through Note.create and marks the claim as migrated; it can be rerun.
  3. Release 2: the flag is on for everyone; the old array is read by nobody.
  4. 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#

Team guides · Chapter 38· 37 min read

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

  1. A new record is a create, a named business step is an action, anything else saves a group of details.
  2. Send one request per save; the microservice saves all of it or nothing.
  3. Save a group of details with PUT and send every field in the group; never use PATCH.
  4. Send an Idempotency-Key header with every create, so a repeat cannot create a second record.
  5. Send the record's version with every change; the microservice refuses an old version with 409.
  6. Never retry a change automatically; retry a read once.
  7. Return 201 with a Location header for a create, and 204 with no body for every other save.
  8. A change that goes to another system is one business action, and it calls that system once.
  9. Translate the other system's answer into our own status codes.

Part B. Endpoints that read data

  1. Build read endpoints around records, not around screens.
  2. Each kind of record has two response formats only: a summary for lists and a detail for one record.
  3. Never make one call per row of a list.
  4. Page, sort and index every list.
  5. Return a lookup list as codes and labels, cached for 5 minutes.
  6. Check permission on every endpoint, reads included, even when the caller is our own Express.

Part C. Processes across several microservices

  1. A process across microservices has one owning microservice, and Express calls only that one.
  2. Change data in another microservice by sending an event, never by calling its API.
  3. Save each step's change and its event in one transaction, and skip an event you already processed.
  4. Decide what happens when a step fails before you build the process.
  5. Link the steps by events each microservice reacts to; a directing microservice only for many steps or deadlines.
  6. Return 204 and a progress field when the user needs only the first step; 202 with a status URL when they must wait for all steps.

Part D. Conventions

  1. GET reads, POST creates or acts, PUT saves a group; never PATCH, and no DELETE on business records.
  2. Name collections with plural nouns and actions with lower-case verbs; nest one level at most.
  3. Return a JSON object with camelCase fields, ISO dates, UTC times and two-decimal money.
  4. Never put personal data in a URL; search by personal details with POST /claims/search.
  5. Return the same status codes and the same error body from every microservice.
  6. Make a breaking API change in three releases, and update openapi.yaml in the same pull request as the code.

Part E. The Express front end

  1. The browser never calls a microservice; Express calls it with the user's token.
  2. Read from several microservices in parallel, but send each save to one microservice.
  3. Give every call from Express a time limit and a circuit breaker.
  4. Show the page with the data that loaded; show an error page only when essential data failed.
  5. Keep answers in the Express session until the save button, then save once and redirect.
  6. Cache lookup lists only; never cache business records.

Part F. Situations you will meet

  1. Upload a file as multipart, store the bytes in an object store, keep only a reference on the record, and stream downloads through Express.
  2. Return field errors as 400 with an errors list of field, code and message; keep business-rule refusals as 422.
  3. 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.
  4. Store a "save and return later" draft as a record of its own, with no business rules until the final create.
  5. On 409, show the user the fresh record and a way to start again; never retry with the new version.
  6. 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.
  7. An outside callback gets its own endpoint, which checks the signature, stores it with its ID, answers 202 and raises our own event; no business logic there.
  8. Export CSV from GET .../export with the list's filters, a row limit and the personal-data permission; a large export returns 202 and a status URL.
  9. Show another microservice's data by reading it in Express; store a copy only when a rule needs it.
  10. Remove a record created by mistake with a cancel action and a reason; keep it, hide it from lists by default, still return it on GET.
  11. 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.
  12. Make openapi.yaml the contract: the microservice validates every response against it, and Express's client tests run against its examples.

Terms#

TermMeaning
ExpressOur front-end server. It builds the GOV.UK pages on the server and calls the microservices
GatewayThe server in front of Express. It signs the user in and keeps the session
RecordOne thing a microservice stores, such as a claim or a note
VersionA number on every record that goes up by one on every save. Every response with a record includes it
Group of detailsFields the screen shows and saves together, such as contact method, phone and email
Business actionA change the business names as its own step, such as "close claim"
Idempotency keyA random ID sent with a create. A repeat with the same key returns the first result instead of a second record
Access tokenThe gateway's signed token (a JWT) for the user; its permissions claim lists what they may do
traceparentThe header that carries the trace ID through every call, so one action can be followed through every log
OwnerThe one microservice that starts a process across microservices and records its progress
Design groupThe 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.

  1. When Express built the claim page, it read the claim with the user's token and put the version in 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 }
  1. 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
  1. 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 }
  1. The claim microservice checks permission, loads the claim, checks the version, applies the rules, saves the claim and a ClaimClosedV1 event in one transaction, and answers with no body.
204 No Content

If 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..." }
  1. 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
  1. Later, the payment microservice receives ClaimClosedV1 from the message broker, stops the payments and sends PaymentsStoppedV1. The claim microservice receives that and sets paymentsStopped: 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:

  1. Does the save create a new record? Create: POST /claims.
  2. 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.
  3. 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 OK

Express 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 Content

The 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 Content

The 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 Content

If 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 Content

Here 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 systemWe return
Refuses the change422 with our own code, such as ADDRESS_REJECTED
Says the version changed (412)409 with code CONCURRENT_UPDATE
Is unreachable or answers 503503. Nothing was changed
Does not answer in time504 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:

KindExampleRule
One recordGET /claims/9f1cDetail format; 404 if missing
A list with filtersGET /claims?status=OPEN&page=0&size=20Rule 13
A record's childrenGET /claims/9f1c/notes?page=0&size=3&sort=createdAt,desc404 if the claim is missing, not an empty list
A lookup listGET /reference-data/CLAIM_TYPERule 14
A calculated answerGET /claims/9f1c/entitlement?from=2026-01-01&to=2026-01-31The 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 record

Summary 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 Express

The 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 out

The 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 redelivery

Either 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 = true

PaymentStopFailedV1 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 updates

Switch 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 days

Why: 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 Content

The 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 reason

Express 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 writes

The 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-A

Express 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=20

Filters, 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=QQ123456C

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

StatusMeaningWhat Express does
400Invalid request; errors names each wrong fieldShows each error next to its field
401Not signed in or session endedSends the user to sign in through the gateway
403Signed in but not allowedShows "You do not have permission"
404Record does not existShows a not-found page
409 CONCURRENT_UPDATERecord changed after the screen loadedShows the 409 page (rule 38); never retries
409 REQUEST_IN_PROGRESSThe first request with this Idempotency-Key is still runningAsks the user to wait, then check the record
422A business rule refused the change; code names the ruleShows the message for that code
422 IDEMPOTENCY_KEY_REUSEDSame key, different request: an Express bugShows an error page
500Unexpected errorShows an error page with the traceId
503 DEPENDENCY_UNAVAILABLEA 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_UNKNOWNAnother 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.

Browser session cookie only Gateway signs in; keeps session Express app (server side) gets user ID + token in headers combines reads; caches lookup values timeouts, circuit breakers, traceparent HTTPS + user's token + traceparent Claim microservice claims, notes, lookups Payment microservice payment status Other microservices each checks permissions itself Events: microservices tell each other what happened. Express is not involved
Browser → Express → microservices: the browser talks only to Express, and Express calls each microservice. The gateway signs the user in and passes the user's ID and token to Express. Express calls each microservice over HTTPS with the user's token and traceparent, and each microservice checks permissions itself. The microservices tell each other what happened with events, and Express is not involved.

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 fails

The 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 ever

Every 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 reached

The 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 cache

Another 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 cached

Why: 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 link

The 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=11MB

claimService.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 ends

The 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/token

The 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 limit

The 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 created

The 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 Content

The 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 row

The 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#

Appendices · Chapter 39· 42 min read

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.

TermWhat it meansWhere it is explained
acknowledgementThe 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
ActuatorThe Spring Boot module that adds health, metrics and information endpoints to a service, such as /actuator/health.Actuator endpoints
adapterA class that implements a port with a real technology, such as MongoDB or HTTP. OrderPersistenceAdapter is one.The direction of the dependencies
ADRShort for architecture decision record. It is a short file that states one decision, its reason and its consequences.How each decision is recorded
aggregateA 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 rootThe 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 POMA 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
AMQPShort for Advanced Message Queuing Protocol. It is the wire protocol that RabbitMQ uses.RabbitMQ
anaemic domain modelA 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
annotationA marker written with @ before a class, method, field or parameter, such as @Override. It does the same as an attribute in C#.Syntax
annotation processorA 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 retentionHow 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 layerThe 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
AOPShort 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
AOTShort 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 cacheA 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 contractThe 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 contextThe 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 eventA 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 serverA 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 serviceThe 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
ArchConditionThe interface of ArchUnit for a custom check that runs on each selected class.A custom ArchCondition
ArchUnitA 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
aspectA 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
AssertJThe assertion library that most Java tests use. An assertion is written assertThat(actual).isEqualTo(expected). AssertJ is like FluentAssertions in .NET.A test
@AsyncA 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 deliveryEvery message arrives, and some messages arrive more than once. So a consumer must handle a repeat.What the outbox guarantees
atomic variableA 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 logA 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 pointThe 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-configurationSpring 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 creatorThe 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
autoboxingJava 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.importsThe 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
@BeanAn 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
beanAn 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 definitionThe 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 scopeHow 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 ValidationThe Java standard for validation annotations such as @NotBlank and @Size. It has the same purpose as DataAnnotations in .NET.Validation and error responses
BeanPostProcessorAn 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 mudA 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
BigDecimalThe 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
BOMShort 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 contextAn 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
BSONThe 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
BuildpacksA tool that turns an application into a container image without a Dockerfile. The build-image task of Spring Boot uses it.Containers
business-expert testA 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
bytecodeThe 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 managerThe 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
@CacheableA 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 threadThe 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
CASShort 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
CDSShort 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
CGLIBShort 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 testA test that records what code does today, right or wrong, before you change the code.What to test first in an older service
charsetThe 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 exceptionAn exception that the compiler makes you handle. You must catch it, or declare it with throws. C# has no checked exceptions.Checked exceptions
circuit breakerAn 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 fileA 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 loaderThe 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
classpathThe 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 resourceA 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 credentialsThe 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 skewHow 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
CMEShort 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 cacheThe 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 constructorA 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
ComparatorAn 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
CompletableFutureThe 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 scanningSpring 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 indexAn index on several fields, in a fixed order.A named index for each query
compressed referencesA 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 metadataThe 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
@ConfigurationPropertiesAn 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
conformistA 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 boundaryThe edge of an aggregate. Everything inside it is checked and saved together, in one transaction.What an aggregate is for
constructor injectionGiving 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 mapA 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 firstA way to keep a contract. The contract is written first, and code is generated from it.Contract first: openapi-generator
controller adviceA 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 chartA 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 subdomainA 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
coroutineThe 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
CORSShort 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
CQRSShort 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
CSRFShort 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-supplierA 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 migrationOne 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
DDDShort 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
deoptimisationThe 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 mediationMaven'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 scopeA 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
dependencyManagementThe 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 groupThe two or three most experienced engineers, with a business expert. They decide boundaries, aggregates, rules, workflows and levels.Who decides what
detached entityAn 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
DIShort 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 bufferA 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
dispatchOne pass of a request through the servlet filters. An error page is a second dispatch of the same request.One request through the checks
DLQShort 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
DMNShort 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 classA 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 eventA 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 serviceA 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 storytellingDomain 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 checkA test that fails when the committed contract differs from the contract that the code generates.Exporting the contract, and the drift check
DSLShort 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
DTOShort 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 problemWriting 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
EARShort 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 POMThe 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
EJBShort 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
entityA 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
enumA 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-processorA 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 labelA 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 registryThe 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 sourcingStoring 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 stormingA 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 consistencyTwo parts of a system may disagree for a while, and agree later, for example after an event arrives.Between services: no shared transaction
exception kindOne 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
ExecutorServiceAn 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
externalisationThe step in Spring Modulith that sends @Externalized events to a message broker after the commit.@Externalized: from the outbox to SNS
failsafeThe Maven plugin that runs the integration tests, the *IT classes, in the integration-test and verify phases.Fast tests and integration tests
fat JAROne 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 noteOne page for each feature, with its aggregates, states, rules with examples, events and partners.The feature design note
feature packageA top-level package for one business capability that a business user recognises, such as order or note.Features at the top, never technologies
FIFO queueShort 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 fieldThe 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
FlywayA 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 ruleAn ArchUnit rule that records the violations of today, and fails only on new ones.Adding the rules to an existing service
fully qualified nameA 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 interfaceAn 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
G1The default garbage collector of the JVM on most machines. It balances short pauses against throughput.Choosing a collector
gathererA 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
GCShort 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 subdomainA 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
GraalVMA 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 degradationA service keeps working in part when one of its dependencies fails.A failed call becomes a 503
GradleA 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-beforeThe 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 groupA named set of health checks, such as readiness.The health probes: liveness without the database
health indicatorA 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
heapThe 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 architectureA layout built from ports and adapters, often with adapter/in and adapter/out folders.Why not adapter/in and adapter/out
HibernateThe most widely used implementation of Jakarta Persistence, and the implementation that Spring Boot uses by default.The landscape
HQLShort 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 keyA 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
idempotentSafe to run twice. Doing it a second time has the same effect as doing it once.Retry only what is safe to repeat
inboxA 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 blockA 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 classA 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 cacheJava 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 eventA 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
interceptorAn 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
IoCShort 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 durationA duration written as text, such as PT1M for one minute or PT15M for fifteen minutes.@Scheduled with durations from settings
isolation levelHow 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
J2EEShort 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
JacksonThe JSON library that Spring Boot uses by default. It does the same as System.Text.Json in .NET.Jackson against System.Text.Json
Jakarta EEThe 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
JARShort 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 moduleA 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.timeThe 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 commentA 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
JAXBShort 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
JDBCShort 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
JDKShort 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
JEPShort 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
JFRShort 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
JITShort 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
JMHShort 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
JMSShort 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
JMXShort 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
JNDIShort 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
JNIShort 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 testA 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
JPAShort 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
jpackageA JDK tool that wraps an application and a Java runtime into a native installer, such as an .msi or a .dmg.jlink and jpackage
JPMSShort 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
JPQLShort 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
JREShort 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
JShellThe 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
JSPShort 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
JSpecifyA 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
JSRShort 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
JUnitThe standard Java testing framework. It does the same as xUnit in .NET. A test is a method marked @Test.A test
JVMShort 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
JWKSShort 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
JWTShort 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
KotlinA 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
lambdaA short unnamed function written with an arrow, such as x -> x * 2. C# writes =>. Java writes ->.Lambdas and method references
layered jarA 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 loadingLoading 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 proxyThe 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 phaseOne 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 styleOne 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 modeA @Configuration class with proxyBeanMethods = false. Its @Bean methods are plain factory methods, and Spring creates no subclass of the class.proxyBeanMethods = false
liveness probeTwo 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 environmentThe 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 repositoryThe folder ~/.m2/repository, where Maven keeps every library that it downloads. It is like the NuGet global packages folder.Where your dependencies actually live
LogbackThe 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
LombokA 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 updateA save that writes old data over a newer change, with no error.Optimistic locking with @Version
LTSShort 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
manifestA 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
MapStructA library that generates object-to-object mapping code when you compile. It has the same purpose as AutoMapper in .NET.MapStruct against AutoMapper
MavenThe 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 coordinatesThe 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 profileA named set of settings in pom.xml that you switch on for some builds only, with mvn -P name.Profiles and BOMs
Maven wrapperThe mvnw script in a project. It downloads and runs the right Maven version, so everyone builds with the same version.The wrapper
MDCShort 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-annotationAn 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
metaspaceThe memory outside the heap where the JVM keeps class definitions. Loading very many classes can use all of it.The memory model
method referenceA 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 securityThe proxy that checks @PreAuthorize and the related annotations when a method is called.How method security works
MicrometerThe 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
MockitoThe standard Java library for mock objects. It does the same as Moq in .NET.Mocking
MockMvcA Spring test helper that sends fake HTTP requests to your controllers without starting a server.Testing a controller
module pathThe 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 collectionA 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 documentOne 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
MVCShort 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 problemLoading 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 TrackingThe 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 keyA 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 configurationA static @Configuration class inside another one. Its conditions apply only to its own beans.The eight auto-configurations
NPAPIShort 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
NPEShort for NullPointerException. It is thrown when code uses a null reference. It does the same as NullReferenceException in C#.The gap, stated plainly
ObjectIdThe 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
ObjectProviderA 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
OIDCShort 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
OOMShort 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 killerThe 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 serviceAn 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
OpenTelemetryThe open standard for traces, metrics and logs across services. Spring Boot and .NET both support it.Tracing
optimistic lockingA 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
OptionalA 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 dependencyA 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
ORMShort for object-relational mapper. It is a library that maps classes to database tables, such as Hibernate or Entity Framework.The landscape
@OverrideAn 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
packageA 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.javaThe 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-privateThe 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 POMA 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
partnershipA partnership is a relation in which two contexts succeed or fail together. So their two teams plan their changes together.Strategic design
PECSShort 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
permitsThe clause in a sealed class or interface that lists the only types allowed to extend it.Sealed types: Java's discriminated unions
persistence adapterThe class that implements a domain repository port with a database, such as OrderPersistenceAdapter.The adapter and the platform's base class
persistence contextThe 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
@PersistenceCreatorA Spring Data annotation that marks the constructor Spring Data uses to load a stored object.The light level: the note feature
pinningA 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 libraryThe 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 threadAn 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
pluginManagementThe section of a POM that configures plugins. The configuration applies only where a POM uses the plugin.Fast tests and integration tests
pointcutAn expression that picks which methods an aspect wraps, such as execution(public * *(..)) for every public method.AOP: writing your own cross-cutting behaviour
poison messageA 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
POJOShort for plain old Java object. It is an ordinary class with fields, getters and setters, and no framework base class.The property gap
POMShort 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 featureA 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 streamA 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 spaceThe 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
ProblemDetailThe 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 managerAn 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 placeholderA 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 sourceOne 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
proxyAn 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 languageA 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
R2DBCShort 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 deliveryA 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 typeA 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
reactiveA 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
recordA 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 patternA pattern that takes a record apart into its components while matching it, such as case Point(int x, int y).Record patterns
ReentrantLockA lock object that means the same as synchronized. It also has extras, such as tryLock with a timeout.ReentrantLock
rehydrateTo rebuild an aggregate from stored data, as Order.rehydrate does. It applies no rule and raises no event.Placing versus rehydrating
relaxed bindingSpring 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 trainThe Java schedule of a new version every six months, in March and September. An LTS release comes every two years.The release train
REPLShort 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 setA 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 memoryThe 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 writeA 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
RMIShort 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 ruleThe 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-onlyThe 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
safepointA 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
sagaA 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
SAMShort 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
@ScheduledA 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
ScopedValueA 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
SDKMANA command-line tool that installs Java versions and switches between them.Installing and switching
sealed typeA 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 contextWhere Spring Security keeps the caller of the current thread. It holds one Authentication, or none.The security context: one per thread
seed dataLookup 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-invocationA 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 versionA 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 waysSeparate 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 collectionA collection with a defined order, and with methods such as getFirst() and reversed(). Sequenced collections were added in Java 21.Sequenced collections
servletThe 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 kernelA 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
ShedLockA 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
SimpleKeyThe default cache key that Spring builds when a cached method has several arguments.Cache keys
SLF4JShort 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
SmartInitializingSingletonA 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 spaceThe solution space is the software that you design to answer the problem space. Its parts are models, contexts and services.Strategic design
spanThe part of the work of a trace that one service does, with its own span ID.Tracing with the OpenTelemetry starter
SpELShort for Spring Expression Language. It is a language for small expressions inside annotations and configuration, written #{...}.SpEL
SPIShort 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 repositoryAn interface that you declare, such as OrderRepository extends JpaRepository<Order, Long>. Spring generates the data-access code for it.Repositories
Spring profileA 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 monitorA 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
starterA 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 stepOne 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 methodA 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 importAn 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
stereotypeAn 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
STOMPShort 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 designThe 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 collectorThe 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 pipelineA 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 concurrencyRunning 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 loggingLog 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
subdomainA 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 subdomainA 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
surefireThe Maven plugin that runs the *Test classes in the test phase.Fast tests and integration tests
switch expressionA 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
synchronizedA 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 actorThe actor system, recorded for work that no user started, such as a message or a scheduled job.Messages and jobs: the system actor
system propertyA 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
TCKShort 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 featureA 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 patternA 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
TemurinA free, widely used JDK distribution from the Eclipse Adoptium project.Which distribution
test sliceA Spring Boot test that starts only one layer of the application, such as the controllers or the repositories, so it runs faster.Test slices
TestcontainersA library that starts real databases and message brokers in Docker containers for a test run. It exists for .NET too.Testcontainers
text blockA multi-line string written between three double quotes. It is like a raw string literal in C#.Text blocks
ThreadLocalA variable with a separate value for each thread. Scoped values are the modern way to pass context.ThreadLocal on virtual threads
tiered compilationThe 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
TLABShort 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 readerA 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 IDThe 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 samplingThe decision whether a trace is recorded and exported. Spring Boot samples 10% of the traces by default.Tracing with the OpenTelemetry starter
transaction managerThe 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 propagationWhat 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
@TransactionalA 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 outboxA 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
TransactionTemplateA 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 failureA 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
TransientTransactionErrorThe MongoDB error label that says the whole transaction may succeed if it runs again.Write conflicts, and what Spring retries
trust storeA 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-resourcesA 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 indexShort 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 erasureJava 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 patternA 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 IDAn 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 languageThe 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 exceptionAn 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 variableThe 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
upsertAn update that inserts the document when no document matches.Seed data, upserted by a natural key
use caseOne 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 objectAn 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
varA keyword that lets the compiler infer the type of a local variable from its value, exactly as in C#.var
varargsA last parameter written Type... name, which accepts any number of arguments. It is like params in C#.Varargs
vertical sliceA 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 threadA 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 timeoutHow 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
volatileA 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
WARShort 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
warmupThe 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 guardA 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
wildcardA ? 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 testA 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
witherA 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 engineA 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 typeThe object version of a primitive, such as Integer for int. Collections and generics need wrappers, because they cannot hold primitives.Wrapper types
write conflictMongoDB error 112. Two transactions changed the same document, and the database aborted one of them.A conflict inside a transaction
yieldThe keyword that returns a value from a block inside a switch expression. It is not related to yield return in C#.Multi-statement arms
ZGCA JVM garbage collector designed for very short pauses, even on large heaps.Choosing a collector

About this book#

Appendices · Chapter 40· 2 min read

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#

SideVersionNote
Java25 LTSthe companion service compiles for Java 25, and the text says where Java 21 differs in a way that matters
.NET10, C# 14the C# side of every comparison uses the modern style, such as records and primary constructors
Spring Boot4.1the companion service is built with 4.1.2, and the Boot 3 names are given where they changed
Spring Framework7the container, the web layer, transactions and events
Spring Security7the resource server, the filter chain and method security
Spring Data MongoDBthe version Boot 4.1 managesagainst MongoDB 8 locally, which takes the place of Amazon DocumentDB in the tests
Spring Modulith2.1the event publication registry and the outbox
Spring Cloud AWS4.1the 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 coveredWhere to read insteadWhy it is out
CQRS, event sourcing and sagas in codethe chapters that explain each as a choice, and the Atlas, the sibling book on DDD and microserviceseach 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 databasethe handbook's chapter on JPA and migrationsthe service stores documents, as the reader's services do
Kotlinthe handbook's Kotlin appendixthe platform facts in this book apply unchanged to a Kotlin codebase
Reactive WebFluxthe handbook's WebFlux chapterthe service blocks on virtual threads, and the handbook explains when WebFlux is the better choice
The first-read explanation of each featurethe handbookthis 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