Skip to content
ArticlesBack EndJava

Build Your First DDD Application with Spring Boot 4: Step-by-Step Tutorial

27 min readsalahtobok
Hero image for DDD Spring Boot 4 Tutorial

1. Introduction: Why Build a Real DDD Application?

Most developers learn Domain-Driven Design (DDD) through abstract books. They read about Bounded Contexts, Ubiquitous Language, and Aggregates. Yet, when they open their IDE, they struggle to write code. They ask simple questions. Where does business logic live? How do Aggregates work with Spring Data JPA? How do we test domain rules without loading an entire database?

This hands-on tutorial solves that problem. You will build a complete Task Management application from scratch. We use modern Java 25 and Spring Boot 4. Every single step includes production-ready code. You will see how clean domain objects protect their own invariants. You will learn how the Application layer coordinates use cases. Finally, you will enforce architecture rules automatically using ArchUnit.

Key Principle: In Domain-Driven Design, the domain model is the center of the system. Frameworks like Spring and database drivers are external tools. They adapt to the domain, never the other way around.

2. Domain Overview: The Task Management System

To master tactical DDD patterns, we need a domain that is simple to understand but rich in business rules. We are building a team Task Management system. In this domain, work is organized into Projects. Each project contains multiple Tasks.

Our domain experts give us clear business rules:

  • Project Boundary: A Project cannot exist without a non-blank name. A Project has a status: ACTIVE, COMPLETED, or ARCHIVED.
  • Capacity Limits: A single Project can hold a maximum of 50 tasks. This boundary prevents unbounded aggregate growth.
  • No Duplicate Tasks: Task titles within the same project must be unique (case-insensitive). You cannot add two tasks named “Design Homepage” to the same project.
  • Task Lifecycle: A task starts in the TODO state. It moves to IN_PROGRESS, and finally to DONE. A task can also be CANCELLED. Completed tasks cannot be cancelled or restarted.
  • Project Completion Invariant: A Project cannot be marked COMPLETED if any tasks remain incomplete (either in TODO or IN_PROGRESS state).
  • Closed Project Guard: You cannot add new tasks to a completed or archived project.
Task Management Domain Model and Class Relationships
Figure 1: Task Management Domain Model showing relationships between Project, Task, Priority, and Status.

3. Step 1: Project Setup with Spring Boot 4 and Java 25

Let us set up a clean Maven project using Spring Boot 4.0.0 and Java 25. Our build configuration includes Spring Web, Spring Data JPA, PostgreSQL for runtime, H2 for lightning-fast test execution, and ArchUnit 1.4.1 for architecture testing.

Create your pom.xml file in the project root directory:

<!-- pom.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.0.0</version>
        <relativePath/>
    </parent>
    <groupId>com.webcodein.taskmanager</groupId>
    <artifactId>first-ddd-application</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <name>First DDD Application</name>
    <description>First DDD Application with Spring Boot 4 and Java 25</description>

    <properties>
        <java.version>25</java.version>
    </properties>

    <dependencies>
        <!-- Web Layer -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Persistence Layer -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <!-- Database Drivers -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- Testing Frameworks -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.tngtech.archunit</groupId>
            <artifactId>archunit-junit5</artifactId>
            <version>1.4.1</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Next, structure your package directories to reflect DDD layers. Instead of traditional folders like controllers and services, organize the code into four distinct layers:

Spring Boot 4 DDD Package and Project Structure Overview
Figure 2: Package structure organizing code into domain, application, infrastructure, and presentation layers.

Here is what each layer contains:

  • domain: Contains business entities, value objects, domain events, domain services, and repository interfaces. It has zero dependencies on web or framework logic.
  • application: Orchestrates business use cases, manages database transactions, and converts domain aggregates into data transfer objects (DTOs).
  • infrastructure: Implements persistence ports, database adapters, Spring Data repositories, and third-party integrations.
  • presentation: Contains REST controllers, HTTP request/response mappings, and exception handlers.

4. Step 2: Defining the Ubiquitous Language

Before writing domain code, developers and business domain experts must agree on a shared vocabulary. This is the Ubiquitous Language. Every class name, variable name, and method name in your code must match these agreed terms.

In our Task Management application, we establish four foundational terms:

  • Project: An Aggregate Root representing an organized body of work with strict boundaries.
  • Task: An internal Entity that belongs to a Project. It represents a single action item with a title, status, and priority.
  • Priority: An immutable Value Object defining the urgency level of a task (e.g., LOW, MEDIUM, HIGH, URGENT).
  • TaskStatus: The exact state of a task in its workflow (TODO, IN_PROGRESS, DONE, CANCELLED).

When you speak with product managers, you say: “A Project adds a Task with a Priority.” You never say: “The Project table inserts a foreign key into the Task table.” Speaking the ubiquitous language prevents design translation bugs.

5. Step 3: Modelling Value Objects and Status Enums

In Domain-Driven Design, Value Objects have no unique identity. Two Value Objects are equal if all their fields contain the same values. They are immutable and validate their own internal invariants upon instantiation.

In Java 25, records are the perfect vehicle for Value Objects. Starting with Hibernate 6.x and continuing in Spring Boot 4, records can be directly annotated with @Embeddable. Let us create Priority:

// src/main/java/com/webcodein/taskmanager/domain/model/Priority.java
package com.webcodein.taskmanager.domain.model;

import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;

@Embeddable
public record Priority(
        @Column(name = "priority_level") int level,
        @Column(name = "priority_name") String name
) {
    public static final Priority LOW = new Priority(1, "LOW");
    public static final Priority MEDIUM = new Priority(2, "MEDIUM");
    public static final Priority HIGH = new Priority(3, "HIGH");
    public static final Priority URGENT = new Priority(4, "URGENT");

    public Priority {
        if (level < 1 || level > 4) {
            throw new IllegalArgumentException("Priority level must be between 1 and 4, got: " + level);
        }
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("Priority name cannot be empty");
        }
    }

    public static Priority of(int level) {
        return switch (level) {
            case 1 -> LOW;
            case 2 -> MEDIUM;
            case 3 -> HIGH;
            case 4 -> URGENT;
            default -> throw new IllegalArgumentException("Invalid priority level: " + level);
        };
    }
}

Notice the compact constructor in the record. If any code attempts to create a Priority with level 0 or 5, the record immediately throws an IllegalArgumentException. An invalid Value Object can never exist in memory.

Now, let us create the lifecycle enums for TaskStatus and ProjectStatus:

// src/main/java/com/webcodein/taskmanager/domain/model/TaskStatus.java
package com.webcodein.taskmanager.domain.model;

public enum TaskStatus {
    TODO,
    IN_PROGRESS,
    DONE,
    CANCELLED
}
// src/main/java/com/webcodein/taskmanager/domain/model/ProjectStatus.java
package com.webcodein.taskmanager.domain.model;

public enum ProjectStatus {
    ACTIVE,
    COMPLETED,
    ARCHIVED
}

6. Step 4: Building the Task Entity with State Transitions

Unlike Value Objects, Entities possess a distinct identity (an ID) that remains constant over time, even as their attributes change. In tactical DDD, entities that live inside an aggregate should not be freely modified by outside code. They should expose rich business methods instead of public setters.

Let us create the Task entity. Notice its package-private constructor: external layers cannot instantiate a Task directly; only the Project Aggregate Root can create tasks.

// src/main/java/com/webcodein/taskmanager/domain/model/Task.java
package com.webcodein.taskmanager.domain.model;

import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import java.util.Objects;
import java.util.UUID;

@Entity
@Table(name = "tasks")
public class Task {

    @Id
    @Column(name = "id", nullable = false, updatable = false)
    private UUID id;

    @Column(name = "title", nullable = false)
    private String title;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false)
    private TaskStatus status;

    @Embedded
    private Priority priority;

    protected Task() {
        // Required for JPA reflection
    }

    Task(UUID id, String title, Priority priority) {
        if (id == null) {
            throw new IllegalArgumentException("Task id cannot be null");
        }
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("Task title cannot be empty");
        }
        this.id = id;
        this.title = title.trim();
        this.status = TaskStatus.TODO;
        this.priority = priority != null ? priority : Priority.MEDIUM;
    }

    public static Task reconstitute(UUID id, String title, TaskStatus status, Priority priority) {
        Task task = new Task();
        task.id = id;
        task.title = title;
        task.status = status;
        task.priority = priority;
        return task;
    }

    public void startProgress() {
        if (this.status == TaskStatus.DONE) {
            throw new IllegalStateException("Cannot start task that is already completed");
        }
        if (this.status == TaskStatus.CANCELLED) {
            throw new IllegalStateException("Cannot start cancelled task");
        }
        this.status = TaskStatus.IN_PROGRESS;
    }

    public void markAsDone() {
        if (this.status == TaskStatus.DONE) {
            throw new IllegalStateException("Task is already done");
        }
        if (this.status == TaskStatus.CANCELLED) {
            throw new IllegalStateException("Cannot complete cancelled task");
        }
        this.status = TaskStatus.DONE;
    }

    public void cancel() {
        if (this.status == TaskStatus.DONE) {
            throw new IllegalStateException("Cannot cancel completed task");
        }
        this.status = TaskStatus.CANCELLED;
    }

    public UUID getId() { return id; }
    public String getTitle() { return title; }
    public TaskStatus getStatus() { return status; }
    public Priority getPriority() { return priority; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Task task)) return false;
        return Objects.equals(id, task.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

Observe the reconstitute(...) factory method. When an existing task is loaded from the database by an infrastructure repository, it already has an established status. The reconstitute pattern restores entity state without triggering runtime creation invariants or validation rules.

Why is this separation essential? Consider what happens when your application loads a completed or archived task from the database. If you use standard business methods during database loading, your business guard clauses might reject valid historical records. Reconstitution cleanly separates runtime business mutation from persistence hydration.

7. Step 5: Crafting the Project Aggregate Root and Invariants

The Aggregate Root is the gatekeeper of consistency. All incoming operations from the outside world must pass through the Aggregate Root. External services never modify child tasks directly; they instruct the Aggregate Root to execute the action.

Aggregate Root Invariant Protection Pattern Example
Figure 3: Aggregate Root pattern encapsulating internal child entities and guarding domain invariants.

Let us create domain event records before writing the Aggregate Root. Domain Events capture significant occurrences that domain experts care about. They are immutable records that record what happened and when. When business mutations occur inside our aggregate, it records these events:

// src/main/java/com/webcodein/taskmanager/domain/event/TaskAddedEvent.java
package com.webcodein.taskmanager.domain.event;

import java.time.Instant;
import java.util.UUID;

public record TaskAddedEvent(
        UUID projectId,
        UUID taskId,
        String taskTitle,
        Instant occurredOn
) {
    public TaskAddedEvent(UUID projectId, UUID taskId, String taskTitle) {
        this(projectId, taskId, taskTitle, Instant.now());
    }
}
// src/main/java/com/webcodein/taskmanager/domain/event/ProjectCompletedEvent.java
package com.webcodein.taskmanager.domain.event;

import java.time.Instant;
import java.util.UUID;

public record ProjectCompletedEvent(
        UUID projectId,
        int totalTasks,
        Instant completedAt
) {
    public ProjectCompletedEvent(UUID projectId, int totalTasks) {
        this(projectId, totalTasks, Instant.now());
    }
}

Now, let us examine the complete Project Aggregate Root:

// src/main/java/com/webcodein/taskmanager/domain/model/Project.java
package com.webcodein.taskmanager.domain.model;

import com.webcodein.taskmanager.domain.event.ProjectCompletedEvent;
import com.webcodein.taskmanager.domain.event.TaskAddedEvent;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;

@Entity
@Table(name = "projects")
public class Project {

    public static final int MAX_TASKS_PER_PROJECT = 50;

    @Id
    @Column(name = "id", nullable = false, updatable = false)
    private UUID id;

    @Column(name = "name", nullable = false)
    private String name;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false)
    private ProjectStatus status;

    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
    @JoinColumn(name = "project_id")
    private List<Task> tasks = new ArrayList<>();

    @Transient
    private final List<Object> domainEvents = new ArrayList<>();

    protected Project() {
        // Required for JPA
    }

    public Project(String name) {
        this(UUID.randomUUID(), name);
    }

    public Project(UUID id, String name) {
        if (id == null) {
            throw new IllegalArgumentException("Project id cannot be null");
        }
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("Project name is required and cannot be blank");
        }
        this.id = id;
        this.name = name.trim();
        this.status = ProjectStatus.ACTIVE;
    }

    public static Project reconstitute(UUID id, String name, ProjectStatus status, List<Task> tasks) {
        Project project = new Project();
        project.id = id;
        project.name = name;
        project.status = status;
        if (tasks != null) {
            project.tasks = new ArrayList<>(tasks);
        }
        return project;
    }

    public Task addTask(String title) {
        return addTask(title, Priority.MEDIUM);
    }

    public Task addTask(String title, Priority priority) {
        if (this.status != ProjectStatus.ACTIVE) {
            throw new IllegalStateException("Cannot add tasks to non-active project (current status: " + this.status + ")");
        }
        if (this.tasks.size() >= MAX_TASKS_PER_PROJECT) {
            throw new IllegalStateException("Project reached maximum capacity of " + MAX_TASKS_PER_PROJECT + " tasks");
        }
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("Task title cannot be empty");
        }
        String normalizedTitle = title.trim();
        boolean duplicate = this.tasks.stream()
                .anyMatch(t -> t.getTitle().equalsIgnoreCase(normalizedTitle));
        if (duplicate) {
            throw new IllegalArgumentException("Task with title '" + normalizedTitle + "' already exists in project");
        }

        UUID taskId = UUID.randomUUID();
        Task newTask = new Task(taskId, normalizedTitle, priority != null ? priority : Priority.MEDIUM);
        this.tasks.add(newTask);
        this.domainEvents.add(new TaskAddedEvent(this.id, taskId, normalizedTitle));
        return newTask;
    }

    public void complete() {
        if (this.status == ProjectStatus.COMPLETED) {
            throw new IllegalStateException("Project is already completed");
        }
        boolean hasPending = this.tasks.stream()
                .anyMatch(t -> t.getStatus() != TaskStatus.DONE && t.getStatus() != TaskStatus.CANCELLED);
        if (hasPending) {
            throw new IllegalStateException("Cannot complete project with pending or in-progress tasks");
        }
        this.status = ProjectStatus.COMPLETED;
        this.domainEvents.add(new ProjectCompletedEvent(this.id, this.tasks.size()));
    }

    public void archive() {
        this.status = ProjectStatus.ARCHIVED;
    }

    public Optional<Task> findTask(UUID taskId) {
        return this.tasks.stream()
                .filter(t -> t.getId().equals(taskId))
                .findFirst();
    }

    public UUID getId() { return id; }
    public String getName() { return name; }
    public ProjectStatus getStatus() { return status; }
    public List<Task> getTasks() { return Collections.unmodifiableList(this.tasks); }
    public List<Object> getDomainEvents() { return Collections.unmodifiableList(this.domainEvents); }
    public void clearDomainEvents() { this.domainEvents.clear(); }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Project project)) return false;
        return Objects.equals(id, project.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}
DDD Tactical Patterns Project Aggregate Boundary and Task Lifecycle Diagram
Figure 4: Detailed Project Aggregate Boundary, internal Task lifecycle state machine, and Domain Events dispatch.

Take note of three critical encapsulation practices:

  1. Collections.unmodifiableList(...): The getter for tasks returns an unmodifiable view. Callers cannot perform project.getTasks().add(...). They must call project.addTask(...).
  2. Invariant Enforcement: The aggregate prevents adding more than 50 tasks and forbids duplicate titles.
  3. State Coherence: The complete() method scans all child tasks. If even one task is still TODO or IN_PROGRESS, the method aborts with an IllegalStateException.

8. Step 6: Defining the Domain Repository Interface

In Domain-Driven Design, the domain layer defines repository interfaces as Ports. The domain specifies what persistence capabilities it requires. It does not know whether records are stored in PostgreSQL, MongoDB, or an in-memory map.

Only Aggregate Roots have repositories. You never create a separate repository for child entities like Task. All persistence operations on tasks are performed through the ProjectRepository:

// src/main/java/com/webcodein/taskmanager/domain/repository/ProjectRepository.java
package com.webcodein.taskmanager.domain.repository;

import com.webcodein.taskmanager.domain.model.Project;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

public interface ProjectRepository {

    Project save(Project project);

    Optional<Project> findById(UUID id);

    List<Project> findAll();

    void deleteById(UUID id);

    boolean existsById(UUID id);
}
DDD Aggregate Boundary and Child Entity Encapsulation Diagram
Figure 5: Repository boundary: only Aggregate Roots are loaded and saved directly through repository ports.

9. Step 7: Implementing the Spring Data JPA Persistence Adapter

Now we move into the infrastructure layer. Here, we implement the domain port using Spring Data JPA. The infrastructure layer is free to use Spring annotations like @Repository and framework interfaces like JpaRepository.

First, define the internal Spring Data interface:

// src/main/java/com/webcodein/taskmanager/infrastructure/persistence/SpringDataProjectRepository.java
package com.webcodein.taskmanager.infrastructure.persistence;

import com.webcodein.taskmanager.domain.model.Project;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.UUID;

@Repository
public interface SpringDataProjectRepository extends JpaRepository<Project, UUID> {
}

Second, create the adapter class that implements our clean domain interface ProjectRepository:

// src/main/java/com/webcodein/taskmanager/infrastructure/persistence/JpaProjectRepository.java
package com.webcodein.taskmanager.infrastructure.persistence;

import com.webcodein.taskmanager.domain.model.Project;
import com.webcodein.taskmanager.domain.repository.ProjectRepository;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

@Repository
public class JpaProjectRepository implements ProjectRepository {

    private final SpringDataProjectRepository repository;

    public JpaProjectRepository(SpringDataProjectRepository repository) {
        this.repository = repository;
    }

    @Override
    public Project save(Project project) {
        return repository.save(project);
    }

    @Override
    public Optional<Project> findById(UUID id) {
        return repository.findById(id);
    }

    @Override
    public List<Project> findAll() {
        return repository.findAll();
    }

    @Override
    public void deleteById(UUID id) {
        repository.deleteById(id);
    }

    @Override
    public boolean existsById(UUID id) {
        return repository.existsById(id);
    }
}

Notice the power of Dependency Inversion. The application layer depends solely on the domain interface ProjectRepository. If you ever switch persistence backends or migrate to a document database, not a single line of domain or application code changes.

10. Step 8: Building the Task Assignment Domain Service

When business logic coordinates multiple entities or enforces domain policies involving repository checks, it belongs in a Domain Service. Domain Services live in the domain layer. To maintain domain purity, they must remain free of Spring framework annotations.

// src/main/java/com/webcodein/taskmanager/domain/service/TaskAssignmentService.java
package com.webcodein.taskmanager.domain.service;

import com.webcodein.taskmanager.domain.model.Priority;
import com.webcodein.taskmanager.domain.model.Project;
import com.webcodein.taskmanager.domain.model.Task;
import com.webcodein.taskmanager.domain.repository.ProjectRepository;

import java.util.NoSuchElementException;
import java.util.UUID;

public class TaskAssignmentService {

    private final ProjectRepository projectRepository;

    public TaskAssignmentService(ProjectRepository projectRepository) {
        this.projectRepository = projectRepository;
    }

    public Task assignTask(UUID projectId, String taskTitle) {
        return assignTask(projectId, taskTitle, Priority.MEDIUM);
    }

    public Task assignTask(UUID projectId, String taskTitle, Priority priority) {
        Project project = projectRepository.findById(projectId)
                .orElseThrow(() -> new NoSuchElementException("Project not found: " + projectId));

        Task task = project.addTask(taskTitle, priority);
        projectRepository.save(project);
        return task;
    }
}

Many developers find the boundary between Domain Services and Application Services confusing. Here is the golden rule to keep them straight:

  • Domain Service: Encapsulates pure business rules that do not fit inside a single entity. It operates exclusively using the ubiquitous language of your domain model. It never deals with web requests, JSON payloads, or security tokens.
  • Application Service: Coordinates external use cases. It manages transaction boundaries, verifies user authentication, coordinates domain services and repositories, and returns DTOs to the presentation layer.

How does Spring know how to inject this pure domain service into our application services? We wire it through an infrastructure configuration bean:

// src/main/java/com/webcodein/taskmanager/infrastructure/configuration/DomainConfiguration.java
package com.webcodein.taskmanager.infrastructure.configuration;

import com.webcodein.taskmanager.domain.repository.ProjectRepository;
import com.webcodein.taskmanager.domain.service.TaskAssignmentService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DomainConfiguration {

    @Bean
    public TaskAssignmentService taskAssignmentService(ProjectRepository projectRepository) {
        return new TaskAssignmentService(projectRepository);
    }
}

11. Step 9: Orchestrating Use Cases in the Application Layer

The Application Layer sits directly above the domain. Its responsibility is use case orchestration: opening database transactions, validating authorization, loading aggregates, invoking domain methods, and transforming entities into response DTOs.

Let us create the command records and response DTOs:

// src/main/java/com/webcodein/taskmanager/application/dto/CreateProjectCommand.java
package com.webcodein.taskmanager.application.dto;

public record CreateProjectCommand(String name) {}
// src/main/java/com/webcodein/taskmanager/application/dto/AddTaskCommand.java
package com.webcodein.taskmanager.application.dto;

public record AddTaskCommand(String title, Integer priorityLevel) {}
// src/main/java/com/webcodein/taskmanager/application/dto/TaskDto.java
package com.webcodein.taskmanager.application.dto;

import com.webcodein.taskmanager.domain.model.Task;
import java.util.UUID;

public record TaskDto(
        UUID id,
        String title,
        String status,
        int priorityLevel,
        String priorityName
) {
    public static TaskDto fromDomain(Task task) {
        return new TaskDto(
                task.getId(),
                task.getTitle(),
                task.getStatus().name(),
                task.getPriority() != null ? task.getPriority().level() : 2,
                task.getPriority() != null ? task.getPriority().name() : "MEDIUM"
        );
    }
}
// src/main/java/com/webcodein/taskmanager/application/dto/ProjectDto.java
package com.webcodein.taskmanager.application.dto;

import com.webcodein.taskmanager.domain.model.Project;
import java.util.List;
import java.util.UUID;

public record ProjectDto(
        UUID id,
        String name,
        String status,
        List<TaskDto> tasks
) {
    public static ProjectDto fromDomain(Project project) {
        List<TaskDto> taskDtos = project.getTasks().stream()
                .map(TaskDto::fromDomain)
                .toList();
        return new ProjectDto(
                project.getId(),
                project.getName(),
                project.getStatus().name(),
                taskDtos
        );
    }
}

Now, let us examine the ProjectApplicationService. Every public method is wrapped in a Spring @Transactional boundary:

// src/main/java/com/webcodein/taskmanager/application/ProjectApplicationService.java
package com.webcodein.taskmanager.application;

import com.webcodein.taskmanager.application.dto.AddTaskCommand;
import com.webcodein.taskmanager.application.dto.CreateProjectCommand;
import com.webcodein.taskmanager.application.dto.ProjectDto;
import com.webcodein.taskmanager.domain.model.Priority;
import com.webcodein.taskmanager.domain.model.Project;
import com.webcodein.taskmanager.domain.model.Task;
import com.webcodein.taskmanager.domain.repository.ProjectRepository;
import com.webcodein.taskmanager.domain.service.TaskAssignmentService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.NoSuchElementException;
import java.util.UUID;

@Service
@Transactional
public class ProjectApplicationService {

    private final ProjectRepository projectRepository;
    private final TaskAssignmentService taskAssignmentService;

    public ProjectApplicationService(ProjectRepository projectRepository,
                                     TaskAssignmentService taskAssignmentService) {
        this.projectRepository = projectRepository;
        this.taskAssignmentService = taskAssignmentService;
    }

    public UUID createProject(CreateProjectCommand command) {
        Project project = new Project(command.name());
        Project saved = projectRepository.save(project);
        return saved.getId();
    }

    public UUID addTaskToProject(UUID projectId, String title) {
        Task task = taskAssignmentService.assignTask(projectId, title);
        return task.getId();
    }

    public UUID addTaskToProject(UUID projectId, AddTaskCommand command) {
        Priority priority = command.priorityLevel() != null
                ? Priority.of(command.priorityLevel())
                : Priority.MEDIUM;
        Task task = taskAssignmentService.assignTask(projectId, command.title(), priority);
        return task.getId();
    }

    public void startTask(UUID projectId, UUID taskId) {
        Project project = loadProject(projectId);
        Task task = project.findTask(taskId)
                .orElseThrow(() -> new NoSuchElementException("Task not found: " + taskId));
        task.startProgress();
        projectRepository.save(project);
    }

    public void markTaskDone(UUID projectId, UUID taskId) {
        Project project = loadProject(projectId);
        Task task = project.findTask(taskId)
                .orElseThrow(() -> new NoSuchElementException("Task not found: " + taskId));
        task.markAsDone();
        projectRepository.save(project);
    }

    public void completeProject(UUID projectId) {
        Project project = loadProject(projectId);
        project.complete();
        projectRepository.save(project);
    }

    @Transactional(readOnly = true)
    public ProjectDto getProject(UUID projectId) {
        Project project = loadProject(projectId);
        return ProjectDto.fromDomain(project);
    }

    @Transactional(readOnly = true)
    public List<ProjectDto> listProjects() {
        return projectRepository.findAll().stream()
                .map(ProjectDto::fromDomain)
                .toList();
    }

    public void deleteProject(UUID projectId) {
        projectRepository.deleteById(projectId);
    }

    private Project loadProject(UUID projectId) {
        return projectRepository.findById(projectId)
                .orElseThrow(() -> new NoSuchElementException("Project not found: " + projectId));
    }
}
Spring Boot 4 DDD Architecture End-to-End Request and Persistence Flow Diagram
Figure 6: Complete End-to-End Request, Application Service Orchestration, and Persistence Flow in Spring Boot 4.

12. Step 10: Exposing REST Endpoints and Handling Errors

The Presentation Layer handles HTTP networking. It converts JSON request bodies into Application commands, delegates execution to ProjectApplicationService, and translates domain exceptions into standard HTTP error responses.

// src/main/java/com/webcodein/taskmanager/presentation/ProjectController.java
package com.webcodein.taskmanager.presentation;

import com.webcodein.taskmanager.application.ProjectApplicationService;
import com.webcodein.taskmanager.application.dto.AddTaskCommand;
import com.webcodein.taskmanager.application.dto.CreateProjectCommand;
import com.webcodein.taskmanager.application.dto.ProjectDto;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.UUID;

@RestController
@RequestMapping("/api/projects")
public class ProjectController {

    private final ProjectApplicationService appService;

    public ProjectController(ProjectApplicationService appService) {
        this.appService = appService;
    }

    @PostMapping
    public ResponseEntity<Map<String, Object>> createProject(@RequestBody CreateProjectCommand command) {
        UUID id = appService.createProject(command);
        return ResponseEntity.created(URI.create("/api/projects/" + id))
                .body(Map.of("id", id, "message", "Project created successfully"));
    }

    @GetMapping
    public ResponseEntity<List<ProjectDto>> listProjects() {
        return ResponseEntity.ok(appService.listProjects());
    }

    @GetMapping("/{id}")
    public ResponseEntity<ProjectDto> getProject(@PathVariable UUID id) {
        return ResponseEntity.ok(appService.getProject(id));
    }

    @PostMapping("/{id}/tasks")
    public ResponseEntity<Map<String, Object>> addTask(@PathVariable UUID id,
                                                        @RequestBody TaskRequest request) {
        UUID taskId = appService.addTaskToProject(id, new AddTaskCommand(request.title(), request.priorityLevel()));
        return ResponseEntity.created(URI.create("/api/projects/" + id + "/tasks/" + taskId))
                .body(Map.of("id", taskId, "projectId", id, "message", "Task added successfully"));
    }

    @PutMapping("/{id}/tasks/{taskId}/start")
    public ResponseEntity<Map<String, String>> startTask(@PathVariable UUID id,
                                                         @PathVariable UUID taskId) {
        appService.startTask(id, taskId);
        return ResponseEntity.ok(Map.of("message", "Task started"));
    }

    @PutMapping("/{id}/tasks/{taskId}/complete")
    public ResponseEntity<Map<String, String>> completeTask(@PathVariable UUID id,
                                                            @PathVariable UUID taskId) {
        appService.markTaskDone(id, taskId);
        return ResponseEntity.ok(Map.of("message", "Task marked as done"));
    }

    @PutMapping("/{id}/complete")
    public ResponseEntity<Map<String, String>> completeProject(@PathVariable UUID id) {
        appService.completeProject(id);
        return ResponseEntity.ok(Map.of("message", "Project completed"));
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteProject(@PathVariable UUID id) {
        appService.deleteProject(id);
    }

    public record TaskRequest(String title, Integer priorityLevel) {
        public TaskRequest(String title) {
            this(title, 2);
        }
    }
}

To map domain exceptions to proper HTTP error codes, create a global controller advice:

// src/main/java/com/webcodein/taskmanager/presentation/GlobalExceptionHandler.java
package com.webcodein.taskmanager.presentation;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.Instant;
import java.util.Map;
import java.util.NoSuchElementException;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException ex) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of(
                "error", "Bad Request",
                "message", ex.getMessage(),
                "timestamp", Instant.now().toString()
        ));
    }

    @ExceptionHandler(IllegalStateException.class)
    public ResponseEntity<Map<String, Object>> handleIllegalState(IllegalStateException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of(
                "error", "Conflict",
                "message", ex.getMessage(),
                "timestamp", Instant.now().toString()
        ));
    }

    @ExceptionHandler(NoSuchElementException.class)
    public ResponseEntity<Map<String, Object>> handleNotFound(NoSuchElementException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of(
                "error", "Not Found",
                "message", ex.getMessage(),
                "timestamp", Instant.now().toString()
        ));
    }
}

13. Step 11: Verifying Business Rules with Tests

Because the domain layer is completely decoupled from Spring and database infrastructure, you can test business logic using pure, sub-millisecond JUnit 5 unit tests. No database containers or heavy Spring contexts are required.

Let us write comprehensive aggregate root invariant tests:

// src/test/java/com/webcodein/taskmanager/domain/model/ProjectTest.java
package com.webcodein.taskmanager.domain.model;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.UUID;

import static org.junit.jupiter.api.Assertions.*;

@DisplayName("Project Aggregate Root Tests")
class ProjectTest {

    @Test
    @DisplayName("Should create project with ACTIVE status and empty tasks")
    void shouldCreateProjectWithActiveStatus() {
        Project project = new Project("Website Redesign");

        assertNotNull(project.getId());
        assertEquals("Website Redesign", project.getName());
        assertEquals(ProjectStatus.ACTIVE, project.getStatus());
        assertTrue(project.getTasks().isEmpty());
    }

    @Test
    @DisplayName("Should throw exception when creating project with null or blank name")
    void shouldThrowExceptionForInvalidName() {
        assertThrows(IllegalArgumentException.class, () -> new Project(null));
        assertThrows(IllegalArgumentException.class, () -> new Project("   "));
    }

    @Test
    @DisplayName("Should add task to project and register domain event")
    void shouldAddTaskToProject() {
        Project project = new Project("Website Redesign");
        Task task = project.addTask("Design Homepage", Priority.HIGH);

        assertEquals(1, project.getTasks().size());
        assertEquals("Design Homepage", task.getTitle());
        assertEquals(Priority.HIGH, task.getPriority());
        assertEquals(TaskStatus.TODO, task.getStatus());
        assertEquals(1, project.getDomainEvents().size());
    }

    @Test
    @DisplayName("Should reject duplicate task title in same project")
    void shouldRejectDuplicateTaskTitle() {
        Project project = new Project("Website Redesign");
        project.addTask("Design Homepage");

        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> {
            project.addTask("design homepage"); // Case-insensitive
        });
        assertTrue(ex.getMessage().contains("already exists"));
    }

    @Test
    @DisplayName("Should reject adding task when project is completed")
    void shouldRejectAddingTaskWhenProjectCompleted() {
        Project project = new Project("Website Redesign");
        Task task = project.addTask("Setup CI");
        task.markAsDone();
        project.complete();

        IllegalStateException ex = assertThrows(IllegalStateException.class, () -> {
            project.addTask("Another Task");
        });
        assertTrue(ex.getMessage().contains("Cannot add tasks to non-active project"));
    }

    @Test
    @DisplayName("Should enforce maximum 50 tasks limit")
    void shouldEnforceMaxTasksLimit() {
        Project project = new Project("Mega Project");
        for (int i = 0; i < Project.MAX_TASKS_PER_PROJECT; i++) {
            project.addTask("Task " + i);
        }

        IllegalStateException ex = assertThrows(IllegalStateException.class, () -> {
            project.addTask("Overflow Task");
        });
        assertTrue(ex.getMessage().contains("reached maximum capacity"));
    }

    @Test
    @DisplayName("Should complete project when all tasks are DONE")
    void shouldCompleteProjectWhenAllTasksDone() {
        Project project = new Project("Sprint 1");
        Task t1 = project.addTask("Task 1");
        Task t2 = project.addTask("Task 2");

        t1.markAsDone();
        t2.markAsDone();

        project.complete();
        assertEquals(ProjectStatus.COMPLETED, project.getStatus());
        assertEquals(3, project.getDomainEvents().size());
    }

    @Test
    @DisplayName("Should reject completing project with pending tasks")
    void shouldRejectCompletingProjectWithPendingTasks() {
        Project project = new Project("Sprint 1");
        project.addTask("Task 1");

        IllegalStateException ex = assertThrows(IllegalStateException.class, project::complete);
        assertTrue(ex.getMessage().contains("Cannot complete project with pending or in-progress tasks"));
    }

    @Test
    @DisplayName("Should reconstitute project without triggering validation")
    void shouldReconstituteProject() {
        UUID id = UUID.randomUUID();
        UUID taskId = UUID.randomUUID();
        Task task = Task.reconstitute(taskId, "Legacy Task", TaskStatus.DONE, Priority.LOW);

        Project project = Project.reconstitute(id, "Archived Project", ProjectStatus.COMPLETED, List.of(task));

        assertEquals(id, project.getId());
        assertEquals("Archived Project", project.getName());
        assertEquals(ProjectStatus.COMPLETED, project.getStatus());
        assertEquals(1, project.getTasks().size());
        assertEquals("Legacy Task", project.getTasks().get(0).getTitle());
    }
}

Now, test the Web presentation layer in isolation using Spring Boot 4’s @WebMvcTest slice:

// src/test/java/com/webcodein/taskmanager/presentation/ProjectControllerTest.java
package com.webcodein.taskmanager.presentation;

import com.webcodein.taskmanager.application.ProjectApplicationService;
import com.webcodein.taskmanager.application.dto.AddTaskCommand;
import com.webcodein.taskmanager.application.dto.CreateProjectCommand;
import com.webcodein.taskmanager.application.dto.ProjectDto;
import com.webcodein.taskmanager.application.dto.TaskDto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import java.util.List;
import java.util.NoSuchElementException;
import java.util.UUID;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(ProjectController.class)
@AutoConfigureMockMvc
@DisplayName("ProjectController WebMvc Slice Tests")
class ProjectControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private ProjectApplicationService appService;

    @Test
    @DisplayName("POST /api/projects should return 201 Created")
    void shouldCreateProject() throws Exception {
        UUID projectId = UUID.randomUUID();
        when(appService.createProject(any(CreateProjectCommand.class))).thenReturn(projectId);

        mockMvc.perform(post("/api/projects")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "name": "Alpha Platform"
                                }
                                """))
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", "/api/projects/" + projectId))
                .andExpect(jsonPath("$.id").value(projectId.toString()));
    }

    @Test
    @DisplayName("PUT /api/projects/{id}/complete should return 409 Conflict when tasks unfinished")
    void shouldReturn409WhenCompleteFailsInvariant() throws Exception {
        UUID projectId = UUID.randomUUID();
        doThrow(new IllegalStateException("Cannot complete project with pending tasks"))
                .when(appService).completeProject(projectId);

        mockMvc.perform(put("/api/projects/" + projectId + "/complete"))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.error").value("Conflict"))
                .andExpect(jsonPath("$.message").value("Cannot complete project with pending tasks"));
    }
}

Execute the test suite from your terminal:

mvn test

Expected output confirms all unit, web slice, and integration tests pass cleanly:

[INFO] Running ProjectApplicationService Application Layer Tests
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running Priority Value Object Tests
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running Project Aggregate Root Tests
[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running Task Entity Tests
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running TaskAssignmentService Domain Service Tests
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running ProjectController WebMvc Slice Tests
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running TaskManagerApplication Full Integration Tests
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

14. Step 12: Enforcing Architecture Boundaries with ArchUnit

Code reviews alone cannot guarantee that team members respect DDD layer rules. Developers might accidentally import Spring annotations into domain entities or call repositories directly from presentation controllers. ArchUnit prevents architecture degradation by treating architectural boundaries as executable automated unit tests.

Create DddArchitectureTest.java in your test folder:

// src/test/java/com/webcodein/taskmanager/DddArchitectureTest.java
package com.webcodein.taskmanager;

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 org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.RestController;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;

@AnalyzeClasses(packages = "com.webcodein.taskmanager", importOptions = {ImportOption.DoNotIncludeTests.class})
public class DddArchitectureTest {

    @ArchTest
    static final ArchRule layered_architecture_respected = layeredArchitecture()
            .consideringAllDependencies()
            .layer("Domain").definedBy("com.webcodein.taskmanager.domain..")
            .layer("Application").definedBy("com.webcodein.taskmanager.application..")
            .layer("Infrastructure").definedBy("com.webcodein.taskmanager.infrastructure..")
            .layer("Presentation").definedBy("com.webcodein.taskmanager.presentation..")
            .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure", "Presentation")
            .whereLayer("Presentation").mayNotBeAccessedByAnyLayer()
            .whereLayer("Application").mayOnlyBeAccessedByLayers("Presentation", "Infrastructure");

    @ArchTest
    static final ArchRule domain_does_not_depend_on_application_or_infrastructure = classes()
            .that().resideInAPackage("com.webcodein.taskmanager.domain..")
            .should().onlyDependOnClassesThat().resideInAnyPackage(
                    "com.webcodein.taskmanager.domain..",
                    "java..",
                    "jakarta.persistence.."
            );

    @ArchTest
    static final ArchRule application_services_should_be_annotated_with_service = classes()
            .that().resideInAPackage("com.webcodein.taskmanager.application..")
            .and().haveSimpleNameEndingWith("Service")
            .should().beAnnotatedWith(Service.class);

    @ArchTest
    static final ArchRule presentation_controllers_should_be_annotated_with_rest_controller = classes()
            .that().resideInAPackage("com.webcodein.taskmanager.presentation..")
            .and().haveSimpleNameEndingWith("Controller")
            .should().beAnnotatedWith(RestController.class);
}

If someone accidentally injects a Spring bean into a domain model class, the ArchUnit test fails instantly during CI build, blocking the Pull Request.

15. Comparative Architecture Matrix: DDD vs Traditional CRUD

Understanding when to choose Domain-Driven Design versus traditional layered CRUD is crucial for engineering success:

Dimension Traditional CRUD Architecture Domain-Driven Design (DDD)
Business Logic Location Scattered across service methods and SQL triggers (Anemic Domain Model). Encapsulated entirely inside Aggregate Roots, Entities, and Value Objects.
State Mutability Open public getters and setters allow any service to mutate internal state. Strictly protected. State changes occur exclusively via explicit business methods.
Invariant Protection Validation is fragmented across web controllers, services, and database checks. Guaranteed by Aggregate boundaries upon instantiation and mutation.
Framework Coupling Domain entities are tightly bound to ORM annotations and Spring transactions. Core domain rules are framework-agnostic pure Java objects.
Testability Testing business logic requires full Spring context and database mocks. Tested in sub-milliseconds with pure JUnit 5 unit tests without mocks.
Ideal Application Scale Simple administrative dashboards, report readers, and lightweight CRUD forms. Complex enterprise workflows, financial systems, logistics, and healthcare portals.

16. Production Best Practices and Common Pitfalls

When applying Domain-Driven Design in production applications, follow these proven engineering practices:

  • Keep Aggregates Small: Design aggregates around transactional boundaries, not entire database clusters. Large aggregates cause frequent database lock contention and optimistic locking exceptions.
  • Reference Other Aggregates by ID Only: Never establish direct JPA object references between two different Aggregate Roots. Reference other aggregates by their UUID or strongly-typed ID record.
  • Never Leak Entities Through Controllers: Never return domain entities directly from Spring MVC controllers. Always transform domain state into immutable response DTO records in the Application layer.
  • Use Reconstitution Factories: When re-hydrating aggregates from existing database rows, use a separate reconstitute(...) method to avoid re-triggering creation invariants.
  • Publish Domain Events for Cross-Aggregate Communication: When an aggregate state change affects another bounded context, publish a domain event rather than calling the other aggregate directly in the same transaction. Explore our companion guide on Event-Driven Architecture with Kafka and Spring Boot 4.

17. Conclusion and Next Steps

Congratulations! You have built your first complete Domain-Driven Design application with Spring Boot 4 and Java 25. You learned how to model clean Value Objects with Java records, protect domain invariants inside Aggregate Roots, decouple persistence via Repository interfaces, and enforce architecture boundaries with ArchUnit.

You can clone and inspect the complete, runnable source code from our companion GitHub repository.

To further deepen your enterprise software architecture expertise, explore our related masterclasses:

18. Frequently Asked Questions

What is the difference between an Entity and an Aggregate Root?

Every Aggregate Root is an Entity, but not every Entity is an Aggregate Root. An Aggregate Root is the primary gateway entity that controls access and ensures invariant consistency for an entire cluster of related entities and value objects. External services can only reference and load the Aggregate Root directly.

Why shouldn’t I put @Service annotations on Domain Services?

Domain Services represent pure business logic that belongs to the core domain model. Placing framework annotations like Spring’s @Service creates a compile-time dependency on external frameworks, violating the Dependency Inversion Principle. Instead, instantiate domain services as Spring beans using an infrastructure @Configuration class.

How do Aggregates prevent concurrent data corruption?

Aggregates guarantee consistency by acting as the single transactional boundary. In production, aggregates use optimistic locking via a @Version column or database row-level locking. Any concurrent update to the aggregate or its child entities increments the aggregate version, rejecting conflicting concurrent writes.

Can an Aggregate Root reference another Aggregate Root directly?

No. In Domain-Driven Design, Aggregate Roots must never hold direct object references to other Aggregate Roots. They should only reference other aggregates by their unique identity (such as a UUID). This prevents memory bloat and eliminates tight transactional coupling between aggregates.

When should I choose DDD over traditional CRUD?

Choose DDD when your application has complex business invariants, evolving domain rules, multiple lifecycle states, and high domain complexity. For simple data entry applications, reporting tables, or basic CRUD portals with minimal business logic, traditional layered CRUD is faster and introduces less architectural overhead.

Comments