A Spring application can have controllers, services, repositories and a database—and still make its business rules surprisingly hard to find. When the rules for confirming an order are scattered across HTTP handlers, persistence callbacks and generic update methods, even a small change becomes a search exercise.

Domain-Driven Design, usually shortened to DDD, gives us a way to make those rules explicit. We start with the business problem, agree on a useful model, and give the model clear responsibilities. Spring then supplies the application infrastructure around it.

This guide builds a small ordering application from that starting point. You will learn how to identify boundaries, model an aggregate, represent money, coordinate a use case, persist changes and test the rules. The complete Java project accompanies the article, together with editable UML and BPMN sources.

The central goal is to make business decisions visible in the code and difficult to bypass. A particular package structure is useful only when it supports that goal.

You need basic Java knowledge, some familiarity with classes and interfaces, and an understanding of HTTP requests. You do not need previous DDD experience. If Spring itself is new to you, read our guide to building a first Spring Boot REST API alongside this article.

1. Why this topic matters in the current Spring ecosystem

Research checked on October 29, 2026 points to a particularly useful direction for beginners: building a modular application whose business boundaries can be inspected and tested before introducing distributed services.

This is an editorial assessment of technical relevance, not a survey claiming that one architecture has the largest market share. Homann Software’s engineering focus makes the connection straightforward: Java, Spring and explicit software architecture are directly relevant to turning business requirements into maintainable systems.

Three developments and capabilities shape this tutorial:

Development or capability What the primary source establishes What we do with it
A current stable Spring Boot baseline The project’s 4.1.2 release was published on October 22, 2026. Pin the example to Boot 4.1.2 rather than a moving version.
Explicit modules inside one application Spring Modulith’s fundamentals describe module APIs, internals and dependencies. Keep Catalog and Ordering in one deployment with a deliberate dependency between them.
Architectural rules that run as tests Modulith verification checks module cycles, internal access and declared dependencies. Add a boundary test alongside business and integration tests.

The documentation currently lists Spring Modulith 2.1.1 as stable and 2.2.0-M2 as preview. Spring Boot’s documentation lists 4.1.2 as stable and 4.2.0-RC1 as preview. Our tested combination is Boot 4.1.2, Modulith 2.1.1, Java 21.0.11 and Maven 3.9.11. These versions describe this reproducible lab, not a permanent recommendation to ignore future updates.

DDD itself is much older than these releases. New framework support makes certain architectural decisions easier to check; it does not replace the conversations needed to discover a good business model.

2. What DDD actually means

In DDD, the domain is the area of business activity we are trying to understand. A model is a purposeful simplification of that activity. A ubiquitous language is the shared vocabulary used by the people discussing and implementing that model. These concepts and the patterns introduced below are described in Eric Evans’s DDD Reference.

For our shop, the vocabulary includes a draft order, an order line, a price snapshot, confirmation and cancellation. A conversation might reveal a rule such as: “An order can be confirmed only when it is a draft and contains at least one line.”

That sentence should remain recognizable in the implementation. A method named confirm() communicates more than updateStatus("CONFIRMED"), because it names a business action and can enforce its preconditions.

Two aspects of DDD help us ask different questions:

  • Strategic design: Which problems belong together? Where does a term have one precise meaning? Who owns a model and its integration contracts?
  • Tactical design: Inside a chosen boundary, which objects carry identity, which are values, and where do consistency rules live?

Beginners often start with tactical patterns because they look like code. Spend time on the strategic questions first. Otherwise, an elaborate domain model may encode the wrong responsibilities very carefully.

DDD is most valuable when business behavior is rich, terminology is ambiguous, or rules change often. A small reference-data screen may need little more than straightforward CRUD—create, read, update and delete. Apply the modeling effort where it reduces confusion.

3. Start with a business conversation

Before writing annotations, ask someone who understands the process to walk through a typical order and several exceptions.

What makes an order a draft? When does a quoted price become an agreed price? Can a confirmed order be edited? Does cancellation mean abandoning a draft or reversing an accepted commercial commitment? Who decides the product price? What should happen when two requests change the same order?

For this teaching example, we agree on a deliberately narrow policy:

  1. A new order starts as DRAFT and may initially be empty.
  2. A line contains a product code, a quantity and a snapshot of the server’s quoted unit price.
  3. Quantities range from 1 to 1,000. An order contains at most 100 lines.
  4. This shop accepts EUR only. Amounts use integer cents.
  5. Only a non-empty draft can be confirmed.
  6. Only a draft can be cancelled. Confirmation and cancellation are terminal states in this model.
  7. Confirmed or cancelled orders cannot acquire further lines.
  8. A stale concurrent write must not overwrite another committed change.

These are our example’s decisions, not universal commerce rules. Another business may allow amendments, partial cancellations or multiple currencies. Discover and name those behaviors before changing the model.

Our first use case is “Confirm an order.” The caller supplies an order identifier; the application loads the order; the order checks its rules; the application publishes an in-process fact; and the transaction commits. An unknown identifier produces a not-found response. An empty or terminal order produces a state conflict.

This small use-case description gives us a foundation for the API, diagrams and tests. Keep it beside the code as the behavior evolves.

4. Find bounded contexts before choosing services

A bounded context defines where a model and its vocabulary apply. Martin Fowler’s explanation of bounded contexts illustrates why large systems can need different models for apparently similar concepts.

Consider the word “product.” Catalog cares about the current offer. Ordering cares about the product code and the price agreed for a particular line. Billing may care about invoice descriptions, tax treatment and legally relevant records. One shared mutable Product entity would tie these concerns together unnecessarily.

Proposed context map: Ordering requests a quote from Catalog and may later integrate with Billing through events. Billing is not implemented.

The lab implements Catalog and Ordering. Billing is shown only to explain a possible future boundary. The arrows describe specific interactions; they do not prescribe a universal context-mapping relationship.

A subdomain is a part of the business problem. A bounded context is a boundary around a particular model used to solve part of that problem. A module is a technical organization mechanism. We deliberately map two candidate contexts to two application modules in this lab; these concepts do not automatically have a one-to-one relationship in every system.

A bounded context also does not require a separate microservice. We can keep both modules in one Spring Boot process, with one database and one deployment. Extracting a service later would introduce network failures, independent deployment concerns and consistency questions. None of those is necessary to understand the initial order rules.

For a broader introduction to the framework roles, our Spring, Spring Boot, Spring Modulith and DDD beginner guide provides a companion overview. Here we concentrate on building and defending the domain model itself.

5. Learn the tactical patterns through the order

An entity keeps its identity as its state changes. Our order remains the same order when it moves from draft to confirmed.

A value object represents a value without independent business identity. Money(3900, "EUR") expresses the same value as another instance with those components. The record’s equality semantics are useful here.

An aggregate groups objects whose rules are enforced through one root. Our Order is that root and owns its lines. The application asks the root to add a line or confirm the order rather than saving arbitrary line changes directly.

A repository supplies access to aggregates. A domain event represents a meaningful occurrence, such as OrderConfirmed. A domain service is appropriate when a business operation does not naturally belong to an entity or value object; this example does not need one.

UML class diagram showing Order as aggregate root, composition of zero to one hundred OrderLine objects, and OrderLine creating Money values.

The filled diamond denotes composition. OrderLine exposes an immutable value-style API, but its JPA embeddable implementation does not override equality. Money is the example’s complete value object with record equality.

Do not turn every table into an aggregate root. Our lines have no independent lifecycle or repository. Equally, do not put the entire shop inside one aggregate: a giant object graph would make unrelated changes contend for the same consistency boundary.

The right boundary follows the rules that must hold together. In this example, line contents and order status belong together because confirmation depends on those contents.

6. Give Spring and the domain different responsibilities

The project is organized by business capability first:

com.homannsoftware.shop
├── ShopApplication
├── catalog
│   ├── Catalog
│   └── package-info.java
└── orders
    ├── Orders
    ├── OrderConfirmed
    ├── package-info.java
    └── internal
        ├── domain
        │   ├── Order
        │   ├── OrderLine
        │   ├── Money
        │   └── OrderRepository
        ├── persistence
        │   ├── JpaOrderRepository
        │   └── OrderRepositoryAdapter
        └── web
            └── OrderController

Orders is the public application facade. It coordinates use cases and transaction boundaries. Order owns lifecycle decisions. Money owns its value constraints and arithmetic. The controller translates HTTP requests into calls. The persistence adapter connects our repository interface to Spring Data JPA.

This dependency direction is intentional: the domain does not import Spring, the web adapter or the persistence adapter. It does contain Jakarta Persistence annotations on Order and OrderLine. We choose that limited coupling to avoid teaching two separate object graphs and a mapper before readers understand aggregates.

A separate persistence model is a legitimate alternative when the mapping starts distorting the business model. It adds mapping code and another representation to maintain. Record that tradeoff in an architecture decision record rather than treating either choice as a badge of DDD correctness.

Spring, Spring Boot and Spring Data have different jobs here. Spring manages collaborating application objects and transactions. Boot configures the application from the dependencies and settings. Spring Data creates a repository implementation. None of them discovers the meaning of “confirm an order” for us.

7. Represent money explicitly

For this EUR-only example, integer cents keep arithmetic exact without introducing decimal rounding policy. The complete Money record is:

package com.homannsoftware.shop.orders.internal.domain;
import java.util.Currency;
import java.util.Objects;
public record Money(long cents, String currency) {
 public Money {
  if (cents < 0) throw new IllegalArgumentException("Negative amount");
  Objects.requireNonNull(currency, "currency");
  Currency.getInstance(currency);
  if (!"EUR".equals(currency)) throw new IllegalArgumentException("This shop accepts EUR only");
 }
 public Money plus(Money other) {
  if (!currency.equals(other.currency)) throw new IllegalArgumentException("Currency mismatch");
  return new Money(Math.addExact(cents, other.cents), currency);
 }
 public Money times(int quantity) {
  if (quantity < 1 || quantity > 1000) throw new IllegalArgumentException("Quantity must be 1..1000");
  return new Money(Math.multiplyExact(cents, quantity), currency);
 }
}

The constructor rejects negative amounts, invalid currency codes and currencies outside the lab’s policy. times() checks the quantity range. Math.addExact() and Math.multiplyExact() fail on overflow instead of silently wrapping a large positive amount into a different value.

This is a small domain-specific type, not a general monetary library. It does not calculate tax, discounts, exchange rates or currencies with other fraction conventions. A production pricing model must specify those rules, including rounding and the point at which rounding occurs.

The price comes from the server’s Catalog, whose fixtures are EUR 39.00 for BOOK-DDD and EUR 15.00 for MUG. Clients submit product code and quantity. They cannot choose the authoritative price by adding a price field to their JSON.

When the application adds a line, it copies the quote into the order. A later catalog update should not silently rewrite an existing order’s price. That is why a line stores a price snapshot rather than resolving its total from today’s catalog every time someone reads the order.

The lab requests a fresh quote on each add-line operation; it has no quote expiry, reservation or catalog persistence. Those are additional requirements to model if the business needs them.

8. Put lifecycle rules inside the aggregate

Here is the aggregate implementation used by the runnable project:

package com.homannsoftware.shop.orders.internal.domain;
import jakarta.persistence.*;
import java.util.*;
@Entity
@Table(name = "purchase_orders")
public class Order {
 public enum Status { DRAFT, CONFIRMED, CANCELLED }
 @Id private UUID id;
 @Version private Long version;
 @Enumerated(EnumType.STRING) private Status status;
 @ElementCollection
 @CollectionTable(name = "order_lines", joinColumns = @JoinColumn(name = "order_id"))
 @OrderColumn(name = "line_position")
 private List<OrderLine> lines = new ArrayList<>();
 protected Order() {}
 public static Order draft() {
  var order = new Order();
  order.id = UUID.randomUUID();
  order.status = Status.DRAFT;
  return order;
 }
 public void addLine(OrderLine line) {
  requireDraft();
  Objects.requireNonNull(line, "line");
  if (lines.size() >= 100) throw new IllegalStateException("At most 100 lines");
  // Validate the prospective total before changing state.
  total().plus(line.total());
  lines.add(line);
 }
 public void confirm() {
  requireDraft();
  if (lines.isEmpty()) throw new IllegalStateException("An empty order cannot be confirmed");
  status = Status.CONFIRMED;
 }
 public void cancel() { requireDraft(); status = Status.CANCELLED; }
 private void requireDraft() {
  if (status != Status.DRAFT) throw new IllegalStateException("Order is not a draft");
 }
 public Money total() {
  var result = new Money(0, "EUR");
  for (var line : lines) result = result.plus(line.total());
  return result;
 }
 public UUID id() { return id; }
 public Long version() { return version; }
 public Status status() { return status; }
 public List<OrderLine> lines() { return List.copyOf(lines); }
}

There are several useful details to notice.

First, callers receive an order through draft(), which establishes an identifier and initial state. The protected empty constructor exists for JPA; application code does not use it to build half-initialized orders.

Second, changing the order means calling a named operation. There is no public setStatus() or setLines() that bypasses the checks. confirm() makes the empty-order rule visible at the point where it matters.

Third, addLine() checks the prospective total before changing the collection. If arithmetic would overflow, the operation fails without leaving a partially changed in-memory aggregate. Database rollback is useful, but a domain object should also preserve its own state when it rejects an operation.

Fourth, lines() returns a copy of the list, and OrderLine exposes no public setters. A caller cannot clear the aggregate’s internal collection or edit a price through a returned line object.

Finally, the state machine is deliberately small: DRAFT can become CONFIRMED or CANCELLED; neither terminal state has an outgoing transition. A second confirmation returns a conflict. We have not implemented idempotent confirmation retries. If a client times out after a successful commit, it must reload the order to discover the outcome.

These rules are independent of the web framework. A scheduled job, a command-line interface or a future message handler should encounter the same decisions when using the model.

9. Use an application service to coordinate the use case

The application service loads the aggregate, obtains a quote when required, asks the aggregate to act and prepares a response. Here is the full facade:

package com.homannsoftware.shop.orders;
import com.homannsoftware.shop.catalog.Catalog;
import com.homannsoftware.shop.orders.internal.domain.*;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
@Transactional
public class Orders {
 private final OrderRepository repository;
 private final Catalog catalog;
 private final ApplicationEventPublisher events;
 public Orders(OrderRepository repository, Catalog catalog, ApplicationEventPublisher events) {
  this.repository = repository; this.catalog = catalog; this.events = events;
 }
 public View create() { return view(repository.save(Order.draft())); }
 public View addLine(UUID id, String productCode, int quantity) {
  var order = load(id);
  var quote = catalog.quote(productCode);
  order.addLine(new OrderLine(quote.productCode(), quantity,
    new Money(quote.unitPriceCents(), quote.currency())));
  return view(order);
 }
 public View confirm(UUID id) {
  var order = load(id);
  order.confirm();
  var total = order.total();
  events.publishEvent(new OrderConfirmed(id, total.cents(), total.currency()));
  return view(order);
 }
 public View cancel(UUID id) { var order = load(id); order.cancel(); return view(order); }
 @Transactional(readOnly = true)
 public View get(UUID id) { return view(load(id)); }
 private Order load(UUID id) { return repository.findById(id).orElseThrow(MissingOrder::new); }
 private View view(Order order) {
  var total = order.total();
  return new View(order.id(), order.status().name(), total.cents(), total.currency(),
    order.lines().stream().map(l -> new Line(l.productCode(), l.quantity(), l.unitPrice().cents())).toList());
 }
 public record Line(String productCode, int quantity, long unitPriceCents) {}
 public record View(UUID id, String status, long totalCents, String currency, List<Line> lines) {}
 public static class MissingOrder extends RuntimeException {}
}

The distinctions matter. Orders.confirm() is the use-case entry point; Order.confirm() is the business decision. The former needs infrastructure collaborators. The latter only needs the state it protects.

Orders.View and Orders.Line are response snapshots. They let the controller return the information clients need without exposing managed JPA entities. Mapping happens while the transaction is open, so loading a lazy line collection does not depend on an open persistence session in the web view. The project explicitly sets spring.jpa.open-in-view=false.

Constructor injection makes the collaborators visible. We do not need field injection, a global service locator or a base service hierarchy. A reader can see the dependencies by reading the constructor.

10. Understand what the transaction does

The facade uses Spring’s @Transactional. In this example, a modifying call normally runs in one transaction; if it is called inside an existing transaction with the default propagation, it participates in that transaction.

The controller calls the Spring-managed service bean, so the call passes through the transaction proxy. Spring’s transaction annotation documentation explains the default proxy behavior: self-invocation does not start an independently intercepted transaction. The default rollback rules include unchecked exceptions and errors; checked exception behavior requires deliberate configuration when appropriate.

The repository creates a new order through save(). For later operations, the loaded order is managed inside the transaction. Its state changes are persisted through JPA dirty checking at flush or commit; a second explicit save() is not required in this path.

The pure repository port is intentionally narrow:

package com.homannsoftware.shop.orders.internal.domain;
import java.util.Optional;
import java.util.UUID;
public interface OrderRepository {
 Order save(Order order);
 Optional<Order> findById(UUID id);
}

The Spring Data interface and adapter live under internal.persistence. The application uses the port; the domain does not depend on JpaRepository.

A transaction protects database changes participating in that transaction. It does not make an email reversible or include a remote payment provider automatically. Avoid placing an irreversible external side effect in the middle of confirmation and assuming rollback can undo it.

For the same reason, a business transaction and an HTTP request are different concepts. One request calls one use case in this example, but the model should be defined by consistency requirements rather than by transport convenience.

11. Protect against concurrent writers

Two requests can load the same draft before either commits. If one confirms it and another cancels its stale copy, merely checking status == DRAFT in each Java object is insufficient: both checks can succeed against their own snapshots.

The aggregate therefore has a @Version field. The version participates in optimistic concurrency control; a conflicting stale update must fail rather than overwrite a committed change. Jakarta Persistence describes this behavior in its entity version and optimistic locking specification.

Our integration test opens two independent entity managers and transactions. Both load one order. The first confirms and commits. The second tries to cancel the stale instance and fails at commit. A fresh read still reports CONFIRMED.

This test establishes the observed behavior for the supplied Hibernate/H2 combination. It is not a throughput benchmark or proof for every database isolation mode. The application maps translated optimistic-lock conflicts to HTTP 409; the stale-writer test checks the persistence failure directly rather than racing two HTTP clients.

There is another boundary to understand: @Version protects overlapping database work. It does not, by itself, reject an old browser screen if a later request loads the newest entity before making its change. For that requirement, design an expected-version or ETag/If-Match contract and test it. This lab does not expose such a contract.

12. Draw the process to expose missing outcomes

A short BPMN model makes the business alternatives easy to review:

BPMN confirmation process: load the order, reject a missing order, check that it is a non-empty draft, then confirm or reject it.

This non-executable BPMN 2.0 model shows business decisions. Its successful end means the business confirmation path; the full technical operation must also commit successfully. Concurrency and listener failures can still abort it.

The process starts with a confirmation request. A missing order ends on a not-found path. An existing order must be a non-empty draft. Otherwise, the request is rejected. The exclusive gateways mean one outgoing business path is chosen.

The notation follows OMG’s BPMN 2.0.2 specification. The download includes a .bpmn file with diagram coordinates so readers can edit the process. No workflow engine is used by the Java application.

BPMN and UML answer different questions here. BPMN helps review what happens and which outcomes exist. The UML class view helps review who owns the state and behavior. Neither substitutes for tests, but both can reveal questions before implementation becomes expensive.

13. Treat an event as a fact, and delivery as a separate problem

After the aggregate accepts confirmation, the application publishes this immutable event:

package com.homannsoftware.shop.orders;
import java.util.UUID;
public record OrderConfirmed(UUID orderId, long totalCents, String currency) {}

The name is in the past tense because it describes an occurrence. It carries the order identifier and total rather than a live JPA entity. A future consumer should not gain an indirect reference to another module’s mutable object graph.

There is a crucial timing detail: our event is published inside the transaction, before commit. As described in Spring Modulith’s event documentation, ordinary Spring application-event dispatch is synchronous by default. A synchronous listener can fail the originating operation, and a listener may observe the event before a later rollback.

The integration tests deliberately demonstrate that limitation. A listener exception rolls back the order’s confirmation. A separate test adds a line, confirms, then throws later in the surrounding transaction: the database returns to its earlier state even though the in-process listener has already run.

Therefore, an event received by such a listener is not evidence that the database has committed. The sample includes no shipping, billing or external delivery consumer, no outbox and no durable publication registry.

If secondary work must happen after commit and survive failures, design that delivery path explicitly. Spring Modulith provides an event publication registry and transactional listener support; our guide to business boundaries and reliable events explores that next step. Durable tracking also needs an operational recovery policy and duplicate-safe consumers. Do not infer exactly-once effects from publishing an event object.

14. Put validation at the right boundary

Our HTTP API accepts a product code and quantity. Jakarta Bean Validation catches malformed request values early, such as a blank code or quantity zero. The model still checks its own rules because callers other than HTTP may invoke it.

The controller does not contain the confirmation policy. It converts outcomes into an HTTP contract:

Request or outcome Response in this lab
Create an empty draft 201 Created with a Location header
Read or successfully modify an existing order 200 OK with a response snapshot
Invalid quantity or unknown catalog product 400 Bad Request
Unknown order identifier 404 Not Found
Empty confirmation, terminal-state edit or translated optimistic-lock conflict 409 Conflict

The request schema contains no authoritative price field. The HTTP test even submits unitPriceCents: 1; the fixture’s real quote still produces a total of 7,800 cents for two books. In this configuration the extra field is ignored, not rejected. A stricter unknown-field policy is another possible API choice.

Validation, authorization and business policy are separate concerns. This learning application binds to loopback and contains no authentication or per-order authorization. Adding a valid identifier would not establish that a caller owns that order. A real service must make that access decision before exposing or modifying business data.

15. Verify the module boundaries

The orders module declares its allowed external dependency in package-info.java:

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

The module root exposes Orders and OrderConfirmed. Its subpackages contain implementation details. Catalog exposes its small quoting API. This follows the module arrangement described in the Modulith documentation rather than relying on package names as decoration.

The architecture tests run with the business tests:

package com.homannsoftware.shop;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
class ArchitectureTest {
 @Test void modulesRespectTheirBoundaries() { ApplicationModules.of(ShopApplication.class).verify(); }
 @Test void domainDoesNotDependOnSpringOrAdapters() {
  noClasses().that().resideInAPackage("..orders.internal.domain..")
    .should().dependOnClassesThat().resideInAnyPackage("org.springframework..", "..internal.web..", "..internal.persistence..")
    .check(new ClassFileImporter().importPackages("com.homannsoftware.shop"));
 }
}

The first test checks Modulith’s structural constraints. The second adds a separate local rule: the domain cannot depend on Spring or either adapter package. JPA annotations remain permitted by our chosen mapping tradeoff.

These tests cannot tell us whether Catalog and Ordering are the best business boundaries. They can tell us when code violates the boundaries we declared. Review the model with domain experts; use automation to keep an agreed structure from drifting accidentally.

16. Test behavior at three levels

The delivered project passed 32 tests with zero failures, errors or skipped tests on Java 21.0.11 and Maven 3.9.11. The saved Maven log and Surefire XML reports are included in the article package.

Test group Tests What is exercised
Domain tests without a Spring context 16 Money, quantities, totals, overflow, line limits, collection protection and lifecycle rules
Architecture tests 2 Module dependencies and domain dependency direction
Spring/JPA integration tests 8 Persistence, price snapshots, events, rollback, cancellation and stale writers
HTTP tests through MockMvc 6 Status codes, validation, price authority and terminal-state edits

A useful domain test reads like a rule. For example, the project contains this test, executed without starting Spring:

@Test
void emptyOrderCannotBeConfirmed() {
    assertThatIllegalStateException()
        .isThrownBy(() -> Order.draft().confirm());
}

For an ordinary happy path, two books at EUR 39.00 and one mug at EUR 15.00 produce EUR 93.00. The domain test checks the exact 9,300-cent result. Other tests target failures that a happy path would miss: overflowing arithmetic must not add a line, cancelled orders must not be confirmed, and a rejected confirmation must not publish an event.

The Spring integration tests use H2 and the real repository/service wiring. MockMvc exercises Spring MVC inside the test process; those tests do not claim to test a network proxy or a browser. An additional live HTTP smoke check starts the packaged application and checks the create/add/confirm/read sequence and failure responses over loopback.

Keep the purpose of each test explicit. A unit test establishes a model rule quickly. An integration test checks infrastructure behavior that plain Java cannot establish. An architecture test protects dependency choices. Replacing one group with another leaves different gaps.

17. Run the complete example

Download the complete example project and editable diagrams

The ZIP contains the Maven project, setup instructions, live smoke-check scripts and editable diagrams. The article shows selected complete source files; the download contains the supporting classes and all tests, so you do not have to reconstruct a project from disconnected snippets.

Prerequisites are a JDK 21 installation and Maven 3.9.x. No Docker, database server, message broker or external account is required. Maven needs access to its normal artifact repositories on the first build.

From the extracted examples directory:

mvn clean verify
java -jar target/ddd-shop-1.0.0.jar

The application listens on http://127.0.0.1:8092. In another PowerShell window, this sequence creates and confirms the EUR 93.00 order:

$base = 'http://127.0.0.1:8092/orders'
$order = Invoke-RestMethod -Method Post -Uri $base
$id = $order.id
Invoke-RestMethod -Method Post -Uri "$base/$id/lines" `
  -ContentType 'application/json' `
  -Body '{"productCode":"BOOK-DDD","quantity":2}' | Out-Null
Invoke-RestMethod -Method Post -Uri "$base/$id/lines" `
  -ContentType 'application/json' `
  -Body '{"productCode":"MUG","quantity":1}' | Out-Null
Invoke-RestMethod -Method Post -Uri "$base/$id/confirm"

The last response reports status: CONFIRMED, totalCents: 9300 and currency: EUR. Try confirming an empty draft or adding another line after confirmation; both operations return 409.

The fixed catalog, in-memory database and schema creation are deliberate learning shortcuts. Restarting the application loses its orders. The project has no identity system, durable event delivery, migrations, production observability or comprehensive input resource limits. Those belong in a production design; they are not prerequisites for understanding the demonstrated aggregate.

18. Keep a small chain from requirement to evidence

DDD works better when the model remains connected to the reason it exists. For this example, that chain can be quite small:

Business rule or decision Implementation Evidence
A confirmation needs at least one line Order.confirm() Empty-order domain test and HTTP 409 test
An agreed price is a snapshot OrderLine fields and catalog lookup in Orders.addLine() Persisted price and exact-total tests
Terminal orders cannot be edited Root operations check draft status Domain lifecycle and HTTP edit tests
A stale writer must not erase confirmation Order.version Two-transaction integration test
Domain code stays independent of Spring and adapters Internal domain package ArchUnit dependency test
Ordering uses only the declared Catalog API Module declaration Modulith verification

The download includes a short architecture decision record for the single-process deployment, JPA annotation tradeoff, EUR-only money type and event-delivery scope. When requirements change, update the use case, model, diagrams and tests together.

Include non-functional requirements in that conversation too. An order that is correct for a single user may still need a concurrency policy, a response-time target, an audit trail or a retention policy. DDD helps organize business behavior; it does not automatically supply those qualities.

19. Common beginner mistakes

Starting with tables and adding DDD names afterwards. Begin with actions, decisions and language. Tables are a persistence design that should support the model.

Moving all decisions into a service. An application service that edits arbitrary fields leaves the aggregate with little responsibility. Let the object that owns the state protect its lifecycle.

Making every object a Spring bean. Our Money, OrderLine and Order instances are ordinary business objects. Dependency injection manages the surrounding application collaborators.

Assuming private fields solve every integrity problem. Encapsulation cannot resolve conflicting database snapshots. Model the rule and select an appropriate concurrency mechanism.

Sharing entities across contexts. Share explicit identifiers, value snapshots and contracts. A shared mutable entity makes independent meanings harder to maintain.

Using events without discussing failure. Ask when the event is observed, whether it survives a crash, how failures are retried and whether consumers tolerate duplicates.

Building every pattern at once. This tutorial needs neither event sourcing nor separate CQRS read models. A normal JPA-backed aggregate is enough to teach the business behavior. Add more mechanisms when requirements justify their costs.

20. What to build next

Work through changes that force a new business decision rather than only another annotation. Add a rule for maximum order value and decide whether it applies before or after discounts. Design confirmation retries with an idempotency contract. Introduce a real catalog adapter while keeping agreed prices stable. Replace H2 with your target database and rerun the persistence and concurrency tests against it.

Then explore reliable post-commit integration with a separate consumer, failure recovery and duplicate-safe handling. If another team owns billing, agree on its language and contract before sharing classes.

A successful first DDD application is one where a reader can follow “confirm an order” from the use-case description to the aggregate method, transaction boundary and failure tests. Spring can make that application convenient to run. The model makes its business behavior understandable.