You can build your first Spring endpoint in minutes. The harder question arrives when the application grows: where should the next piece of business logic live?

This guide connects four ideas that are often introduced separately. Spring creates and connects application objects. Spring Boot supplies a convenient way to configure and run them. Domain-Driven Design (DDD) helps you discover a useful business model. Spring Modulith helps you check the boundaries of that model inside a single application.

We will build a tiny library lending service. A member borrows a physical copy, receives a due date and later returns it. You need basic Java knowledge—classes, constructors, collections and exceptions—but no previous Spring experience. The complete project accompanies this article; the Java excerpts below come from that tested project.

Illustrated library with bookshelves, a borrowing desk and a reading corner under one shared roof.
One library, distinct responsibilities: a visual metaphor for business modules within one application.

Why this is a useful Spring topic now

Spring’s recent releases strengthen both the application platform and the tools around modular application design. Boot 4.1 introduced gRPC integration, HTTP-client SSRF mitigation and observability updates. Those are valuable developments, although a beginner’s first REST application can start with a much smaller set of features. Spring Boot 4.1 release announcement

Spring Modulith 2.1 added ways to combine module integration tests with Boot slice tests, extended event-test capture across threads, and introduced outbox integrations for event externalization. These are concrete additions to the toolkit for applications with internal business modules. They are evidence of framework investment, rather than a measurement of industry adoption. Spring Modulith 2.1 release announcement

For this beginner tutorial, our editorial choice is business modules inside one deployable application. It fits Homann Software’s focus on Java engineering and software architecture, and gives newcomers something they can build and test locally before tackling distributed systems. Homann Software

Research checked on October 13, 2026: the project uses Spring Boot 4.1.1 and Spring Modulith 2.1.1. Boot 4.2.0-M2 and Modulith 2.2.0-M2 are preview releases in the sources consulted. A release calendar can contain future versions; a scheduled date does not establish that an artifact has been released. Boot 4.1.1 announcement, Boot project, Modulith reference

Four ideas, four responsibilities

Four complementary responsibilities: DDD models the business, Spring wires objects, Boot runs the application and Modulith checks module boundaries.

Spring Framework: a Spring bean is an object managed by Spring’s container. If a service needs another service, Spring can pass that dependency into its constructor. This is dependency injection. The class declares what it needs instead of creating every collaborator itself. Our Lending service receives a Catalog; in a plain unit test, we can construct the same two objects ourselves. Spring dependency injection reference

Spring Boot: Boot builds on Spring with starters, conditional auto-configuration and a standard application entry point. A starter brings a useful set of dependencies; auto-configuration reacts to the classpath and existing beans. Our web starter supplies the HTTP infrastructure used by the example. You can replace defaults as your requirements become clearer. Boot auto-configuration, Boot overview

DDD: start with the language, rules and decisions of the business. In our library, “copy” means a particular lendable item, while “loan” means a borrowing relationship. Discuss those distinctions with the people doing the work. DDD includes both broad model boundaries and the detailed behavior of objects within them. Martin Fowler on DDD

Spring Modulith: describe logical modules in a Boot application, then verify and document their arrangement. Here we use it to detect forbidden code dependencies and generate module diagrams. A module remains part of the same application. It does not need its own deployment or network connection. Spring Modulith overview

These responsibilities meet in one simple chain: the business conversation suggests a model; Java implements its rules; Spring connects the services; Boot runs them; Modulith checks selected structural constraints.

When the customer environment makes microservices too expensive

In our experience, Spring Modulith is particularly worthwhile when a familiar microservice architecture becomes disproportionately complex in the customer’s environment—and implementing it would mean substantial additional cost with little corresponding benefit. An architecture that works well on one organization’s established platform may be an expensive fit for another organization’s infrastructure, operating model and support capacity.

Consider a customer with an established Java hosting environment, a small operations team and a coordinated release process. If the application’s modules have similar scaling needs and one team owns them, separate deployments may add little practical value. Making them separate services still creates additional work around deployment automation, service communication, monitoring and failure handling. Fowler describes this additional burden as the microservice premium: its benefits need to justify its costs in the particular system. Microservice Premium

For that customer, compare the work required to operate both options. Depending on the existing platform, distributing the application can mean more runtime instances to provision and patch, additional network permissions and service credentials, more deployment pipelines, and more complex diagnosis across service boundaries. Separate data ownership can also require explicit consistency and recovery mechanisms. These are implementation and ongoing support costs; cloud usage or server capacity is only part of the calculation. Kubernetes is one possible platform choice, not a prerequisite for microservices. Fowler’s trade-off analysis explains why distribution and operational maturity matter alongside the benefits of independent deployment. Microservice Trade-Offs

Spring Modulith offers a useful option here: organize the business capabilities as modules within a Spring Boot application, check their dependencies and test their behavior while retaining one application deployment. In our library example, Catalog and Lending can remain distinct responsibilities without adding a network boundary between them. Modulith’s structural verification and module tooling help maintain that arrangement as the code grows. Spring Modulith reference

The decision still has trade-offs. Modules share a process and release unit; they do not gain independent scaling or process isolation. The application still needs appropriate security, backups, monitoring and availability measures. A later service extraction requires deliberate work on APIs, data ownership and failure semantics.

Our practical recommendation is to record the decision with the customer: which capability needs an independent release, separate scaling or stronger isolation; what measurable benefit would that provide; and what additional build and operating effort would it require? When those benefits are small and the required platform work is large, a modular monolith with Spring Modulith is often the more proportionate engineering choice. Estimate costs for that environment instead of assuming a universal savings percentage.

Begin with the use case and its language

Our deliberately small use case is: a member borrows a known physical copy for fourteen days.

We choose these rules for the tutorial:

  • Only the two catalogued copies, copy-001 and copy-002, can be borrowed.
  • A copy can have at most one active loan.
  • A member identifier must be present.
  • The due date is fourteen calendar days after the borrowing date.
  • A return cannot precede the borrowing date.
  • Returning a copy makes it available for another loan.

These are example policies, not universal library policies. A real discovery conversation would ask about membership eligibility, renewals, lost copies and overdue loans.

Three DDD terms help organize that conversation. A bounded context is a boundary within which a particular model and vocabulary apply. A value object describes a value through its attributes. An aggregate groups state and behavior behind a controlled entry point so its local rules stay consistent. Bounded contexts, DDD reference by Eric Evans

In this example, Catalog describes known physical copies; Lending manages active borrowing. Treat these as candidate model boundaries to investigate, rather than claiming a package proves that a bounded context exists. The technical web module is an HTTP adapter. It is not a business bounded context. A larger context can contain several modules.

Our small Loan is the aggregate root for its return behavior. The rule “one active loan per copy” spans successive loans, so the Lending service enforces it in the active-loan collection. The receipt is an immutable API snapshot represented by a Java record; a Java record does not automatically become a DDD value object. We use strings for identifiers to keep the first project approachable. Dedicated identifier value objects are a useful next exercise.

Give each module a clear public surface

UML component diagram: web depends on the Lending API; Lending depends on Catalog; Loan stays internal to Lending. All modules run in one Spring Boot application.

The complete source layout is:

com.homannsoftware.library
  LibraryApplication.java
  catalog
    package-info.java
    Catalog.java
  lending
    package-info.java
    Lending.java
    LoanReceipt.java
    internal
      Loan.java
  web
    package-info.java
    internal
      LoanController.java

By default, Modulith derives modules from the direct subpackages of the application package. Public types in a module’s base package form its default API; its subpackages are internal unless explicitly exposed. That makes Lending and LoanReceipt accessible to web, while lending.internal.Loan is hidden at the module level. Modulith fundamentals

“Internal” here is an architecture rule. Loan is a public Java class because Lending lives in a different Java package and must use it. Java itself permits another module to import it. The architecture test below catches that forbidden dependency. These are logical application modules, separate from Maven projects or Java Platform Module System modules.

The dependency rule for Lending is explicit:

@org.springframework.modulith.ApplicationModule(allowedDependencies = "catalog")
package com.homannsoftware.library.lending;

The same annotation allows web to depend only on lending; Catalog declares an empty set of allowed module dependencies. This creates the intended direction: web → lending → catalog. Keep dependencies purposeful; placing everything in a shared package makes ownership harder to see.

Give modules explicit contracts—and choose how they communicate

A useful design habit is to give each business module a small, explicit public contract. Other modules should ask it to perform a use case or provide information through that contract. They should not reach into its repositories or mutable domain objects.

There are two meanings of interface to distinguish. A Java interface declares methods that an implementation must provide. A Spring Modulith module interface describes the types exposed to other modules. By default, the module’s base package is its API. An optional @NamedInterface exposes an additional package and lets consumers declare dependencies such as lending :: events. It is not a Java interface declaration. Modulith does not require exactly one Java interface per module: several focused contracts, public data types and event types can form a module’s API. Modulith fundamentals

The starter above exposes Catalog and Lending as concrete service classes. To show the alternative without making the first example harder to follow, the accompanying interface-based variation replaces those public classes with Java interfaces and moves the service implementations into internal packages. Its business rules and HTTP behavior are the same.

The Catalog contract is deliberately small:

package com.homannsoftware.library.catalog;

public interface Catalog {
    void requireCopy(String copyId);
}

Lending exposes the use cases that its callers need:

package com.homannsoftware.library.lending;

import java.time.LocalDate;

public interface Lending {
    LoanReceipt borrow(String copyId, String memberId, LocalDate date);
    void returnCopy(String copyId, LocalDate date);
}

The variation uses this arrangement:

catalog
  Catalog.java                   public Java interface
  internal/DefaultCatalog.java   Spring service implementation
lending
  Lending.java                   public Java interface
  LoanReceipt.java               public response data
  internal/DefaultLending.java   Spring service implementation
  internal/Loan.java             domain behavior
web
  internal/LoanController.java   depends on Lending

DefaultCatalog implements Catalog; DefaultLending implements Lending. Both are annotated with @Service. The existing controller can still receive Lending through its constructor, but the type now denotes the contract. Spring injects the matching implementation bean. Similarly, DefaultLending receives the Catalog contract. If several beans implement a contract, use an explicit selection policy such as @Qualifier; an interface alone does not decide which bean to inject. Spring constructor injection

The architecture rule remains web → lending → catalog. Implementations live inside their owning modules; callers use interfaces and immutable response data. A Java interface makes the dependency explicit, while the Modulith architecture test checks that production code does not bypass the boundary. The separate variation passed 17 tests, including actual HTTP requests and the architecture check. Its three intentional mutations—internal access, a dependency cycle and removal of duplicate-loan protection—were rejected, and the restored baseline passed again.

Download the tested interface-based variation (ZIP). Unzip it, open a terminal in interface-example, and run mvn clean test. You can start it with mvn spring-boot:run, using the same sample JSON and curl requests as the first project. Run one variation at a time on port 8080.

Direct calls when an answer is needed now

A direct interface call is a synchronous, in-process interaction. For example, Lending calls Catalog.requireCopy(copyId) before accepting a loan. It needs the outcome immediately; an exception prevents the borrow from proceeding. There is no REST endpoint or network hop between these modules.

Use this approach when the caller needs a result or a prerequisite check as part of its use case. Keep the dependency direction clear and avoid chains of modules calling each other in circles. With persistent storage, decide the transaction boundary explicitly; injecting an interface does not create one. Our in-memory variation still has no database transaction.

Events when another module reacts to a business fact

For a follow-up responsibility, an event can reduce knowledge of the consumer. A future Notifications module could react to a LoanBorrowed fact instead of Lending calling a notification service directly. Lending would publish the event through Spring’s ApplicationEventPublisher; Notifications would depend on the public event type. That is still a code dependency, even though the publisher need not know the listener. Spring Modulith recommends event publication and consumption for loosely coupled module interaction. Modulith application events

Events are not automatically asynchronous. With Spring’s default event multicaster, an ordinary @EventListener runs synchronously in the publishing thread. Its failure can affect the caller; event publication is not a promise of background processing or durable delivery. Spring application-context events

For transactional follow-up work, Modulith’s @ApplicationModuleListener combines asynchronous, after-commit handling with a separate transaction. This requires the relevant asynchronous and transaction infrastructure. A configured persistent Event Publication Registry can record outstanding listener deliveries and support retry; it does not make downstream side effects exactly-once. Consumers still need duplicate-safe behavior. Our in-memory starter and interface variation do not implement this event path. Homann’s tested reliable-events example shows that next stage. Modulith event listener and registry reference

Start with the interaction’s meaning: a call asks for work or information; an event reports a fact that interested modules can react to. An event that is really a request for an immediate answer usually makes the flow harder to understand. Neither communication style turns a logical module into an independently deployed microservice.

An event communication example: Lending tells Notifications what happened

Let us make the event idea concrete with a third runnable variation. Catalog still answers the prerequisite check directly. After a successful borrow, Lending publishes a LoanBorrowed fact, and a new Notifications module records a notice. Lending does not import or call the notification listener. Notifications depends on Lending’s public event type, so the dependency points from the consumer to the event-owning module.

This example uses Spring’s default synchronous, in-process event delivery. It introduces no message broker and sends no email. It is a small step for learning publication and consumption before adding persistent transactions and reliable delivery. Spring Modulith describes this publication pattern as a way to decouple modules from the specific consumers of their events. Modulith event communication

The immutable event belongs to Lending’s public API:

package com.homannsoftware.library.lending;

import java.time.LocalDate;

public record LoanBorrowed(String copyId, String memberId, LocalDate dueOn) {}

The variant’s DefaultLending receives both Catalog and ApplicationEventPublisher through its constructor. After validating the input, checking Catalog and rejecting an existing loan, it performs these final steps in borrow():

var receipt = loan.receipt();
activeLoans.put(copyId, loan);
events.publishEvent(new LoanBorrowed(receipt.copyId(), receipt.memberId(), receipt.dueOn()));
return receipt;

Notifications declares @ApplicationModule(allowedDependencies = "lending"). Its listener lives in notifications.internal:

package com.homannsoftware.library.notifications.internal;

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import com.homannsoftware.library.lending.LoanBorrowed;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class LoanNoticeListener {
    private final List<LoanBorrowed> notices = new CopyOnWriteArrayList<>();

    @EventListener
    public void on(LoanBorrowed event) {
        notices.add(event); // Demonstration only: records a notice, sends no email.
    }

    public List<LoanBorrowed> recordedNotices() {
        return List.copyOf(notices);
    }
}

recordedNotices() returns an immutable snapshot for the example’s tests. A production notification module would implement its own delivery use case; this list is only an observable learning substitute. The event carries identifiers and a due date instead of exposing Lending’s mutable Loan object.

UML sequence diagram: Lending publishes LoanBorrowed through Spring; Notifications records a notice synchronously before the borrowing response returns.

The diagram shows the successful path in the tested application. The default multicaster calls the @EventListener on the publishing thread. The listener returns before publishEvent() returns, and the controller then responds with 201 Created. An event is therefore a change in how responsibilities communicate, not proof of asynchronous processing. Spring application-event behavior

Verified result: 19 tests passed. In addition to the original domain, service, architecture and HTTP tests, the event integration test checks the event’s fields, successful delivery, and the absence of additional events for rejected duplicate or unknown-copy requests. Returning and borrowing again produces a second event. The architecture test also verifies the new Notifications module and generates its PlantUML documentation.

The failure test uses an actual Spring event listener that throws. Publication fails back to the caller, but the loan is still in the map: this in-memory example has no transaction that rolls the change back. A subsequent borrow correctly finds the copy already on loan. That is a deliberately tested limitation, not the desired reliability policy for a production notification system.

Download the tested event communication example (ZIP). Unzip it, open a terminal in event-example, and run mvn clean test. Start the server with mvn spring-boot:run; the original JSON and curl requests still apply. Run one project at a time on port 8080. The ZIP includes the sequence diagram’s PlantUML source and generated module diagrams.

For important follow-up work, extend the design with persistent state, an appropriate transaction boundary, after-commit processing and durable publication tracking. @ApplicationModuleListener and a configured Event Publication Registry support that next stage, with duplicate-safe consumers still required. The earlier reliable-events article demonstrates that separate transactional arrangement. Do not replace this listener annotation and assume the in-memory example has gained those guarantees. Modulith transactional event support

Let Boot start the application and Spring connect it

The Maven project pins the Boot parent and imports the Modulith bill of materials (BOM). A BOM manages compatible dependency versions; it does not download every library into your application. The project includes spring-boot-starter-webmvc, validation, the Modulith API, and test/documentation dependencies. See the complete pom.xml in the download for all coordinates.

The entry point is short:

package com.homannsoftware.library;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class LibraryApplication {
    public static void main(String[] args) {
        SpringApplication.run(LibraryApplication.class, args);
    }
}

Place it above the modules in the package tree so the default component scan finds their services and controller. @SpringBootApplication enables the standard Boot configuration arrangement, and SpringApplication.run(...) creates the application context and starts the application. Boot application guide

Our application service is the public entry point for lending:

package com.homannsoftware.library.lending;

import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import com.homannsoftware.library.catalog.Catalog;
import com.homannsoftware.library.lending.internal.Loan;
import org.springframework.stereotype.Service;

@Service
public class Lending {
    private final Catalog catalog;
    private final Map<String, Loan> activeLoans = new HashMap<>();

    public Lending(Catalog catalog) {
        this.catalog = catalog;
    }

    public synchronized LoanReceipt borrow(String copyId, String memberId, LocalDate date) {
        var loan = new Loan(copyId, memberId, date);
        catalog.requireCopy(copyId);
        if (activeLoans.containsKey(copyId)) {
            throw new IllegalStateException("Copy already on loan");
        }
        activeLoans.put(copyId, loan);
        return loan.receipt();
    }

    public synchronized void returnCopy(String copyId, LocalDate date) {
        var loan = activeLoans.get(copyId);
        if (loan == null) throw new IllegalStateException("No active loan");
        loan.returnOn(date);
        activeLoans.remove(copyId);
    }
}

@Service makes the class discoverable as a Spring bean. Its single constructor declares the Catalog dependency. Calling borrow creates a valid loan, checks that the copy exists, rejects an existing active loan and stores the new one. LoanReceipt carries the response data without exposing the mutable loan object.

The synchronized keyword serializes calls on this service instance. In this demonstration, it keeps the check and insertion together and coordinates borrowing with returning. It is a coarse lock that also serializes operations on different copies. It provides no coordination between application instances.

Scope of the demo: the catalog and active loans live in memory. There is no database, history or recovery after restart. The API accepts dates supplied by the caller to make the tutorial reproducible. A production service needs an explicit policy for trusted dates, authentication, membership checks, persistence and concurrent database updates. Adding @Transactional to this HashMap would not make its contents persistent or give them database rollback semantics.

Keep the business behavior in ordinary Java

The loan object contains the rule for a valid return:

package com.homannsoftware.library.lending.internal;

import java.time.LocalDate;
import java.util.Objects;
import com.homannsoftware.library.lending.LoanReceipt;

public final class Loan {
    private final String copyId;
    private final String memberId;
    private final LocalDate borrowedOn;
    private boolean returned;

    public Loan(String copyId, String memberId, LocalDate borrowedOn) {
        if (copyId == null || copyId.isBlank() || memberId == null || memberId.isBlank()) {
            throw new IllegalArgumentException("Copy and member are required");
        }
        this.copyId = copyId;
        this.memberId = memberId;
        this.borrowedOn = Objects.requireNonNull(borrowedOn);
    }

    public LoanReceipt receipt() {
        return new LoanReceipt(copyId, memberId, borrowedOn, borrowedOn.plusDays(14));
    }

    public void returnOn(LocalDate date) {
        Objects.requireNonNull(date);
        if (returned) throw new IllegalStateException("Loan already returned");
        if (date.isBefore(borrowedOn)) {
            throw new IllegalArgumentException("Return precedes borrowing");
        }
        returned = true;
    }
}

There are no Spring annotations here. The constructor rejects missing identifiers, and receipt() calculates the due date. returnOn() refuses a date before borrowing or a second return on the same object. Only after those checks does it change the state.

That order matters. If an invalid return throws an exception, the loan remains active. The service removes it from the collection only after a successful return. Our tests verify that a rejected return does not accidentally make the copy available.

The HTTP adapter translates between JSON and the service. @RestController identifies a controller whose results become HTTP response bodies, @RequestBody reads JSON into a request record, and @Valid triggers validation of the record’s annotated fields. The controller chooses 201 Created for borrowing, 204 No Content for returning, 400 Bad Request for invalid input or an unknown copy, and 409 Conflict for an incompatible loan state. The full controller is in the runnable project. Spring REST guide, Spring MVC validation

Follow one request through the application

UML sequence diagram of borrowing: client sends a POST, the controller invokes Lending, Catalog validates the copy, Lending creates the loan and the response returns a due date.

The controller knows the public lending service. The service knows the public catalog service and its own loan implementation. The domain object knows its rules. That division makes it easier to locate a change: a new lending period belongs near loan behavior, while a different HTTP representation belongs in the adapter.

We use a synchronous service call because borrowing needs an immediate answer about whether the copy is known. Events become useful when a separate responsibility can react to a completed business fact—for example, sending a notification after a loan has been stored. That introduces additional decisions about transactions, failures and duplicate delivery. Homann’s guide to business boundaries and reliable events covers that next step.

Run it locally

Download the complete tested example project (ZIP), including Maven configuration, Java sources, tests and sample request files.

Install a JDK 21 and Maven, unzip the accompanying examples, and open a terminal in examples. The verified environment used Eclipse Temurin 21.0.11 and Maven 3.9.11, with no preview language features.

mvn clean test
mvn spring-boot:run

In a second terminal, borrow a copy. Save this as borrow.json:

{"copyId":"copy-001","memberId":"m-1","borrowedOn":"2026-10-13"}

Then send it:

curl -i -H "Content-Type: application/json" --data-binary @borrow.json http://localhost:8080/loans

On Windows PowerShell, use curl.exe to select the curl executable explicitly. The successful response is 201, with this JSON content (property order is not significant):

{"copyId":"copy-001","memberId":"m-1","borrowedOn":"2026-10-13","dueOn":"2026-10-27"}

Repeat the request while the loan is active: it returns 409. To return the copy, save {"returnedOn":"2026-10-14"} as return.json and send:

curl -i -H "Content-Type: application/json" --data-binary @return.json http://localhost:8080/loans/copy-001/return

That returns 204. You can now borrow the copy again. Stop the application with Ctrl+C; restarting clears the demo’s state.

Make the module boundary executable

A diagram is useful, but a test can check whether code still follows it:

package com.homannsoftware.library;

import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.docs.Documenter;

class ArchitectureTest {
    @Test
    void verifiesBoundariesAndDocumentsModules() {
        var modules = ApplicationModules.of(LibraryApplication.class).verify();
        new Documenter(modules).writeModulesAsPlantUml().writeIndividualModulesAsPlantUml();
    }
}

Modulith’s verify() checks cycles, access to internal packages and the allowed dependencies we declared. The Documenter generates PlantUML descriptions from the analyzed module structure under target/spring-modulith-docs. Those generated diagrams accompany the source; the article illustrations are simplified views for learning. Module verification, Module documentation

Verified result: 17 tests passed, with zero failures, errors or skipped tests. We also deliberately introduced three defects: a Web reference to the internal Loan class, a Catalog-to-Lending dependency creating a cycle, and removal of the duplicate-loan guard. The relevant tests rejected all three; after restoring the source, all 17 baseline tests passed again. These mutation checks are supplied as a separate Python script; running the application itself needs only Java and Maven.

The domain tests exercise rules directly without starting Spring. Service tests check active-loan behavior, including two simultaneous borrowers competing for one copy. The HTTP tests start Boot on a random local port and send actual HTTP requests using Java’s HttpClient. They check successful borrowing, conflict, invalid return, return and reborrowing, plus malformed or incomplete input. The architecture test checks the dependency arrangement and writes documentation.

An architecture test cannot decide whether Catalog and Lending are the right business boundaries. That still requires conversation and design judgment. Likewise, the concurrency test proves a property of this single in-memory service; it is not evidence of distributed locking or production performance.

Keep the reasons for architectural choices in /docs/adr

A useful good practice is to keep Architecture Decision Records (ADRs) beside the source code, in /docs/adr relative to the repository root. An ADR explains one significant choice: the problem, the alternatives, the decision and its consequences. A future maintainer can then understand why the application has a particular shape, even when the people who made the decision are no longer on the team. Michael Nygard: Documenting Architecture Decisions

MADR means Markdown Architectural Decision Records. It is a structured Markdown format for writing ADRs, rather than a second kind of document that you must maintain alongside them. Its template helps capture context, decision drivers, considered options, the outcome and how to confirm that the decision is being followed. MADR’s documentation uses docs/decisions and permits other directory conventions; here we recommend /docs/adr as the project convention. It is not a requirement imposed by Spring Modulith. MADR documentation and template

For this tutorial, a small decision log could look like this:

docs/adr/
  README.md
  template.md
  0001-use-a-modular-monolith.md
  0002-expose-module-contracts.md
  0003-use-synchronous-calls-for-prerequisite-checks.md

The first record explains the fit between one deployment and the customer’s operating environment. The second records public module contracts, internal implementation packages and allowed dependency directions. The third explains why borrowing needs an immediate Catalog check, while notifications could later react to an event.

Here is a shortened MADR-style record for the third decision. Its accepted status refers to the tutorial design, not to an approval in a real customer project:

---
status: accepted
date: 2026-10-13
---
# Use synchronous calls for prerequisite checks

## Context and Problem Statement
Borrowing must reject an unknown copy before creating an active loan.

## Decision Drivers
- The caller needs an immediate answer.
- The starter runs in one process and stores loans in memory.

## Considered Options
- Call Catalog directly through its public contract.
- Request validation through an event and await a response.

## Decision Outcome
Choose the direct call. It makes the prerequisite and failure path explicit.
In the interface variation, Lending depends on the Catalog Java interface.

### Consequences
- Lending depends on Catalog's public API.
- Both participate synchronously in the request.
- Future notifications may use events with separate delivery semantics.

### Confirmation
Run LendingTest, HttpTest and ArchitectureTest.
They check rejected unknown copies, HTTP behavior and module dependencies.

Draft a record when a meaningful choice arises, review it with the people responsible for the decision, and version it with the related change. Include the real customer constraints and evidence: operating capacity, release needs, workload assumptions and estimated costs. Use links to requirements, diagrams and tests so the rationale can be checked. A test confirms behavior or structure; it does not prove that the original economic assumptions remain valid.

Keep accepted reasoning as history. When a choice changes materially, create a new record, mark the old one as superseded and link the two. A proposed record is not approval to implement a decision. Avoid writing an ADR for every class or routine method; focus on choices with lasting consequences. This keeps the decision log useful rather than turning it into paperwork. Nygard’s ADR lifecycle guidance

What to learn next

Change one business rule first: add a maximum lending period or model a member’s eligibility. Write an example of accepted and rejected behavior before adjusting the implementation. Then introduce typed identifiers and decide which component owns each rule.

After that, replace the active-loan map with persistent storage. Design a database constraint or locking strategy for the one-active-loan rule, test competing transactions, and retain loan history. Add authentication and authorization before exposing borrowing to real members. Introduce events when an actual follow-up responsibility needs them, with tests for failure and repeated delivery.

For a small CRUD application with little business behavior, simpler code may be sufficient. As rules accumulate, this approach gives you places to put them and a way to check some of the boundaries. Your first useful milestone is a working use case whose language, responsibilities and tests agree.