Skip to content
Back EndJavaJavaEE

Practical Domain-Driven Design with Jakarta EE 11

12 min readsalahtobok
Domain-Driven Design and Jakarta EE 11 Multipage Guide

What is Domain-Driven Design?

Domain-Driven Design (DDD) is a software development approach that focuses on the core business problem (the domain). It helps developers create software that accurately reflects how the business actually works. Instead of focusing on databases or technical frameworks first, DDD focuses on understanding the business rules and modeling them in code.

If you are completely new to this concept, consider reading our Introduction to Domain-Driven Design with Jakarta EE 11 first.

The problem DDD tries to solve is business complexity. As applications grow, they often become a “Big Ball of Mud.” Code gets messy. A small change in one place breaks something else. Developers and business experts stop understanding each other. DDD solves this by creating strict boundaries and a shared vocabulary.

Domain-Driven Design Architecture Diagram
The core layers of a Domain-Driven Design application.

What Jakarta EE Provides

Jakarta EE 11 is a powerful set of specifications for building enterprise Java applications. It provides standard tools for things like dependency injection (CDI), building APIs (Jakarta REST), and talking to databases (Jakarta Persistence and Jakarta Data).

Why They Work Well Together

It is important to understand that DDD is an architectural approach, not a framework. Jakarta EE is the technical framework. They work perfectly together because Jakarta EE provides the technical foundation, allowing you to focus on the business domain.

There is a clear difference between business architecture and technical architecture. The business architecture (DDD) decides how an Order works. The technical architecture (Jakarta EE) decides how the Order is saved to a PostgreSQL database.

Our Example: A Document Management System

Throughout this guide, we will build a Document Management System. This is a realistic application where users can create documents, organize them in folders, and manage permissions. This consistent example will help you see how DDD and Jakarta EE work together in practice.

Understanding the Domain

In DDD, the Domain is the main subject area of your software. For our example, the domain is “Document Management.” However, a domain is usually too large to understand all at once. We must break it down into smaller pieces called Subdomains.

  • Core Domain: The most important part of the business. This makes your application unique. In our system, the Document Lifecycle and Versioning is the Core Domain.
  • Supporting Subdomain: Important, but not the primary focus. For example, User Permissions.
  • Generic Subdomain: Standard features that many applications need. For example, sending email notifications or generating PDF reports.
Document Management System Domain Model
The domain model showing relationships between Documents, Folders, and Users.

Domain Experts and the Ubiquitous Language

Developers often invent technical names for business concepts. This causes confusion. In DDD, developers must work closely with Domain Experts (the people who actually understand the business, like product managers or users).

Together, they create a Ubiquitous Language. This is a shared vocabulary used everywhere: in meetings, in documentation, and most importantly, directly in the Java code. If a domain expert calls it a “Draft,” you must create a class or enum called Draft. You cannot name it UnpublishedRecord.

Glossary of Our Domain

Term Definition in Our System
Document A file uploaded by a user with a title, status, and content.
Folder A container used to organize multiple Documents.
Draft The initial status of a Document before it is published.
Publisher A User who has the authority to make a Document visible to others.

Entities, Value Objects, and Aggregates

To write the code for our domain, we use tactical DDD patterns. The three most important are Entities, Value Objects, and Aggregates.

Difference between Entity, Value Object, and Aggregate
Aggregates protect Entities and Value Objects inside a consistency boundary.

Entity

An Entity is an object with a unique identity that remains the same over time, even if its properties change. It has a specific lifecycle and contains business behavior.

import java.util.UUID;

public class Document {
    private final UUID id; // Identity
    private String title;
    private DocumentStatus status; // Lifecycle state

    public Document(UUID id, String title) {
        this.id = id;
        this.title = title;
        this.status = DocumentStatus.DRAFT;
    }

    // Business behavior
    public void publish() {
        if (this.status == DocumentStatus.ARCHIVED) {
            throw new IllegalStateException("Cannot publish an archived document.");
        }
        this.status = DocumentStatus.PUBLISHED;
    }
}

Value Object

A Value Object represents a descriptive concept. It has no independent identity. Two Value Objects are equal if all their properties are exactly the same. They must be immutable (unchangeable). In Java 25, we use record to create perfect Value Objects.

public record FileExtension(String value) {
    public FileExtension {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Extension cannot be empty");
        }
        if (!value.startsWith(".")) {
            throw new IllegalArgumentException("Extension must start with a dot");
        }
    }
}

Aggregate and Aggregate Root

An Aggregate is a cluster of Entities and Value Objects that are treated as a single unit. The Aggregate Root is the main Entity in the cluster. Outside objects can only hold a reference to the Aggregate Root, never to its internal parts.

The Aggregate Root acts as a consistency boundary. It ensures all business rules (invariants) are followed when its internal data changes. Aggregates should remain reasonably small to avoid database locking issues.

Clear Distinction:

  • Entity: Has an ID. Changes over time.
  • Value Object: No ID. Immutable. Describes things.
  • Aggregate: A group of objects saved together.
  • Aggregate Root: The “boss” Entity that controls the Aggregate.

Repositories and Services

Once we have our domain objects, we need ways to save them and execute complex actions.

Application Architecture combining DDD and Jakarta EE
How Application Services orchestrate Repositories and Domain logic.

Repository

A Repository is an abstraction that acts like a collection of Aggregate Roots in memory. Its purpose is to load and save Aggregates. The domain only knows the interface. The persistence implementation (like Jakarta Data or Hibernate) is hidden.

The domain should never depend directly on database technology. This keeps your business logic clean and easy to test.

Domain Service

Sometimes, a business rule involves multiple Aggregates or does not logically belong inside a single Entity. When this happens, we create a Domain Service. It holds pure business logic but has no state of its own.

For example, a DocumentArchivalService might verify rules across both a Document and a Folder before archiving.

Application Service

An Application Service orchestrates the workflow. It does not contain business rules. Instead, it:

  1. Receives a request (often a DTO).
  2. Starts a database transaction.
  3. Calls a Repository to load an Aggregate.
  4. Calls a method on the Aggregate to perform business logic.
  5. Calls the Repository to save the Aggregate.
  6. Ends the transaction.

Clear Distinction:

  • Domain Service: Pure business logic that does not fit in an Entity.
  • Application Service: Workflow orchestration, transactions, and use cases.
  • Repository: Saving and loading Aggregates.

Bounded Contexts and Modular Architecture

In large companies, different departments use the same words to mean completely different things. A “Document” in the HR department is very different from a “Document” in the Legal department.

Trying to create one giant, shared Document class for the whole company will fail. It will become a massive, confusing object. DDD solves this with the Bounded Context.

Bounded Contexts in Domain-Driven Design
Separating models using strict Bounded Contexts.

A Bounded Context establishes a strict boundary. Inside the boundary, a specific model is valid. The same business concept can have different meanings in different contexts.

Example Architecture

Document Management Context
        |
        +---- Document (Contains content, versions, and statuses)
        +---- Folder
        +---- Permission

Identity Context
        |
        +---- User (Contains passwords, roles, and login history)
        +---- Organization

Notification Context
        |
        +---- Notification (Contains delivery status and templates)
        +---- Recipient

Instead of one giant model, we create separate modules or microservices. If the Document Management Context needs to know the author’s name, it only stores a simple authorId. It does not import the complex User object from the Identity Context.

Implementing DDD with Jakarta EE 11

Jakarta EE 11 offers a robust set of specifications to implement DDD cleanly. Each technology has a specific responsibility in the architecture.

  • CDI (Contexts and Dependency Injection): Used to wire Application Services and Repositories together.
  • Jakarta REST: Used to create the delivery mechanism (the API endpoints) that trigger the Application Services.
  • Jakarta Persistence (JPA): Used behind the Repository interface to map Entities and Value Objects to database tables.
  • Jakarta Transactions (JTA): Used on Application Services to control transaction boundaries.
  • Jakarta Validation: Used to ensure incoming data (DTOs) is correct before it reaches the domain.
  • Jakarta Events: Used to implement Domain Events, allowing different Aggregates to communicate loosely.
Jakarta EE Architecture Diagram
How Jakarta EE specifications align with Domain-Driven Design layers.

A good DDD project separates infrastructure from the domain. Here is a recommended structure for our Document Management System:

This separation of concerns pairs perfectly with Hexagonal Architecture (Ports and Adapters).

src/main/java
└── com.webcodein.documents
    ├── domain
    │   ├── model          (Entities, Value Objects, Aggregates)
    │   ├── repository     (Repository Interfaces only)
    │   ├── service        (Domain Services)
    │   └── event          (Domain Events)
    │
    ├── application
    │   ├── service        (Application Services / Use Cases)
    │   └── dto            (Data Transfer Objects)
    │
    ├── infrastructure
    │   ├── persistence    (JPA configurations, Jakarta Data interfaces)
    │   └── configuration  (CDI producers, Framework setup)
    │
    └── interfaces
        └── rest           (Jakarta REST Endpoints)

The exact structure can vary depending on project size, but the rule remains: the domain package must never import classes from infrastructure or interfaces.

Building a Real Jakarta EE DDD Application

Now, let’s build the actual Java 25 application step by step.

Document Publishing Workflow Diagram
The workflow for creating and publishing a document.

1. Value Object and Enum

package com.webcodein.documents.domain.model;

public enum DocumentStatus {
    DRAFT, PUBLISHED, ARCHIVED
}

// Java 25 record used as a Value Object
public record DocumentTitle(String value) {
    public DocumentTitle {
        if (value == null || value.trim().length() < 3) {
            throw new IllegalArgumentException("Title must be at least 3 characters");
        }
    }
}

2. Aggregate Root (Entity)

We use JPA annotations directly on our Aggregate Root to map it, while keeping strict business rules.

package com.webcodein.documents.domain.model;

import jakarta.persistence.*;
import java.util.UUID;

@Entity
@Table(name = "documents")
public class Document {

    @Id
    private UUID id;

    @Column(nullable = false)
    private String title;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private DocumentStatus status;

    protected Document() {} // Required by JPA

    public Document(UUID id, DocumentTitle title) {
        this.id = id;
        this.title = title.value();
        this.status = DocumentStatus.DRAFT;
    }

    public void publish() {
        if (this.status == DocumentStatus.ARCHIVED) {
            throw new IllegalStateException("Cannot publish archived document");
        }
        this.status = DocumentStatus.PUBLISHED;
    }

    public DocumentStatus getStatus() { return status; }
}

3. Repository with Jakarta Data

We use Jakarta Data 1.0 to completely automate the database queries.

package com.webcodein.documents.domain.repository;

import com.webcodein.documents.domain.model.Document;
import jakarta.data.repository.CrudRepository;
import jakarta.data.repository.Repository;
import java.util.UUID;

@Repository
public interface DocumentRepository extends CrudRepository<Document, UUID> {
}

4. Application Service

The Application Service handles the transaction using @Transactional.

package com.webcodein.documents.application.service;

import com.webcodein.documents.domain.model.Document;
import com.webcodein.documents.domain.repository.DocumentRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.util.UUID;

@ApplicationScoped
public class DocumentService {

    @Inject
    private DocumentRepository documentRepository;

    @Transactional
    public void publishDocument(UUID documentId) {
        Document doc = documentRepository.findById(documentId)
            .orElseThrow(() -> new IllegalArgumentException("Document not found"));
        
        doc.publish();
        documentRepository.save(doc); // Jakarta Data handles the update
    }
}

5. Jakarta REST Endpoint

package com.webcodein.documents.interfaces.rest;

import com.webcodein.documents.application.service.DocumentService;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Response;
import java.util.UUID;

@Path("/documents")
public class DocumentResource {

    @Inject
    private DocumentService documentService;

    @POST
    @Path("/{id}/publish")
    public Response publishDocument(@PathParam("id") UUID id) {
        documentService.publishDocument(id);
        return Response.ok().build();
    }
}

Common Mistakes and Best Practices

Implementing DDD correctly requires discipline. Here are common mistakes developers make and how to avoid them.

Common Mistakes

  • Anemic Domain Model: Creating entities that only have getters and setters, and moving all business rules into Application Services. This defeats the purpose of DDD.
  • Large Aggregates: Creating aggregates that contain too many entities. If an Aggregate is too large, multiple users will cause database locking conflicts when saving.
  • Infrastructure in Domain: Making Entities depend directly on database connection objects or REST classes.
  • One Giant Context: Trying to share the exact same User class across billing, security, and shipping modules.

Practical Recommendations Checklist

✓ Use clear domain language in your class names.
✓ Enforce explicit business rules inside the Entity methods.
✓ Keep aggregates reasonably small to improve database performance.
✓ Use Application Services to control transaction boundaries.
✓ Isolate the Domain layer from Infrastructure frameworks.
✓ Create well-defined Bounded Contexts.
✓ Write unit tests that target business behavior, not just getters.

Frequently Asked Questions

What is DDD in Java?
DDD in Java is the practice of modeling complex business rules using object-oriented principles, focusing on Entities, Value Objects, and strict boundaries.

Is DDD a framework?
No. Domain-Driven Design is a software design philosophy and architectural approach, not a library or framework.

Can Jakarta EE be used for DDD?
Yes. Jakarta EE specifications like CDI, Jakarta Persistence, and Jakarta REST provide excellent building blocks for implementing DDD clean architectures.

What is an Aggregate in DDD?
An Aggregate is a group of related domain objects (Entities and Value Objects) that are treated as a single unit for data changes and consistency.

What is the difference between an Entity and a Value Object?
An Entity has a unique identity (like a database ID) that stays the same over time. A Value Object has no identity; it is defined solely by its attributes (like a Color or an Address).

What is a Bounded Context?
A Bounded Context is a clear boundary within a system where a specific domain model is defined and applicable. It prevents terms from becoming ambiguous.

Should JPA entities be domain entities?
They can be. While some strict architectures separate them, using JPA annotations directly on your Domain Entities is completely acceptable and practical if you keep infrastructure logic out of them.

Is DDD suitable for every Jakarta EE application?
No. DDD is best for applications with complex business rules. For a simple CRUD application, DDD adds unnecessary complexity.


Companion Workshop Code

Ready to try it yourself? We have built a complete, compilable Java 25 and Jakarta EE 11 project demonstrating this architecture. It includes all the entities, a Jakarta Data repository, and Testcontainers integration tests.

🚀 View the Companion Code on GitHub

Further Reading

Comments