Skip to content
Back EndJavaJavaEE

Introduction to Domain-Driven Design with Jakarta EE 11: A Practical Guide

7 min readsalahtobok
Abstract representation of Domain-Driven Design and Jakarta EE

Domain-Driven Design (DDD) is a software engineering approach that solves complex problems by connecting the code directly to the business domain. When combined with Jakarta EE 11 and Java 25, you can build enterprise systems that are scalable, easy to maintain, and truly reflect the business rules.

In this guide, we will explore the core concepts of DDD, breaking it down into Strategic Design and Tactical Design. We will then show you how to implement these concepts using the latest features in Jakarta EE 11, including Jakarta Data 1.0 and Jakarta Persistence (Hibernate 7).

What is Domain-Driven Design (DDD)?

At its core, DDD focuses on understanding the business (the domain) before writing any code. Developers and domain experts work together to create a shared vocabulary. This is called the Ubiquitous Language. Every term used in discussions must appear exactly the same way in the Java code.

If the business talks about an Order, a Customer, and a Product, your code should have classes named exactly Order, Customer, and Product. DDD prevents the code from drifting away from the actual business reality.

Strategic Design: Bounded Contexts

Large enterprise systems are too complex to model as one giant application. Strategic DDD solves this by splitting the system into smaller, logical boundaries called Bounded Contexts.

Strategic Domain-Driven Design Diagram showing Bounded Contexts
Strategic DDD splits a large enterprise system into distinct Bounded Contexts.

A Bounded Context defines a specific area of the business. For example, an e-commerce platform might have:

  • Inventory Context: Focuses on stock levels and warehouse locations.
  • Orders Context: Focuses on shopping carts, checkout, and payment status.
  • Shipping Context: Focuses on tracking numbers and delivery routes.

A Product in the Inventory context means “a physical box in a warehouse.” A Product in the Orders context means “a price and a name shown to the user.” By separating these contexts, we avoid creating a massive, confusing Product class that tries to do everything.

Tactical Design: The Building Blocks

Once you define your Bounded Contexts, you use Tactical DDD to write the code inside them. Tactical design provides specific patterns to structure your Java classes.

Tactical DDD Patterns showing Aggregate Root, Entities, and Value Objects
Tactical DDD patterns define the internal structure of a Bounded Context.

1. Entities and Value Objects

In DDD, there are two main types of domain objects:

  • Entity: An object with a unique identity that changes over time. For example, an Order has an ID and a status (Pending, Shipped).
  • Value Object: An object with no identity. It is defined only by its attributes. It is immutable. For example, a Money object (amount and currency) or a ShippingAddress.

2. Aggregates and Aggregate Roots

An Aggregate is a group of related Entities and Value Objects treated as a single unit. The Aggregate Root is the main entity that controls access to the aggregate.

For example, an Order is an Aggregate Root. It contains OrderItem entities. You should never modify an OrderItem directly. You must ask the Order to add or remove items. The Aggregate Root protects the business rules (invariants).

Mapping DDD to Jakarta EE 11

Jakarta EE 11 provides the perfect annotations to map these DDD concepts to actual code. Let’s look at how they connect.

Mapping DDD Concepts to Jakarta EE 11 Annotations
How DDD concepts translate into Jakarta EE 11 and Jakarta Persistence annotations.

Implementing Value Objects with @Embeddable

We use the @Embeddable annotation from Jakarta Persistence to create Value Objects. In Java 25, we can use record types for pure immutability, but for JPA mapping, standard classes are often easier.

import jakarta.persistence.Embeddable;

@Embeddable
public class Address {
    private String street;
    private String city;
    private String zipCode;

    protected Address() {} // Required by JPA

    public Address(String street, String city, String zipCode) {
        this.street = street;
        this.city = city;
        this.zipCode = zipCode;
    }
    
    // Getters only. No setters to enforce immutability!
}

Implementing the Aggregate Root with @Entity

We use the @Entity annotation to define our Aggregate Root. Notice how the Order class protects its state. It does not have public setters.

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

@Entity
@Table(name = "orders")
public class Order {

    @Id
    private UUID id;

    @Embedded
    private Address shippingAddress;

    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();

    private String status;

    protected Order() {} // JPA requirement

    public Order(UUID id, Address shippingAddress) {
        this.id = id;
        this.shippingAddress = shippingAddress;
        this.status = "PENDING";
    }

    // Business method inside the Aggregate Root
    public void addItem(OrderItem item) {
        // Enforce business rules here
        if (this.status.equals("SHIPPED")) {
            throw new IllegalStateException("Cannot add items to a shipped order");
        }
        this.items.add(item);
    }
}

Repositories with Jakarta Data 1.0

In DDD, a Repository is responsible for saving and loading Aggregate Roots. You should only create repositories for Aggregate Roots, never for internal entities.

Jakarta Data 1.0 Repository interface saving an Aggregate Root
A Jakarta Data Repository persists the entire Aggregate Root.

Jakarta Data 1.0 makes it incredibly easy to define DDD repositories. You simply create an interface and use the @Repository annotation. The framework generates the implementation automatically.

import jakarta.data.repository.CrudRepository;
import jakarta.data.repository.Repository;
import java.util.UUID;

@Repository
public interface OrderRepository extends CrudRepository<Order, UUID> {
    
    // Custom query method generated by Jakarta Data
    List<Order> findByStatus(String status);
}

Real-World Use Cases

Domain-Driven Design is not for simple CRUD (Create, Read, Update, Delete) applications. It shines in complex environments:

  • Fintech and Banking: Handling complex transactions, ledgers, and currency exchanges where strict business rules must apply.
  • Healthcare Systems: Managing patient records, appointments, and insurance claims across different hospital departments (Bounded Contexts).
  • E-commerce Platforms: Handling dynamic pricing, advanced inventory reservations, and complex shipping logistics.

Advantages and Limitations

Advantages

  • Clear Code: The code reflects the business reality. Developers and domain experts understand each other.
  • Safe Modifications: Aggregate Roots enforce business rules, making it impossible to leave the system in an invalid state.
  • Microservices Ready: Bounded Contexts naturally map to microservice boundaries.

Limitations

  • High Learning Curve: DDD introduces many new terms and concepts that take time to learn.
  • Overkill for Simple Apps: If your application simply reads and writes data to a database without complex rules, DDD adds unnecessary complexity.

Best Practices and Common Mistakes

Best Practices

  • Focus on the Domain: Spend time talking to business experts before writing code.
  • Use Value Objects: Replace primitive types (like String or int) with Value Objects (like EmailAddress or Quantity) whenever possible.
  • One Aggregate per Transaction: A single database transaction should only modify one Aggregate Root. If you need to update multiple aggregates, use Domain Events.

Common Mistakes

  • Anemic Domain Model: Creating classes with only getters and setters, and putting all the business logic in separate “Service” classes. The logic should live inside the Entities.
  • Repositories for Everything: Creating a repository for every single entity. Remember, only Aggregate Roots get repositories.
  • Ignoring the Ubiquitous Language: Using different names in the code than the business uses in meetings.

Conclusion

Domain-Driven Design provides a powerful way to manage software complexity. By using Bounded Contexts, you can organize large systems. By using Tactical patterns like Aggregate Roots and Value Objects, you can write safe, expressive Java code.

Jakarta EE 11, with Jakarta Persistence and the new Jakarta Data 1.0 specification, offers standard, built-in tools to implement these DDD concepts seamlessly. If you want to see a more advanced architecture, check out our guide on Hexagonal Architecture and DDD.


Companion Workshop Code

Ready to see this running? Get the complete, compilable Java 25 and Jakarta EE 11 project from our GitHub repository. It includes all the entities, repositories, and Testcontainers integration tests.

👉 View the Companion Code on GitHub

References

Comments