Quick Index: Table of Contents
Domain-Driven Design (DDD) helps software engineers build maintainable systems for complex business domains. This masterclass provides a complete practical guide to both strategic and tactical DDD patterns using modern Java 25, Spring Boot 4, and Hibernate 7. Use this index to jump directly to any topic.
- 1. Introduction: Why Domain-Driven Design Matters
- 2. Strategic Design: Bounded Contexts and Context Mapping
- 3. Ubiquitous Language: Bridging Code and Business
- 4. Tactical Design: Core Building Blocks
- 5. Entities vs. Value Objects in Java 25
- 6. Aggregate Roots and Transactional Boundaries
- 7. Hexagonal Architecture: Ports and Adapters
- 8. Production Implementation: Order Management Domain
- 9. Domain Events and Asynchronous Context Integration
- 10. Architecture Governance with ArchUnit
- 11. Comparative Architecture Matrix
- 12. Real-World Enterprise Case Studies
- 13. Production Anti-Patterns and Concrete Fixes
- 14. Production Best Practices Checklist
- 15. Frequently Asked Questions (FAQ)
- 16. Companion Code and Official References
1. Introduction: Why Domain-Driven Design Matters
Software development teams frequently encounter architectural decay as enterprise applications scale. In traditional layered designs, systems often fall victim to the Anemic Domain Model anti-pattern. Entities become passive data holders filled with getters and setters. Meanwhile, critical business rules and validation logic scatter across procedural service layers. Over time, codebases become fragile, difficult to test, and risky to modify.
Eric Evans introduced Domain-Driven Design in 2003 to address this exact challenge. DDD places the core business domain and domain logic at the center of software engineering. Instead of organizing software around database tables, DDD models software around the mental model of business experts. In 2026, modern enterprise frameworks like Spring Boot 4, Java 25, and Hibernate 7 provide first-class language and architectural primitives that make implementing DDD more natural than ever before.
This masterclass bridges theoretical DDD concepts and production-grade Java code. You will learn how to partition complex enterprise domains into isolated Bounded Contexts. You will design rich Aggregate Roots that protect transactional invariants. You will integrate Hexagonal Architecture (Ports and Adapters) with Spring Boot 4. You will publish asynchronous Domain Events to decouple systems. Finally, you will write automated compile-time architecture tests using ArchUnit. To explore fundamental concepts before diving deep, see our beginner’s guide on Domain-Driven Design Explained Simply.
2. Strategic Design: Bounded Contexts and Context Mapping
Domain-Driven Design divides software design into two essential disciplines: Strategic Design and Tactical Design. Strategic design comes first. Attempting to build an entire enterprise platform around a single unified data model leads directly to the “Big Ball of Mud” anti-pattern. Different departments in an enterprise have completely different definitions for identical terms.

DDD solves semantic conflict through Bounded Contexts. A Bounded Context is a linguistic and conceptual boundary within which a specific domain model applies. Inside that boundary, every word in the model has exactly one clear meaning. For a thorough walkthrough of boundaries, read our dedicated article on Bounded Context in DDD Explained.
Consider a large e-commerce platform. The word Customer carries distinct meanings across departments:
- Sales & Ordering Context: A Customer is an active buyer with a shipping address, a shopping cart, and a payment token.
- Billing & Invoicing Context: A Customer is a legal billing entity with a tax registration number, credit limits, and outstanding ledger invoices.
- Warehouse & Logistics Context: A Customer is merely a delivery recipient with physical parcel dimensions and delivery gate instructions.
If you create a single database table named customers with 120 columns to satisfy all three departments, every team constantly conflicts. Any column change by the logistics team risks breaking the invoicing engine. In contrast, DDD creates three independent Bounded Contexts. Each context maintains its own model, its own database tables, and its own domain vocabulary. When contexts need to share information, they communicate through formal relationships called Context Maps:
- Shared Kernel: Two contexts share a small subset of the domain model and database tables. Both teams must agree before changing this code.
- Customer / Supplier: The upstream team (Supplier) delivers data to the downstream team (Customer). The downstream team’s delivery timeline depends on the upstream team.
- Conformist: The downstream context accepts the upstream model without changes. It conforms directly to the upstream contract.
- Anti-Corruption Layer (ACL): The downstream team creates an isolating translation layer. The ACL translates foreign upstream data models into clean internal domain concepts. This prevents external API churn from infecting internal business rules.
- Separate Ways: The two teams find that integration offers little business value. They implement independent solutions with zero coupling.
3. Ubiquitous Language: Bridging Code and Business
Communication breakdowns between software developers and business stakeholders cause most project failures. Developers speak in technical terms like “tables”, “DTOs”, “foreign keys”, and “HTTP endpoints”. Business experts speak in industry terms like “chargebacks”, “waybills”, “grace periods”, and “settlement tranches”. When developers translate business concepts into database operations, subtle bugs emerge.
DDD demands the creation of a Ubiquitous Language. The Ubiquitous Language is a rigorous, shared vocabulary co-created by domain experts and developers. It applies consistently across conversations, architecture diagrams, user stories, and most importantly, production source code.
If business experts say: “When an order is confirmed, we allocate inventory and lock the unit price,” then your Java code must reflect those exact words. You should never write:
// WRONG: Anemic technical jargon
orderService.setStatus(1);
orderService.updateDbRows(orderDto);
Instead, your code must speak the business language directly:
// CORRECT: Ubiquitous Language embedded in domain code
order.confirm();
order.allocateInventory(stockService);
When you align class names, method names, and domain events with the Ubiquitous Language, the code becomes self-documenting. Any business expert can review the domain logic, and any new developer can understand the business intent without guesswork.
4. Tactical Design: Core Building Blocks
While strategic design draws boundaries around systems, Tactical Design structures the code inside each Bounded Context. Tactical DDD provides proven design patterns to build rich, expressive domain models.

The primary tactical building blocks include:
- Entities: Objects defined by a continuous identity rather than their attributes.
- Value Objects: Immutable objects defined entirely by their attributes, with no conceptual identity.
- Aggregate Roots: The primary gateway entity that clusters related entities and enforces business invariants.
- Domain Services: Pure business operations that naturally do not belong to a single Entity or Value Object.
- Repositories: Collection-oriented abstraction interfaces that provide persistence and retrieval for Aggregate Roots.
- Domain Events: Immutable records of important business occurrences that have already happened.
5. Entities vs. Value Objects in Java 25
Understanding the distinction between Entities and Value Objects is fundamental to effective DDD modeling.
An Entity possesses a unique identifier that persists throughout its lifecycle. Even if every other attribute of an Entity changes, its identity remains constant. Two distinct users named “Alex Smith” with the same age are not the same user because their UserId values differ. In Java, Entities require careful implementation of equals() and hashCode() based strictly on their unique business identifier.
A Value Object has no identity. It describes a property, a quantity, or a measurement. If two Value Objects have identical attribute values, they are completely interchangeable. For example, a monetary value of USD 50.00 is identical to any other USD 50.00. Value Objects must be strictly immutable. Once constructed, their state cannot mutate. To change a value, you create a new instance with the updated attributes.
In modern Java 25, Java Records provide the ideal mechanism for implementing Value Objects:
package com.webcodein.ddd.domain.model.order;
import jakarta.persistence.Embeddable;
import java.util.UUID;
@Embeddable
public record OrderItem(UUID productId, int quantity, double price) {
public OrderItem {
if (productId == null) {
throw new IllegalArgumentException("Product ID cannot be null");
}
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be greater than zero");
}
if (price < 0.0) {
throw new IllegalArgumentException("Price cannot be negative");
}
}
public double subtotal() {
return quantity * price;
}
}
Using Java records for Value Objects guarantees immutability, compact constructor validation, and correct value-based equality semantics out of the box. For a side-by-side architectural comparison with simple CRUD records, check out our guide on DDD vs CRUD: Visual Guide for Java Developers.
6. Aggregate Roots and Transactional Boundaries
An Aggregate is a cluster of associated Entities and Value Objects that we treat as a single transactional unit for data changes. Every Aggregate has exactly one Aggregate Root.
The Aggregate Root acts as the gatekeeper for the entire cluster. It enforces the following fundamental rules:
- External References: Outside objects can only hold references to the Aggregate Root. They must never directly reference internal child entities.
- Invariant Protection: All business invariants (consistency rules) inside the aggregate must remain valid at the completion of every transaction.
- Direct Mutation Ban: Callers cannot modify internal children directly. For example, you cannot invoke
order.getItems().get(0).setPrice(...). All mutations must pass through intention-revealing methods on the Aggregate Root. - One Aggregate Per Transaction: A single database transaction should modify exactly one Aggregate instance. Modifying multiple aggregates in one transaction couples boundaries and hurts scalability. Use asynchronous Domain Events for cross-aggregate coordination.
7. Hexagonal Architecture: Ports and Adapters
A domain model must remain clean and decoupled from technical infrastructure. If your business entities contain database queries, HTTP annotations, or messaging broker drivers, business logic becomes tangled with framework details.

DDD pairs naturally with Hexagonal Architecture (also known as Ports and Adapters), created by Alistair Cockburn. Hexagonal architecture organizes the codebase into three distinct concentric layers:
- Domain Layer (Core): Contains Entities, Value Objects, Domain Services, and Repository interfaces. It has zero external library dependencies. It depends only on standard Java.
- Application Layer: Contains Application Services and use-case orchestrators. It coordinates transactions, loads aggregates, and triggers domain methods. It acts as an inbound adapter consumer.
- Infrastructure Layer: Contains technical implementations: Spring REST Controllers, Spring Data JPA Repositories, Kafka Producers, and third-party API clients.
The Dependency Inversion Principle ensures that dependencies always point inward. The Domain Core never depends on the database or the web framework. Instead, the database and web framework depend on the domain interfaces. To examine how Hexagonal compares with Onion and Clean architectures, explore our detailed analysis of Hexagonal vs Clean vs Onion Architecture.
8. Production Implementation: Order Management Domain
Let us examine the complete, working production implementation from our companion repository. We will inspect the Aggregate Root, the Repository interface, the Application Service, and the REST Controller.
Implementing the Order Aggregate Root
The Order class below represents our Aggregate Root. It encapsulates child items, manages lifecycle status transitions, and enforces all business invariants.
package com.webcodein.ddd.domain.model.order;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "orders")
public class Order {
@Id
private UUID id;
@Column(nullable = false)
private UUID customerId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "order_items", joinColumns = @JoinColumn(name = "order_id"))
private List<OrderItem> items = new ArrayList<>();
protected Order() {
// Required by Hibernate runtime reflection
}
public Order(UUID customerId) {
if (customerId == null) {
throw new IllegalArgumentException("Customer ID cannot be null");
}
this.id = UUID.randomUUID();
this.customerId = customerId;
this.status = OrderStatus.PENDING;
}
public void addItem(UUID productId, int quantity, double price) {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException("Cannot modify an order that is not pending.");
}
if (this.items.size() >= 100) {
throw new IllegalStateException("An order cannot contain more than 100 distinct items.");
}
this.items.add(new OrderItem(productId, quantity, price));
}
public void confirm() {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException("Only pending orders can be confirmed.");
}
if (this.items.isEmpty()) {
throw new IllegalStateException("Cannot confirm an empty order.");
}
this.status = OrderStatus.CONFIRMED;
}
public void cancel() {
if (this.status == OrderStatus.SHIPPED) {
throw new IllegalStateException("Cannot cancel an order that has already shipped.");
}
if (this.status == OrderStatus.CANCELLED) {
throw new IllegalStateException("Order is already cancelled.");
}
this.status = OrderStatus.CANCELLED;
}
public double getTotalAmount() {
return items.stream()
.mapToDouble(OrderItem::subtotal)
.sum();
}
public UUID getId() {
return id;
}
public UUID getCustomerId() {
return customerId;
}
public OrderStatus getStatus() {
return status;
}
public List<OrderItem> getItems() {
return Collections.unmodifiableList(items);
}
}
Notice three critical design decisions in this implementation:
- Encapsulated Collections:
getItems()returns an unmodifiable list. No external class can bypass the aggregate to add or remove items. - Intent-Revealing Methods: Business methods like
addItem(),confirm(), andcancel()clearly declare business intent and validate prerequisites before changing state. - Complete Invariant Enforcement: An empty order can never transition to
CONFIRMED. An order with 101 items cannot be constructed. Invariants stay protected at all times.
Defining the Domain Repository Interface
The repository interface resides in the Domain layer. It acts as an abstraction over persistent storage:
package com.webcodein.ddd.domain.repository;
import com.webcodein.ddd.domain.model.order.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.UUID;
@Repository
public interface OrderRepository extends JpaRepository<Order, UUID> {
}
In pure Hexagonal designs, you can declare an interface without framework imports and implement it in the infrastructure layer. In pragmatic Spring Boot applications, extending JpaRepository provides built-in CRUD and query capabilities while preserving aggregate boundary semantics. To see full layer separation in action, read our guide on DDD Layers Explained: Where Does Each Piece of Code Go?.
Orchestrating Use Cases with the Application Service
The Application Service coordinates application workflows. It does not contain business rules. Instead, it manages database transactions, loads aggregates, calls business methods, and saves changes.

package com.webcodein.ddd.application.service;
import com.webcodein.ddd.domain.model.order.Order;
import com.webcodein.ddd.domain.repository.OrderRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
@Service
public class OrderApplicationService {
private final OrderRepository orderRepository;
public OrderApplicationService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional
public UUID createOrder(UUID customerId) {
Order order = new Order(customerId);
return orderRepository.save(order).getId();
}
@Transactional
public void addProductToOrder(UUID orderId, UUID productId, int quantity, double price) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Order not found"));
order.addItem(productId, quantity, price);
orderRepository.save(order);
}
@Transactional
public void confirmOrder(UUID orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Order not found"));
order.confirm();
orderRepository.save(order);
}
}
Every method in OrderApplicationService runs inside an atomic @Transactional boundary. The service loads the aggregate, calls business logic on the aggregate root, and persists the result. The application service remains thin and easy to test.
9. Domain Events and Asynchronous Context Integration
When an Aggregate state changes, other Bounded Contexts often need to react. For instance, when an order is confirmed, the Billing context must generate an invoice, and the Shipping context must print shipping labels.

Directly calling other contexts synchronously via REST or gRPC inside the transaction creates tight coupling and distributed failures. Instead, DDD uses Domain Events. A Domain Event represents an immutable historical fact:
package com.webcodein.ddd.domain.event;
import java.time.Instant;
import java.util.UUID;
public record OrderConfirmedEvent(UUID orderId, UUID customerId, double totalAmount, Instant occurredAt) {
public OrderConfirmedEvent(UUID orderId, UUID customerId, double totalAmount) {
this(orderId, customerId, totalAmount, Instant.now());
}
}
To guarantee reliable event delivery without dual-write inconsistencies, pair domain events with the Transactional Outbox Pattern. Write the business state and the domain event into the same database transaction. A background worker or CDC tool (such as Debezium) then streams the outbox events to an Apache Kafka topic. Downstream contexts consume the event independently, achieving eventual consistency without locking distributed databases.
10. Architecture Governance with ArchUnit
Architecture diagrams easily degrade over time if rules are not enforced automatically. A developer might accidentally inject a Spring MVC controller into a domain entity or add JPA dependencies to the domain model.

With ArchUnit, you turn architectural rules into standard unit tests that run during every mvn test build. If someone violates a layer rule, the build fails immediately:
package com.webcodein.ddd.architecture;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
@AnalyzeClasses(packages = "com.webcodein.ddd", importOptions = ImportOption.DoNotIncludeTests.class)
public class DddArchitectureTest {
@ArchTest
public static final ArchRule domain_should_not_depend_on_application_or_infrastructure =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage("..application..", "..infrastructure..");
@ArchTest
public static final ArchRule application_should_not_depend_on_infrastructure =
noClasses()
.that().resideInAPackage("..application..")
.should().dependOnClassesThat().resideInAPackage("..infrastructure..");
@ArchTest
public static final ArchRule layered_architecture_boundaries_must_be_respected =
layeredArchitecture()
.consideringAllDependencies()
.layer("Domain").definedBy("..domain..")
.layer("Application").definedBy("..application..")
.layer("Infrastructure").definedBy("..infrastructure..")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure")
.whereLayer("Application").mayOnlyBeAccessedByLayers("Infrastructure")
.whereLayer("Infrastructure").mayNotBeAccessedByAnyLayer();
@ArchTest
public static final ArchRule controllers_should_reside_in_infrastructure_web_package =
classes()
.that().haveSimpleNameEndingWith("Controller")
.should().resideInAPackage("..infrastructure.web..");
@ArchTest
public static final ArchRule services_should_reside_in_application_service_package =
classes()
.that().haveSimpleNameEndingWith("Service")
.should().resideInAPackage("..application.service..");
}
These five tests prevent architectural decay automatically. Every team member gets instant feedback right in their IDE before pushing code.
11. Comparative Architecture Matrix
The table below compares five modern architectural patterns across eight technical dimensions. Use this matrix to select the right approach for your project.
| Dimension | Traditional CRUD | Tactical DDD | Hexagonal Ports & Adapters | Event-Driven CQRS | Spring Modulith |
|---|---|---|---|---|---|
| Primary Paradigm | Data-centric tables and DTOs | Domain-centric rich behavior | Input/Output interface isolation | Command & Query separation | In-process modular monolith |
| Domain Logic Location | Scattered across service classes | Encapsulated in Aggregate Roots | Isolated in Domain Core | Split into Command Handlers & Projectors | Encapsulated in bounded module packages |
| Invariant Protection | Weak (manual service validations) | Guaranteed by Aggregate boundary | Guaranteed by Core domain | Enforced on Write Model only | Enforced by Module API interfaces |
| Database Coupling | High (direct entity-to-table mapping) | Moderate (repositories abstract persistence) | Zero (isolated via adapter ports) | Low (different read and write schemas) | Moderate (can share or isolate schemas) |
| Concurrency Handling | Database row locks or optimistic checks | Optimistic versioning on Aggregate Root | Optimistic versioning on Aggregate Root | Event stream appending with idempotency | Optimistic locks with transactional events |
| Testing Strategy | Mock-heavy unit and integration tests | Pure POJO unit tests on Aggregates | Unit tests for Core; adapters tested separately | Given-When-Then event stream unit tests | ArchUnit module tests and verification |
| Cognitive Complexity | Low initially; unmaintainable at scale | Moderate; requires domain modeling skills | Moderate; requires strict discipline | High; requires distributed data mindset | Moderate; lower overhead than microservices |
| Best Fit Scenario | Simple admin portals, reporting tools | Complex core business engines, banking | Long-lived systems with changing tech stacks | High-scale event streaming, audit systems | Growing enterprise applications on JVM |
For modular monoliths running on Spring Boot 4, learn more in our detailed guide on Spring Modulith: Modular Monoliths with Spring Boot 4.
12. Real-World Enterprise Case Studies
Let us look at how leading enterprise organizations apply Domain-Driven Design in production:
Case Study 1: Global Freight Logistics & Route Optimization
Challenge: An international shipping company struggled with shipment tracking. A single freight shipment passes through ocean carriers, customs checkpoints, bonded warehouses, and local delivery trucks. Legacy CRUD systems updated shipment records concurrently, causing conflicting delivery statuses and lost containers.
DDD Solution: The engineering team created a Shipment Aggregate Root. Waypoint scans, customs clearances, and carrier transfers were modeled as domain operations on the aggregate. The aggregate enforces a strict rule: a container cannot be loaded onto a vessel without a valid customs release timestamp. Downstream tracking notifications were decoupled using ShipmentDispatchedEvent and CustomsClearedEvent published via Kafka. System errors dropped by 94%.
Case Study 2: FinTech Real-Time Multi-Currency Escrow Engine
Challenge: A payment gateway handled multi-party marketplace transactions. During transactions, seller payouts, buyer refunds, and escrow balances mutated simultaneously. Traditional service methods suffered from race conditions, resulting in currency conversion discrepancies and inconsistent account balances.
DDD Solution: The team modeled EscrowAccount as an Aggregate Root using immutable Money Value Objects. All monetary calculations are performed in minor currency units (cents) to avoid floating-point errors. The aggregate enforces the core invariant: the account balance cannot fall below zero, and currency exchanges must lock a guaranteed exchange rate quote. Optimistic locking on the Aggregate Root eliminated race conditions entirely.
Case Study 3: Healthcare EHR Patient Consent & Diagnostic History
Challenge: A hospital network needed to manage patient diagnostic records while strictly adhering to patient privacy consents under GDPR and HIPAA regulations. Direct database queries frequently leaked sensitive patient data to unauthorized diagnostic labs.
DDD Solution: The team introduced an Anti-Corruption Layer (ACL) and partitioned the system into a Clinical Diagnosis Context and a Patient Privacy Consent Context. Before diagnostic data is shared, the DiagnosticRecord aggregate queries the consent policy. If a patient revokes research sharing consent, the aggregate rejects the export operation at the domain boundary, preventing legal compliance breaches.
13. Production Anti-Patterns and Concrete Fixes
Avoid these three common pitfalls when applying DDD in production:
Anti-Pattern 1: The Anemic Setter Disease
Problem: Developers generate Lombok @Data or public setters on domain entities. Services bypass business validation and directly mutate fields anywhere in the application.
Fix: Remove all public setters. Make field mutations private or package-private. Provide intention-revealing business methods on the Aggregate Root that validate invariants before mutating state.
Anti-Pattern 2: The Monster God Aggregate
Problem: Designing an aggregate that includes thousands of child entities (for instance, a Customer aggregate that directly holds every historical order, invoice, and support ticket). Loading this aggregate consumes huge amounts of memory and causes constant database locking contention.
Fix: Break large aggregates into smaller, focused aggregates. Keep only what is strictly necessary for immediate transactional consistency. Reference other aggregates by their unique ID (e.g., CustomerId) rather than holding direct object pointers.
Anti-Pattern 3: Framework Pollution in the Domain Model
Problem: Adding Spring annotations, HTTP request mappings, or serialization tags directly inside domain model classes. When frameworks upgrade, domain logic breaks.
Fix: Enforce strict layer boundaries. Domain entities should rely exclusively on standard Java classes. Use ArchUnit tests in your continuous integration pipeline to block forbidden framework imports automatically.
14. Production Best Practices Checklist
Follow this checklist when reviewing any DDD pull request:
- ✓ Ubiquitous Language: Do class and method names match business concepts rather than database technical jargon?
- ✓ Encapsulation: Are internal aggregate collections protected by
Collections.unmodifiableList()? - ✓ Single Responsibility: Does each transaction update exactly one Aggregate Root instance?
- ✓ Value Object Immutability: Are Value Objects implemented using immutable Java records with validation in compact constructors?
- ✓ Asynchronous Decoupling: Are cross-boundary notifications communicated via Domain Events instead of synchronous HTTP calls?
- ✓ Compile-Time Governance: Are ArchUnit fitness tests passing in the CI build?
- ✓ Optimistic Locking: Does the Aggregate Root use a
@Versioncolumn to prevent concurrent update anomalies?
15. Frequently Asked Questions (FAQ)
When should I use Domain-Driven Design instead of simple CRUD?
Use Domain-Driven Design when your application has complex, evolving business logic, multiple business rules, or high cross-departmental coordination needs. If your system primarily performs simple data entry, reads, and writes with minimal validation, simple CRUD is faster, simpler, and more cost-effective.
How do Aggregate Roots prevent data corruption?
Aggregate Roots act as the sole entry point for all state modifications within an aggregate boundary. Because outside classes cannot directly manipulate internal child entities, all changes must pass through business methods on the root. The root validates business invariants before saving changes, ensuring data remains valid.
Can an Aggregate Root reference another Aggregate Root directly?
No. In Domain-Driven Design, an Aggregate Root should only reference other Aggregate Roots by their unique identifier (such as UUID or a strongly typed Id Value Object), never by direct object reference. This keeps aggregate boundaries small, avoids accidental memory loading, and allows independent database transactions.
How does ArchUnit help enforce Domain-Driven Design?
ArchUnit is a Java testing library that inspects compiled bytecode to verify architectural rules. It automatically ensures that the domain layer has no dependencies on web or persistence layers, that naming conventions are respected, and that layer boundaries remain clean. If a developer accidentally breaks a layer rule, ArchUnit fails the build immediately.
What is the difference between an Application Service and a Domain Service?
An Application Service orchestrates use-case workflows: it manages transactions, loads aggregates from repositories, and dispatches notifications. It contains no business logic. A Domain Service contains core business logic that naturally spans multiple entities or aggregates (such as complex currency exchange calculations) and belongs entirely in the Domain layer.
16. Companion Code and Official References
The complete, fully working reference implementation for this masterclass is open source and ready to run on GitHub:
GitHub Workshop Project: learn.webcodein.workshop: architecture/ddd-masterclass-java-guide
The companion project includes all Java 25 domain models, ArchUnit fitness rules, Spring Boot 4 REST controllers, and Testcontainers integration tests.
For further study, refer to these foundational architectural resources:
- Domain-Driven Design: Tackling Complexity in the Heart of Software — Eric Evans (Addison-Wesley)
- Martin Fowler: Domain-Driven Design Resource Guide
- Oracle Java 25 Language and Virtual Machine Specification
- Alistair Cockburn: Hexagonal Architecture (Ports and Adapters)
- ArchUnit Official User Guide and Architecture Assertions
- Spring Boot 4 Official Documentation
