Introduction: The “Where Does This Go?” Problem
You study Domain-Driven Design (DDD). You read books by Eric Evans and Vaughn Vernon. You understand terms like Entities, Value Objects, and Aggregate Roots. Then you open your Java IDE and create a new Spring Boot project. Suddenly, you freeze. Where does this class go?
Every software engineer faces this exact obstacle when moving from traditional layered architectures to DDD:
- Does the Spring Data repository interface belong in the domain package or the persistence package?
- Should an entity class have JPA annotations like
@Tableand@Column, or should it remain pure Java? - Where should transaction boundaries live: on the REST controller, the application service, or the aggregate root?
- Can a presentation controller return a domain aggregate directly to an HTTP client?
Putting classes into the wrong packages destroys the benefits of DDD. It causes cyclic dependencies. It leaks database details into business rules. It makes unit testing slow and difficult because tests require database connections and Spring context initialization.
In this guide, you will master the 4 DDD architectural layers in Spring Boot 4 and Java 25. You will see how the Dependency Inversion Principle (DIP) keeps your core business logic completely independent of databases, web frameworks, and messaging brokers. You will inspect a production-grade directory layout, follow an end-to-end HTTP request through each layer, and learn how to enforce architectural boundaries automatically using ArchUnit in your continuous integration pipeline.

The 4 DDD Layers Overview
Domain-Driven Design organizes complex software systems into four distinct horizontal layers. Each layer has a single, well-defined operational responsibility. The foundational rule of this architecture is strict: dependencies always point inward. The innermost layer never knows anything about the outer layers.
Let us review the operational responsibilities of each layer from the inside out:
| Layer Name | Position | Core Responsibility | Allowed Dependencies | Framework Coupling |
|---|---|---|---|---|
| Domain | Innermost Core | Encapsulates pure business rules, domain invariants, aggregate lifecycles, and domain events. | None (Zero external dependencies). | 100% Framework-Free (Pure Java standard library only). |
| Application | Middle Layer | Orchestrates business use cases, manages transaction boundaries, and coordinates domain objects. | Domain only. | Spring @Service and @Transactional. |
| Infrastructure | Outer Adapter | Provides technical implementations for persistence, messaging queues, external APIs, and file systems. | Domain (to implement port interfaces) and Application. | Spring Data JPA, Hibernate 7, Kafka, Redis, HTTP clients. |
| Presentation | Outer Adapter | Receives inbound HTTP/gRPC requests, validates client payloads, and serializes responses. | Application and Domain (Value Objects / IDs only). | Spring Web MVC, Jackson JSON, Jakarta Validation. |
By keeping the Domain layer at the center, changes to external technical concerns never force changes to your business logic. For example, if you replace PostgreSQL with MongoDB, or if you migrate from Spring MVC REST endpoints to GraphQL or gRPC, you only rewrite classes in the outer Infrastructure and Presentation layers. Your domain logic remains completely untouched.
Layer 1: The Domain Layer (The Heart)
The Domain layer is the heart and soul of your software system. It represents the problem space your company solves. It contains all business entities, calculation algorithms, and business validation rules. In our companion project, the domain models an e-commerce product catalog with inventory controls.
Domain Entities and Aggregates
A domain entity is an object defined by its persistent identity rather than its attributes. An Aggregate Root is the main entity that controls access to a group of related entities and value objects. The aggregate root ensures that all business invariants remain valid at all times.
Notice that our domain aggregate contains no JPA annotations, no getters/setters for everything, and no Spring framework imports:
package com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.domain;
// Pure Java Domain Aggregate Root — No Spring or JPA annotations
public class Product {
private final ProductId id;
private ProductName name;
private Money price;
private int stockQuantity;
public Product(ProductId id, ProductName name, Money price, int stockQuantity) {
if (stockQuantity < 0) {
throw new IllegalArgumentException("Initial stock quantity cannot be negative");
}
this.id = id;
this.name = name;
this.price = price;
this.stockQuantity = stockQuantity;
}
// Business method enforcing critical domain invariants
public void reduceStock(int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("Stock reduction quantity must be positive");
}
if (quantity > this.stockQuantity) {
throw new InsufficientStockException(this.id, quantity, this.stockQuantity);
}
this.stockQuantity -= quantity;
}
public boolean isInStock() {
return this.stockQuantity > 0;
}
public ProductId getId() { return id; }
public ProductName getName() { return name; }
public Money getPrice() { return price; }
public int getStockQuantity() { return stockQuantity; }
}
Notice the method reduceStock(int quantity). In traditional anemic CRUD code, an application service would fetch an entity, read the quantity, write an if check, call setStockQuantity(...), and call save(...). That approach scatters business logic across multiple services. In DDD, the aggregate root protects its own state. Nobody can reduce stock without going through the business validation method.
Value Objects and Immutability
A Value Object is an immutable conceptual element defined solely by its attributes. Value objects have no unique identity. If two value objects have identical values, they are equal. In modern Java 25, records provide the ideal syntax for value objects:
package com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.domain;
import java.math.BigDecimal;
import java.util.Objects;
import java.util.UUID;
// Value Object representing strongly-typed monetary amounts
public record Money(BigDecimal amount, String currency) {
public Money {
Objects.requireNonNull(amount, "Amount cannot be null");
Objects.requireNonNull(currency, "Currency cannot be null");
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Monetary amount cannot be negative");
}
}
}
// Value Object representing strongly-typed product identities
public record ProductId(UUID value) {
public ProductId {
Objects.requireNonNull(value, "ProductId cannot be null");
}
public static ProductId generate() {
return new ProductId(UUID.randomUUID());
}
}
// Value Object validating product naming constraints
public record ProductName(String value) {
public ProductName {
if (value == null || value.isBlank() || value.length() > 200) {
throw new IllegalArgumentException("Product name must be between 1 and 200 characters");
}
}
}
Domain Repository Interfaces (Ports)
The Domain layer specifies what storage capabilities it needs to load and save aggregate roots. However, it does not specify how storage happens. The domain declares a pure Java interface. In Hexagonal terms, this interface is an outbound port:
package com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.domain;
import java.util.List;
// Repository Port Interface — Declared in Domain, implemented in Infrastructure
public interface ProductRepository {
Product findById(ProductId id);
void save(Product product);
List<Product> findByPriceBelow(Money maxPrice);
}
Domain Services and Domain Events
When a business operation involves multiple aggregates or does not naturally belong to a single entity, you create a Domain Service. Domain services contain pure business logic and never perform database I/O directly.
package com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.domain;
import java.time.Instant;
// Domain Event notifying other contexts of a significant business occurrence
public record ProductCreatedEvent(ProductId productId, ProductName name, Instant occurredAt) {}
// Domain Service executing multi-aggregate business validations
public class InventoryService {
public boolean canFulfillOrder(Product product, int requestedQuantity) {
return product.isInStock() && product.getStockQuantity() >= requestedQuantity;
}
}
Layer 2: The Application Layer (The Orchestrator)
The Application layer sits immediately outside the Domain layer. Its primary role is use case orchestration. Think of an application service as a movie director. The director does not act in the scene; instead, the director tells the actors (the domain objects) where to stand, when to speak, and how to interact.
Application Services and Transaction Boundaries
An application service coordinates the steps required to fulfill a user command:
- Open an atomic database transaction using
@Transactional. - Translate input Command DTOs into domain value objects.
- Load the required aggregate roots from the
ProductRepository. - Invoke business methods directly on the aggregate root (e.g.,
product.reduceStock(quantity)). - Persist modified aggregate state back to the repository.
- Dispatch domain events or return execution identifiers.
package com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.application;
import com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.domain.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class ProductApplicationService {
private final ProductRepository productRepository;
public ProductApplicationService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
// Use Case 1: Create and publish a new catalog product
public ProductId createProduct(CreateProductCommand command) {
var productId = ProductId.generate();
var name = new ProductName(command.name());
var price = new Money(command.price(), command.currency());
var product = new Product(productId, name, price, command.initialStock());
productRepository.save(product);
return productId;
}
// Use Case 2: Reduce available inventory upon checkout
public void reduceStock(ProductId productId, int quantity) {
Product product = productRepository.findById(productId);
// All invariant checks and state changes happen inside the domain entity
product.reduceStock(quantity);
productRepository.save(product);
}
}
Notice what is missing from ProductApplicationService: there are no if statements checking whether stock is negative or whether the price is valid. If you place business logic inside your application services, your domain models become anemic data containers. Keep your application services thin!
Command and Query DTOs
Application services communicate with the outside world through Command and Query Data Transfer Objects (DTOs). These records represent intention and protect the domain from direct exposure to HTTP or web request structures:
package com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.application;
import java.math.BigDecimal;
// Input Command DTO decoupled from web protocol parameters
public record CreateProductCommand(
String name,
BigDecimal price,
String currency,
int initialStock
) {}
Layer 3: The Infrastructure Layer (The Adapters)
The Infrastructure layer contains all technical implementations and external integrations. It acts as an adapter that bridges your clean domain model to physical hardware, databases, and third-party networks. This includes database drivers, ORM frameworks, Kafka message producers, Redis caches, and mail clients.
Database Persistence: JPA and Spring Data
In traditional Spring Boot applications, developers put @Entity, @Table, and @Id directly on their domain classes. In strict DDD, this is an anti-pattern. JPA requires a default no-argument constructor, mutable getters and setters, and specific relational table mappings. This pollutes your business model.
Instead, we define a dedicated ProductJpaEntity inside the Infrastructure layer. The JpaProductRepository implements the ProductRepository domain interface and handles the translation between JPA entities and pure domain aggregates:
package com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.infrastructure;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.util.UUID;
// JPA Database Entity — Exists only in the Infrastructure layer
@Entity
@Table(name = "products")
public class ProductJpaEntity {
@Id
private UUID id;
private String name;
private BigDecimal price;
private String currency;
private int stockQuantity;
protected ProductJpaEntity() {}
public ProductJpaEntity(UUID id, String name, BigDecimal price, String currency, int stockQuantity) {
this.id = id;
this.name = name;
this.price = price;
this.currency = currency;
this.stockQuantity = stockQuantity;
}
public UUID getId() { return id; }
public String getName() { return name; }
public BigDecimal getPrice() { return price; }
public String getCurrency() { return currency; }
public int getStockQuantity() { return stockQuantity; }
}
Now, let us inspect the repository adapter. It injects the Spring Data JPA interface and implements the domain port:
package com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.infrastructure;
import com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.domain.*;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
// Internal Spring Data interface hidden within Infrastructure
public interface SpringDataProductRepository extends JpaRepository<ProductJpaEntity, UUID> {}
// Infrastructure Adapter implementing Domain Port Interface
@Repository
public class JpaProductRepository implements ProductRepository {
private final SpringDataProductRepository springRepo;
public JpaProductRepository(SpringDataProductRepository springRepo) {
this.springRepo = springRepo;
}
@Override
public Product findById(ProductId id) {
var entity = springRepo.findById(id.value())
.orElseThrow(() -> new ProductNotFoundException(id));
return toDomain(entity);
}
@Override
public void save(Product product) {
springRepo.save(toJpaEntity(product));
}
@Override
public List<Product> findByPriceBelow(Money maxPrice) {
// Translated to database-specific HQL or Criteria query
return List.of();
}
// Bidirectional Mapping: Infrastructure Entity <-> Domain Aggregate
private Product toDomain(ProductJpaEntity entity) {
return new Product(
new ProductId(entity.getId()),
new ProductName(entity.getName()),
new Money(entity.getPrice(), entity.getCurrency()),
entity.getStockQuantity()
);
}
private ProductJpaEntity toJpaEntity(Product product) {
return new ProductJpaEntity(
product.getId().value(),
product.getName().value(),
product.getPrice().amount(),
product.getPrice().currency(),
product.getStockQuantity()
);
}
}
If you migrate your database from relational PostgreSQL to document-oriented MongoDB or AWS DynamoDB, your domain code remains 100% identical. You only create a new repository implementation in the infrastructure package. Learn more about clean decoupling in our Hexagonal Architecture Spring Boot 4 guide.
Layer 4: The Presentation Layer (The Front Door)
The Presentation layer is the front door of your service. It manages interactions with external consumers, including web browsers, mobile applications, and partner microservices. It parses incoming JSON payloads, validates HTTP parameters, translates web exceptions into standard HTTP error responses, and delegates commands to the application layer.
REST Controllers and Request Validation
The controller never accesses the database repository directly. It receives an HTTP request, transforms the request payload into an application command, calls the ProductApplicationService, and serializes the result into a presentation response DTO:
package com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.presentation;
import com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.application.CreateProductCommand;
import com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.application.ProductApplicationService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductApplicationService productService;
public ProductController(ProductApplicationService productService) {
this.productService = productService;
}
@PostMapping
public ResponseEntity<ProductResponse> createProduct(@RequestBody CreateProductRequest request) {
// 1. Transform web request DTO into application command
var command = new CreateProductCommand(
request.name(),
request.price(),
request.currency(),
request.initialStock()
);
// 2. Delegate use case execution to Application Layer
var productId = productService.createProduct(command);
// 3. Return sanitized response DTO
return ResponseEntity.status(HttpStatus.CREATED)
.body(new ProductResponse(productId.value().toString(), "Product created successfully"));
}
}
Presentation DTOs and Response Mapping
Never return domain aggregates or JPA entities directly from your REST controllers. If you serialize a domain aggregate directly to JSON, you couple external clients to internal domain structures. Any internal refactoring breaks client APIs:
package com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.presentation;
import java.math.BigDecimal;
// Inbound JSON HTTP Request Body
public record CreateProductRequest(
String name,
BigDecimal price,
String currency,
int initialStock
) {}
// Outbound JSON HTTP Response Body
public record ProductResponse(
String id,
String message
) {}
The Golden Rule: Dependencies Point Inward
The single most important architectural rule in Domain-Driven Design is the Inward Dependency Rule. Compile-time source code dependencies must only point inward toward higher-level policies. The pure Domain layer sits at the center and depends on nothing.

Let us formalize the four dependency rules for software engineers:
- The Domain Layer has zero outward dependencies. It imports only classes from the JDK standard library (e.g.,
java.util,java.math,java.time). It never imports Spring, Hibernate, Jackson, or Jakarta EE. - The Application Layer depends on the Domain Layer only. It orchestrates domain entities and repository interfaces. It never references JPA entities, database connections, or HTTP servlets.
- The Infrastructure Layer depends inward on the Domain and Application Layers. It implements repository ports defined in the domain and satisfies technical contracts.
- The Presentation Layer depends on the Application Layer. It translates external network protocols into application commands.
End-to-End Request & Data Flow Lifecycle
To truly understand how DDD layers operate in production, you must distinguish between Runtime Control Flow (which moves from the outside in, and then back out) and Compile-Time Dependency Direction (which always points inward toward the domain).

Let us trace the complete lifecycle of a POST /api/products request through all four layers:
- HTTP Ingress: An HTTP client sends a JSON payload to
POST /api/products. Spring MVC’sDispatcherServletroutes the request toProductControllerin the Presentation layer. - Request Validation & Mapping: The
ProductControllervalidates the HTTP format and maps theCreateProductRequestDTO into a type-safeCreateProductCommand. - Use Case Invocation: The controller invokes
productService.createProduct(command)in the Application layer. Spring opens a database transaction via@Transactional. - Domain Instantiation & Invariants: The application service validates and instantiates domain value objects (
ProductId,ProductName,Money) and constructs theProductaggregate root. If any validation invariant fails, a domain exception is thrown immediately. - Persistence Delegation: The application service calls
productRepository.save(product). Notice thatProductApplicationServiceonly sees the domain port interface. - Infrastructure Adapter Execution: At runtime, Spring’s dependency injection container invokes
JpaProductRepositoryin the Infrastructure layer. The adapter maps the domain aggregate into aProductJpaEntityand executes an SQLINSERT INTO productsquery through Spring Data JPA and Hibernate 7. - Transaction Commit & Response: Spring commits the database transaction. The generated
ProductIdis returned to the controller, which wraps it in aProductResponseDTO and sends an HTTP201 Createdstatus back to the client.
Complete Project Structure
Here is the exact file and package layout from our companion workshop repository. Notice the clean separation of concerns across each package:
com.webcodein.workshop.dddlayersexplainedcodestructurespringboot/
├── Application.java # Spring Boot 4 bootstrap entry point
│
├── domain/ # LAYER 1: PURE DOMAIN (Zero Frameworks)
│ ├── Product.java # Aggregate Root enforcing invariants
│ ├── ProductId.java # Strongly-typed Value Object (UUID record)
│ ├── ProductName.java # Validated Value Object (1-200 chars)
│ ├── Money.java # Immutable Monetary Value Object
│ ├── ProductRepository.java # Outbound Repository Port Interface
│ ├── InventoryService.java # Multi-Aggregate Domain Service
│ ├── ProductCreatedEvent.java # Domain Business Event
│ └── InsufficientStockException.java # Domain Business Rule Violation Exception
│
├── application/ # LAYER 2: APPLICATION ORCHESTRATION
│ ├── ProductApplicationService.java # Use case orchestrator (@Service, @Transactional)
│ └── CreateProductCommand.java # Inbound Use Case Command Record
│
├── infrastructure/ # LAYER 3: INFRASTRUCTURE ADAPTERS
│ ├── ProductJpaEntity.java # Relational database entity (@Entity, @Table)
│ ├── SpringDataProductRepository.java # Spring Data JPA interface (JpaRepository)
│ ├── JpaProductRepository.java # Adapter implementing ProductRepository port
│ └── ProductNotFoundException.java # Infrastructure Data Retrieval Exception
│
└── presentation/ # LAYER 4: INBOUND PRESENTATION ADAPTER
├── ProductController.java # Spring MVC REST Controller (@RestController)
├── CreateProductRequest.java # HTTP Request JSON DTO record
└── ProductResponse.java # HTTP Response JSON DTO record
Every class has one clear home. A new developer joining your engineering team never needs to guess where a new class belongs.
15 Classes: Which Layer?
Test your understanding! Examine these 15 common enterprise classes and review which layer they belong to and why:
| # | Class Name & Description | Correct Layer | Architectural Justification |
|---|---|---|---|
| 1 | Order (Aggregate Root) |
Domain | Contains pure business rules, order status transitions, and item calculation logic. |
| 2 | OrderController (REST Endpoint) |
Presentation | Handles incoming HTTP POST/GET requests, headers, and web status codes. |
| 3 | OrderRepository (Java Interface) |
Domain | Defines required persistence capabilities without coupling to storage engines. |
| 4 | JpaOrderRepository (Adapter Class) |
Infrastructure | Implements the domain repository interface using Hibernate and JPA. |
| 5 | Money (Value Object Record) |
Domain | Immutable business concept representing monetary quantities and currency ISO codes. |
| 6 | OrderApplicationService |
Application | Orchestrates order placement use case and manages database transaction boundaries. |
| 7 | OrderJpaEntity (Database Entity) |
Infrastructure | Relational database table mapping with foreign keys, indexes, and JPA annotations. |
| 8 | CreateOrderRequest (Web Record) |
Presentation | Incoming JSON payload from web browsers or mobile application clients. |
| 9 | OrderPlacedEvent (Event Record) |
Domain | Represents a significant business milestone that has already occurred in the domain. |
| 10 | KafkaOrderEventPublisher |
Infrastructure | Serializes domain events to Avro/JSON and produces messages to Apache Kafka topics. |
| 11 | PricingDiscountService |
Domain | Domain service calculating complex tiered discounts across multiple line items. |
| 12 | CreateOrderCommand (Input DTO) |
Application | Protocol-agnostic input parameters supplied to the application service use case. |
| 13 | OrderSummaryResponse (Web DTO) |
Presentation | Outbound JSON data contract serialized to frontend client applications. |
| 14 | SendGridEmailNotificationService |
Infrastructure | Integrates with third-party SendGrid REST APIs to send transactional customer emails. |
| 15 | OrderStatus (Enum) |
Domain | Core business lifecycle states (e.g., PENDING, CONFIRMED, CANCELLED). |
Comparative Architecture & Layer Boundary Matrix
To help you establish clear architectural governance guidelines across your engineering organization, review this comprehensive 6-dimension layer comparison matrix:
| Dimension | Domain Layer | Application Layer | Infrastructure Layer | Presentation Layer |
|---|---|---|---|---|
| Primary Responsibility | Business logic, business rules, calculations, and domain invariants. | Use case orchestration, transaction demarcation, and workflow coordination. | Technical implementations: databases, message queues, external web APIs. | External protocol handling, HTTP serialization, user authentication tokens. |
| Allowed Outward Imports | None (Pure Java JDK standard libraries only). | Domain Layer only. | Domain Layer (ports) and Application Layer. | Application Layer (services/commands) and Domain (IDs/Value Objects). |
| Forbidden Imports | Spring, JPA, Hibernate, Jackson, HTTP, SQL, AWS SDKs. | Spring MVC, JPA Entities, Database Drivers, HTTP Servlets. | Presentation Layer classes (controllers, web DTOs). | JPA Entities, SQL queries, direct Database Repositories. |
| Transaction Boundary | None (Aggregates are unaware of transactions). | Yes (Annotated with Spring @Transactional). |
Participates in active transaction context. | None (Controllers must never be @Transactional). |
| Testing Strategy | Fast JUnit 5 unit tests (Sub-millisecond execution, zero mocks). | Unit tests with Mockito mocks for repository ports. | Integration tests with Testcontainers and real PostgreSQL/Kafka. | Spring @WebMvcTest slice tests with MockMvc. |
| Refactoring Impact | Changes only when core business rules and policies evolve. | Changes when business use cases or transaction workflows change. | Changes when database schemas, cloud providers, or libraries upgrade. | Changes when frontend API contracts or client protocols evolve. |
Real-World Enterprise Use Cases
Let us explore three real-world enterprise architectures that demonstrate how the 4-layer DDD pattern solves critical production challenges:
1. High-Throughput E-Commerce Flash Sale Service
In high-concurrency retail environments, hundreds of customers attempt to purchase limited inventory simultaneously. By encapsulating inventory deduction inside the Product.reduceStock() aggregate method, the domain guarantees that inventory can never drop below zero. The application layer coordinates optimistic locking with database retries, while the presentation layer throttles incoming requests. For more details on high-load concurrency, explore our guide on Java 25 Virtual Threads in Spring Boot.
2. Fintech Multi-Currency Double-Entry Ledger
Financial ledgers require strict mathematical invariants: debits and credits must balance to zero, and monetary amounts must never suffer from floating-point rounding errors. By modeling Money and Currency as immutable value objects in the Domain layer, the domain prevents invalid states at compile time. The Infrastructure layer maps transactions to immutable append-only PostgreSQL tables protected by Row-Level Security. Learn how to secure database rows in our tutorial on Multi-Tenant SaaS with Hibernate 7.
3. Event-Driven Microservices Architecture
When an order is placed, downstream services (billing, shipping, email notifications) must be notified asynchronously. The aggregate root registers a OrderPlacedEvent. The application service persists the aggregate and publishes the event to an outbox table. An infrastructure adapter reads the outbox table and streams the event to Apache Kafka. Read our deep dive on Event-Driven Architecture with Kafka and Spring Boot 4.
Common Anti-Patterns: Wrong Layer Placement
Even experienced engineering teams occasionally violate DDD layering principles. Let us examine the three most common architectural anti-patterns and their corrections:

Anti-Pattern 1: The Controller Bypasses the Application Service
In rapid prototyping, developers often inject the database repository directly into the REST controller. This bypasses transaction boundaries and scatters business logic across controllers:
// WRONG: Controller directly modifies repository, skipping business invariants
@RestController
@RequestMapping("/api/products")
public class BadProductController {
@Autowired private ProductRepository repo;
@PostMapping("/{id}/reduce")
public void reduceStock(@PathVariable UUID id, @RequestParam int qty) {
Product p = repo.findById(new ProductId(id));
p.reduceStock(qty); // No transaction management!
repo.save(p);
}
}
// CORRECT: Controller delegates use case execution to Application Service
@RestController
@RequestMapping("/api/products")
public class GoodProductController {
private final ProductApplicationService productService;
public GoodProductController(ProductApplicationService productService) {
this.productService = productService;
}
@PostMapping("/{id}/reduce")
public ResponseEntity<Void> reduceStock(@PathVariable UUID id, @RequestParam int qty) {
productService.reduceStock(new ProductId(id), qty);
return ResponseEntity.noContent().build();
}
}
Anti-Pattern 2: Domain Entities Covered in JPA Annotations
Placing JPA annotations directly on domain models couples your core business logic to relational database schemas, dirty checking mechanisms, and proxy generation bytecode. When business rules require immutability, JPA often forces mutable setters and zero-argument constructors. Always maintain dedicated *JpaEntity classes in the Infrastructure layer and map them to pure domain objects.
Anti-Pattern 3: Anemic Domain Model with “Manager” Services
If your domain entities contain only private fields, public getters, and public setters, you have an Anemic Domain Model. All business validations end up inside procedural “Manager” services. To fix this, remove setters, introduce rich business methods (such as reduceStock() or applyDiscount()), and enforce invariants inside value object constructors.
Automated Architectural Governance with ArchUnit
Architecture diagrams and documentation guidelines easily degrade over time if they are not enforced programmatically. Developers working under tight deadlines might accidentally import a Spring annotation into a domain class or inject a repository into a controller.
With ArchUnit, you write unit tests that inspect compiled Java bytecode. If anyone breaks a layer boundary rule, the Maven build fails immediately in GitHub Actions!

Here is the complete DddArchitectureTest from our workshop companion repository:
package com.webcodein.workshop.dddlayersexplainedcodestructurespringboot;
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.noClasses;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
@AnalyzeClasses(
packages = "com.webcodein.workshop.dddlayersexplainedcodestructurespringboot",
importOptions = ImportOption.DoNotIncludeTests.class
)
class DddArchitectureTest {
// Rule 1: Strict Inbound Layered Architecture Verification
@ArchTest
static final ArchRule ddd_layers_must_respect_dependency_inversion =
layeredArchitecture()
.consideringAllDependencies()
.layer("Presentation").definedBy("..presentation..")
.layer("Application").definedBy("..application..")
.layer("Domain").definedBy("..domain..")
.layer("Infrastructure").definedBy("..infrastructure..")
.whereLayer("Presentation").mayNotBeAccessedByAnyLayer()
.whereLayer("Application").mayOnlyBeAccessedByLayers("Presentation")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure", "Presentation")
.whereLayer("Infrastructure").mayNotBeAccessedByAnyLayer();
// Rule 2: Pure Domain — Zero Spring Framework Dependencies
@ArchTest
static final ArchRule domain_must_be_free_of_spring_framework =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("org.springframework..");
// Rule 3: Pure Domain — Zero Jakarta Persistence Annotations
@ArchTest
static final ArchRule domain_must_be_free_of_persistence_annotations =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("jakarta.persistence..");
}
When you run ./mvnw test, ArchUnit analyzes all compiled classes in under 3 seconds. If a developer accidentally adds import org.springframework.stereotype.Component; to a domain service, the test fails with an explicit error message pointing to the exact offending line number.
Production Best Practices & Implementation Rules
Follow these five golden implementation rules when designing DDD applications in Spring Boot 4:
- Design the Domain Model First: Before you write SQL DDL scripts or design REST endpoints, author your Domain Entities and Value Objects. Write unit tests to prove your business rules work independently of any database or web server.
- Keep Aggregates Small: An aggregate boundary should only encompass entities that must remain strictly consistent within a single database transaction. Do not lump your entire domain into one giant aggregate.
- Use Value Objects for All Identifiers: Never pass raw
LongorUUIDvalues around your application. AProductIdprevents accidental assignment of anOrderIdto a product parameter. - Enforce Invariants in Constructors: Throw exceptions immediately when an object is created with invalid arguments. An object should never exist in memory in an invalid state.
- Verify Architectural Rules in CI/CD: Include ArchUnit tests in your GitHub Actions workflow so architectural drift is prevented before pull requests can be merged.
Frequently Asked Questions
Can I use JPA annotations directly on my domain objects in pragmatic projects?
For small, CRUD-oriented services with minimal business logic, annotating domain models with JPA can save initial mapping code. However, as business rules grow complex, JPA annotations force compromises: mutable setters, no-arg constructors, and lazy-loading runtime errors. For long-lived enterprise applications, maintaining separate JPA entities in the Infrastructure layer guarantees a pure, resilient domain.
What is the difference between a Domain Service and an Application Service?
A Domain Service contains core business calculation logic that naturally spans multiple aggregates (e.g., currency exchange conversion or cross-product discount matrix calculations). It has no database dependencies. An Application Service orchestrates an entire user workflow: it manages transactions (@Transactional), loads aggregates via repository ports, calls domain methods, and saves state.
Where should input validation occur?
Validation occurs at two distinct stages: (1) Format Validation happens in the Presentation layer (e.g., ensuring a string is valid JSON or not blank using Jakarta Bean Validation). (2) Business Invariant Validation happens in the Domain layer (e.g., checking that a product’s price cannot be negative or stock cannot drop below zero).
How does DDD 4-Layer Architecture compare to Hexagonal Architecture?
They represent the same architectural philosophy using different metaphors. In Hexagonal Architecture (Ports and Adapters), the Domain and Application layers form the “Inside of the Hexagon,” while the Infrastructure and Presentation layers represent the “Outside Adapters.” Outbound ports correspond to Domain Repository interfaces, and inbound adapters correspond to Presentation REST controllers.
Does mapping between JPA Entities and Domain Aggregates hurt performance?
In 99% of business enterprise workloads, the CPU time required to instantiate a few Java records is measured in microseconds, whereas network database I/O takes milliseconds. The maintainability, testability, and decoupling gained from layer separation far outweigh the negligible mapping overhead. For high-volume read queries, consider implementing CQRS projections directly from the database.
Companion Code & Verification
The companion code for this guide is fully tested with **Java 25** and **Spring Boot 4.0.0** in our open-source workshop repository:
Explore the DDD Layers Companion Repository on GitHub
To run the complete test suite including ArchUnit architectural validation and Spring Boot JPA integration tests locally, execute:
# Clone and test the workshop project
git clone https://github.com/salahtobok/learn.webcodein.workshop.git
cd learn.webcodein.workshop/architecture/ddd-layers-explained-code-structure-spring-boot
./mvnw clean test
All 14 unit, slice, integration, and architecture tests pass 100% on JDK 25:
[INFO] Running com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.application.ProductApplicationServiceTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.DddApplicationIntegrationTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.domain.DomainValueObjectsTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.domain.ProductTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.presentation.ProductControllerTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running com.webcodein.workshop.dddlayersexplainedcodestructurespringboot.DddArchitectureTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] Results:
[INFO] Tests run: 14, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
References & Further Reading
- Martin Fowler — Domain-Driven Design Reference & Core Patterns
- ArchUnit Official Documentation — Unit Testing Java Architecture
- Spring Boot 4 Reference Documentation
- Robert C. Martin — The Clean Architecture & Dependency Inversion Principle
- Hexagonal vs Clean vs Onion Architecture: Deep Architectural Comparison
- Clean Architecture vs Hexagonal Architecture in Enterprise Java
- Event-Driven Architecture with Spring Boot 4 and Apache Kafka
- Multi-Tenant SaaS with Hibernate 7 and Row-Level Security
- Modern Local Development with Testcontainers and Docker Compose in Spring Boot 4
