Introduction: Learning Domain-Driven Design the Hard Way
When developers first transition from simple CRUD (Create, Read, Update, Delete) applications to Domain-Driven Design (DDD), the learning curve can feel more like a vertical cliff. I remember my first major DDD project vividly. We had read all the prominent books, memorized the terminology, and were eager to build our shiny new enterprise e-commerce system. We created bounded contexts, defined our aggregates, set up our tactical patterns, and felt incredibly confident about our enterprise-grade architecture.
However, six months into the project, our codebase was a tangled mess. We were fighting with our Object-Relational Mapper (ORM), our application services were thousands of lines long containing all the core business logic, and our domain models were nothing more than glorified data containers lacking any real behavior. We had fundamentally misunderstood the core principles of DDD, applying the patterns superficially without understanding the underlying philosophy of behavioral modeling. We had to learn Domain-Driven Design the hard way, through trial, error, massive production bugs, and painful months of refactoring.
In this comprehensive, deep-dive guide, we will explore 10 common DDD mistakes beginners make. For each mistake, we will examine a bad code example in Java, explain precisely why it is architecturally problematic, provide a fully fixed Java class demonstrating the correct approach, and summarize the key takeaway. By learning from these common pitfalls, you can avoid the frustration of over-engineered, unmaintainable systems and successfully apply DDD in your next large-scale Java backend project.

Mistake 1: The Anemic Domain Model
The Anemic Domain Model is perhaps the most prevalent anti-pattern in modern software development, especially among Java developers accustomed to data-driven architectures and standard Spring tutorials. It occurs when your domain objects contain only data (fields, getters, and setters) while all the actual business logic, validations, and behavior are pushed out into separate stateless service classes. This completely defeats the purpose of object-oriented programming, turning your entities into mere data structures and your services into procedural transaction scripts. When an entity cannot protect its own invariants, it is vulnerable to being put into an invalid state by any part of the system.
❌ Bad Code Example: The Anemic Approach
package com.example.ecommerce.domain;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
// Bad: The entity has no behavior, only state and accessors.
public class Order {
private UUID orderId;
private String customerId;
private String status;
private BigDecimal totalAmount;
private LocalDateTime creationDate;
private List<OrderItem> items;
public Order() {}
public UUID getOrderId() { return orderId; }
public void setOrderId(UUID orderId) { this.orderId = orderId; }
public String getCustomerId() { return customerId; }
public void setCustomerId(String customerId) { this.customerId = customerId; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public BigDecimal getTotalAmount() { return totalAmount; }
public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; }
public LocalDateTime getCreationDate() { return creationDate; }
public void setCreationDate(LocalDateTime creationDate) { this.creationDate = creationDate; }
public List<OrderItem> getItems() { return items; }
public void setItems(List<OrderItem> items) { this.items = items; }
}
Why It’s Wrong: This code completely breaks encapsulation. Because the Order class provides public setters for every field, any external service or class can modify its state at any time, in any order. There is no guarantee that an order is in a valid state. For example, a developer could easily change the order status to “SHIPPED” without verifying if payment was received, or they could modify the total amount without updating the items list. The domain object is defenseless against invalid state transitions, pushing the burden of data integrity onto the developer writing the service layer, which inevitably leads to duplicate logic and bugs.
✅ Fixed Code: The Rich Domain Model
package com.example.ecommerce.domain;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
// Good: The entity protects its invariants and exposes behavior, not just data.
public class Order {
private final UUID id;
private final String customerId;
private OrderStatus status;
private BigDecimal totalAmount;
private final LocalDateTime createdAt;
private final List<OrderItem> items;
// Protected constructor for ORM frameworks like Hibernate, hiding it from business logic
protected Order() {
this.id = null;
this.customerId = null;
this.createdAt = null;
this.items = new ArrayList<>();
}
// Factory method or constructor enforces valid initial state upon creation
public Order(UUID id, String customerId) {
if (id == null || customerId == null || customerId.isBlank()) {
throw new IllegalArgumentException("Order ID and Customer ID are required");
}
this.id = id;
this.customerId = customerId;
this.status = OrderStatus.CREATED;
this.totalAmount = BigDecimal.ZERO;
this.createdAt = LocalDateTime.now();
this.items = new ArrayList<>();
}
// Domain behavior explicitly defined, manipulating internal state safely
public void addItem(Product product, int quantity) {
if (this.status != OrderStatus.CREATED) {
throw new IllegalStateException("Cannot add items to an order that is not in CREATED state");
}
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be greater than zero");
}
OrderItem item = new OrderItem(product.getId(), product.getPrice(), quantity);
this.items.add(item);
recalculateTotal();
}
public void confirm() {
if (this.status != OrderStatus.CREATED) {
throw new IllegalStateException("Only created orders can be confirmed");
}
if (this.items.isEmpty()) {
throw new IllegalStateException("Cannot confirm an order with no items");
}
this.status = OrderStatus.CONFIRMED;
}
// Internal calculation kept private
private void recalculateTotal() {
this.totalAmount = this.items.stream()
.map(OrderItem::getSubtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
// Only expose what is strictly necessary. Return unmodifiable collections to protect internal arrays.
public UUID getId() { return id; }
public OrderStatus getStatus() { return status; }
public BigDecimal getTotalAmount() { return totalAmount; }
public List<OrderItem> getItems() { return Collections.unmodifiableList(items); }
}
Key Takeaway: Push behavior down into the domain entities so that objects are responsible for protecting their own invariants and ensuring valid state transitions natively.
Mistake 2: Using DDD for Simple CRUD Applications
Another major mistake beginners make is attempting to apply Domain-Driven Design to every single project, regardless of its complexity or strategic importance to the business. DDD is a strategic approach designed specifically to tackle complex business logic and intricate domain rules. If you are building a simple contact management system, a configuration dashboard, or a basic content management system where the primary operations are merely creating, reading, updating, and deleting database records, DDD is massive over-engineering. It will slow your team down, add unnecessary boilerplate code, abstract simple concepts into complex structures, and frustrate your developers for absolutely no tangible architectural benefit.
❌ Bad Code Example: Over-engineered CRUD
package com.example.cms.domain;
import java.util.UUID;
// Bad: Applying Aggregate Roots, Value Objects, and complex factories for a simple lookup table.
public class TagAggregateRoot {
private TagId id;
private TagName name;
private AuditTrail audit;
public static TagAggregateRoot createNew(String rawName) {
return new TagAggregateRoot(new TagId(UUID.randomUUID()), new TagName(rawName), AuditTrail.createdNow());
}
private TagAggregateRoot(TagId id, TagName name, AuditTrail audit) {
this.id = id;
this.name = name;
this.audit = audit;
}
public void updateName(String newName) {
this.name = new TagName(newName);
this.audit = this.audit.markUpdated();
}
}
Why It’s Wrong: For a simple ‘Tag’ entity that likely just maps to a database table with an ID and a Name, using an Aggregate Root, custom Value Objects (TagId, TagName), domain factories, and audit objects is entirely disproportionate. There is no complex business logic being protected here. A tag’s name can change, and that’s it. The overhead of mapping this complex object to the database and back outweighs any theoretical architectural purity. You end up maintaining five distinct Java classes just to save a string to a database table, dramatically reducing team velocity and readability.
✅ Fixed Code: Embracing Simple CRUD Entities
package com.example.cms.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
// Good: A simple, straightforward JPA entity for simple data management in a generic subdomain.
@Entity
@Table(name = "tags")
public class TagEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
protected TagEntity() {}
public TagEntity(String name) {
this.name = name;
}
public Long getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}
Key Takeaway: Assess the complexity of your domain first; use simple CRUD frameworks for data-centric generic subdomains and reserve rigorous DDD patterns purely for the complex core business domain.
Mistake 3: Ignoring Ubiquitous Language
Ubiquitous Language is arguably the most critical and foundational concept in Domain-Driven Design. It dictates that developers and domain experts (product owners, business analysts, operators) must share a common vocabulary, and that exact vocabulary must be reflected directly in the source code. Beginners often revert to technical terminology—using words like “Manager,” “Processor,” “Handler,” or “Data”—instead of the actual terms used by the business. When the business talks about “Fulfilling a Shipment” but the code says DeliveryManager.updateDeliveryData(), a translation barrier is erected that breeds severe bugs and miscommunication throughout the software lifecycle.
❌ Bad Code Example: Technical Jargon
package com.example.logistics.service;
import com.example.logistics.domain.OrderData;
// Bad: "Manager", "Process", and "Data" are generic, technical terms, not real business concepts.
public class OrderManager {
public void processOrderData(OrderData data) {
if (data.getFlag() == 1) {
// Update database record directly
data.setStatus("PROC");
// Send email logic here...
}
}
}
Why It’s Wrong: The class name OrderManager tells us absolutely nothing about the actual business capability it provides. “Process” is a vague verb that could mean anything from billing the customer to physically shipping a box. “OrderData” implies a lack of behavior, treating the order merely as a row in a database table. Using integer flags like getFlag() == 1 is completely opaque. This code requires constant translation. A domain expert reading this code (or having it read to them) would have absolutely no idea what business rule is being executed or if it is correct.
✅ Fixed Code: Applying Ubiquitous Language
package com.example.logistics.domain;
// Good: Code speaks the explicit language of the business domain experts.
public class OrderFulfillmentService {
private final WarehouseRepository warehouseRepository;
private final LogisticsProvider logisticsProvider;
public OrderFulfillmentService(WarehouseRepository warehouse, LogisticsProvider logistics) {
this.warehouseRepository = warehouse;
this.logisticsProvider = logistics;
}
public void dispatchOrderForDelivery(Order order) {
if (order.isReadyForFulfillment()) {
Warehouse warehouse = warehouseRepository.findNearestTo(order.getShippingAddress());
DeliveryManifest manifest = warehouse.allocateInventory(order.getItems());
logisticsProvider.schedulePickup(manifest);
order.markAsDispatched();
}
}
}
Key Takeaway: Your class names, method names, and variables should be completely understandable to a non-technical domain expert who is reading over your shoulder.

Mistake 4: The God Aggregate (One Giant Aggregate)
An aggregate in Domain-Driven Design acts as a strict transaction boundary. It ensures that a specific cluster of domain objects is always in a consistent state when saved to the database. Beginners often mistakenly try to model the entire real-world relationship graph in a single aggregate, mirroring foreign keys in a relational database. For example, they make an ECommerceSystem aggregate that contains all Customer entities, which contain all their Order entities, which in turn contain all Product references. This “God Aggregate” leads to massive performance issues, immediate optimistic locking exceptions, and concurrent update failures since every user action attempts to lock the entire hierarchy.
❌ Bad Code Example: The God Aggregate
package com.example.store.domain;
import java.util.List;
// Bad: A massive aggregate holding direct object references to everything in the system, forcing giant transactions.
public class Customer {
private String id;
private String name;
private Address billingAddress;
// Holding all historical orders directly in the customer aggregate!
// Whenever a customer checks out, the entire history is loaded and locked.
private List<Order> orders;
// Holding all reviews this customer has ever written across the site.
private List<ProductReview> reviews;
public void addOrder(Order order) {
this.orders.add(order);
}
}
Why It’s Wrong: If a customer places a new order, the ORM has to load the entire Customer object, including their hundreds of past orders and thousands of product reviews, into memory. If two different backend threads try to update the customer’s billing address and add a new review simultaneously, they will clash over the same database lock for the Customer table, resulting in transaction rollbacks. Transaction boundaries here are far too large, making the system slow, brittle, and unable to scale concurrently.
✅ Fixed Code: Small, Focused Aggregates
package com.example.store.domain;
import java.util.UUID;
import java.util.List;
// Good: Small, focused aggregate. References other distinct aggregates purely by their ID.
public class Customer {
private UUID customerId;
private String name;
private Address billingAddress;
public Customer(UUID customerId, String name, Address billingAddress) {
this.customerId = customerId;
this.name = name;
this.billingAddress = billingAddress;
}
public void changeBillingAddress(Address newAddress) {
this.billingAddress = newAddress;
}
}
// Good: Order is its own completely separate Aggregate Root.
public class Order {
private UUID orderId;
// We reference the Customer by ID, not by direct memory object reference.
private UUID customerId;
// Only objects that strictly change together in the same business transaction are kept inside the boundary.
private List<OrderItem> lineItems;
public Order(UUID orderId, UUID customerId) {
this.orderId = orderId;
this.customerId = customerId;
}
}
Key Takeaway: Design small aggregates and reference other aggregate roots strictly by their unique ID, never by direct object reference, to ensure fast, scalable, and lock-free transactions.
Mistake 5: Exposing Domain Objects in the REST API
When writing REST controllers or API endpoints, it is highly tempting to simply return your domain entities directly to the client. Modern web frameworks like Spring Boot make this incredibly easy by automatically serializing any object returned from a controller to JSON. However, this is a dangerous architectural mistake. By exposing your domain models directly to the web layer, you tightly couple your internal business rules, structure, and database mappings to your external public API contract.

❌ Bad Code Example: Direct Exposure
package com.example.api.controller;
import com.example.ecommerce.domain.Order;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
// Bad: The domain entity is directly serialized and sent to the client, leaking domain secrets.
@RestController
public class OrderController {
private final OrderRepository repository;
public OrderController(OrderRepository repository) {
this.repository = repository;
}
@GetMapping("/api/orders/{id}")
public Order getOrder(@PathVariable UUID id) {
// Danger: Any internal changes to the Order class will instantly break mobile and web clients!
// Danger: Internal fields like profit margins or admin notes might be accidentally serialized to the public!
return repository.findById(id).orElseThrow();
}
}
Why It’s Wrong: If the business decides to rename a field inside the Order class to better match evolving ubiquitous language, or change an internal data structure for performance, your public JSON response changes immediately. This breaks all mobile apps, web frontends, and third-party partner integrations relying on that specific JSON schema. Additionally, you might accidentally expose highly sensitive internal data—like profit margins, supplier costs, or internal status codes—that should remain hidden securely within the backend.
✅ Fixed Code: Using Dedicated DTOs
package com.example.api.controller;
import com.example.ecommerce.domain.Order;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
// Good: A dedicated Data Transfer Object (DTO) explicitly isolates the API contract from the evolving domain model.
record OrderResponseDTO(String id, String total, String friendlyStatus) {}
@RestController
public class OrderController {
private final OrderRepository repository;
public OrderController(OrderRepository repository) {
this.repository = repository;
}
@GetMapping("/api/orders/{id}")
public OrderResponseDTO getOrder(@PathVariable UUID id) {
Order order = repository.findById(id).orElseThrow();
// Map the rich domain object to a stable, versioned external contract
return new OrderResponseDTO(
order.getId().toString(),
order.getTotalAmount().toPlainString(),
order.getStatus().name()
);
}
}
Key Takeaway: Always create Data Transfer Objects (DTOs) for your API responses to decouple your public contracts from your internal domain evolution. Read more about Clean Architecture principles for strict separation of concerns.
Mistake 6: Creating a Repository for Every Single Entity
In standard Spring Data JPA tutorials, the typical pattern heavily pushed on developers is to create a repository interface for every single entity class in the system. While acceptable in simplistic CRUD apps, doing this in a DDD architecture fundamentally breaks the concept of the Aggregate Root. An Aggregate is meant to be the absolute single entry point for retrieving, manipulating, and saving a graph of objects. If you bypass the Aggregate Root and save its internal children directly using their own repositories, you bypass all the business rules protecting the aggregate’s structural integrity.
❌ Bad Code Example: Repositories Everywhere
package com.example.ecommerce.infrastructure;
import com.example.ecommerce.domain.Order;
import com.example.ecommerce.domain.OrderItem;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
// Bad context: An Aggregate Root repository. This part is fine.
public interface OrderRepository extends JpaRepository<Order, UUID> {}
// VERY BAD: A dedicated repository for an inner entity of the Order aggregate.
// Someone can now fetch an OrderItem and modify it completely without the Order ever knowing!
public interface OrderItemRepository extends JpaRepository<OrderItem, UUID> {}
Why It’s Wrong: If a junior developer uses the OrderItemRepository to independently change the price or quantity of a specific item and saves it, the parent Order aggregate is entirely unaware of this transaction. The order’s totalAmount field will now be completely out of sync with its line items. The invariants that the Aggregate Root is supposed to guarantee (e.g., “The order total must equal the sum of item subtotals”) are bypassed entirely, resulting in corrupted data in the database.
✅ Fixed Code: Only Aggregate Roots Get Repositories
package com.example.ecommerce.domain;
import java.util.Optional;
import java.util.UUID;
// Good: Only the identified Aggregate Root (Order) is allowed a repository interface.
public interface OrderRepository {
Optional<Order> findById(UUID id);
void save(Order order);
// Note: No OrderItemRepository exists anywhere in the entire codebase.
// To update an item, you must load the parent Order, call order.updateItemQuantity(...), and save the Order.
}
Key Takeaway: Repositories should strictly be defined on a one-to-one basis with Aggregate Roots, never for internal child entities or value objects, enforcing the aggregate boundary rules.
Mistake 7: Thinking DDD is Just a Folder Structure
Many beginners fall into the trap of thinking that by simply creating packages named domain, application, infrastructure, and presentation, they are automatically “doing DDD.” This is superficial mapping. Domain-Driven Design is fundamentally about behavior, deep models, mapping real-world complexities, and ubiquitous language, not just folder organization. Having an anemic domain model spread across nicely named architectural folders is still fundamentally a poor, procedural design.
❌ Bad Code Example: Folder Structure without Contexts
// BAD PACKAGE STRUCTURE: Organized by architectural layers, but mixing multiple distinct business domains together.
package com.example.app;
// ... Inside src/main/java/com/example/app/ ...
// ├── domain/
// │ ├── Order.java (Anemic)
// │ ├── Customer.java (Anemic)
// │ └── Product.java (Anemic)
// ├── application/
// │ ├── OrderService.java (Massive procedural script that touches Customer and Product directly)
// │ └── CustomerService.java
// └── infrastructure/
// └── AllRepositoriesMixedTogether.java
public class BadStructureVisualized {}
Why It’s Wrong: While the tactical folder names of Hexagonal Architecture are present, the architecture fails to separate business capabilities into Bounded Contexts. Because everything shares the same ‘domain’ folder, the OrderService will inevitably reach into the Customer entity directly, creating massive spiderwebs of dependencies. It promotes a monolithic, highly coupled mindset that is merely masked by impressive-sounding architectural buzzwords.
✅ Fixed Code: Packaging by Bounded Context
// GOOD PACKAGE STRUCTURE: Organized primarily by Bounded Contexts (business capabilities), then by layers.
package com.example.app;
// ... Inside src/main/java/com/example/app/ ...
// ├── sales/ (Bounded Context: Sales)
// │ ├── domain/
// │ │ ├── Order.java (Rich Aggregate)
// │ │ └── OrderRepository.java
// │ └── application/
// │ └── CheckoutProcess.java
// ├── billing/ (Bounded Context: Billing)
// │ ├── domain/
// │ │ ├── Invoice.java (Rich Aggregate)
// │ │ └── PaymentGateway.java
// │ └── application/
// │ └── BillingService.java
// └── infrastructure/ (Shared Tech / DB configurations)
public class GoodStructureVisualized {}
Key Takeaway: Organize your code primarily by business capabilities (Bounded Contexts) first to ensure isolation, and only then apply layered or hexagonal architecture patterns within those distinct contexts.
Mistake 8: Skipping the Domain Conversation (Coding First)
Engineers love to write code. When presented with a new feature request or problem, our first instinct is to open our IDE, create database tables, define relationships, and start modeling Java classes. In DDD, skipping the collaborative discovery phase is absolutely disastrous. If you build the model based purely on your assumptions of how the business works, you will inevitably build the wrong model.
❌ Bad Code Example: Code First Assumptions
// Bad Approach: A developer assumes an Order always has exactly one Payment and models it strictly.
public class Order {
private Payment payment; // Hardcoded 1:1 relationship based on a developer's guess.
public void setPayment(Payment p) { this.payment = p; }
}
Why It’s Wrong: A model designed in isolation by software engineers usually resembles database normalization rules rather than actual business workflows. When the business reveals that corporate orders are paid via multiple invoices, or that orders can be shipped before payment clears, the entire hardcoded model crumbles. This leads to massive friction whenever new business requirements emerge, as the code doesn’t map to the reality of the business process.
✅ Fixed Approach: Collaborative Modeling First
// Good Approach: After an Event Storming session with accounting and sales experts,
// the team realizes Orders and Payments have complex lifecycles and models them as separate aggregates.
public class Order {
private PaymentStatus paymentStatus; // E.g., PENDING, PARTIALLY_PAID, CLEARED
// Domain rule discovered during collaboration: Orders dispatch when status is cleared.
public boolean readyToDispatch() {
return this.paymentStatus == PaymentStatus.CLEARED;
}
}
Key Takeaway: Software development in DDD is a collaborative learning process; conduct Event Storming sessions with real domain experts before writing code. The code is merely an artifact of those conversations.
Mistake 9: Tight Coupling Between Bounded Contexts
As you successfully break down a large system into smaller Bounded Contexts (whether in a modular monolith or as microservices), a common catastrophic mistake is to let one context directly access the database or make synchronous REST API calls to another context during critical user transactions. This creates a distributed monolith, where the failure or slowness of one context brings down the entire system.
❌ Bad Code Example: Direct Database or Synchronous Coupling
package com.example.sales.application;
import com.example.sales.domain.OrderRepository;
import com.example.sales.domain.Order;
import com.example.sales.domain.OrderItem;
import java.util.UUID;
// Bad: The Sales context is directly injecting and using a repository from the Inventory context.
public class OrderCheckoutService {
private final OrderRepository orderRepo;
// CROSSING BOUNDARIES! Sales should not know about Inventory's database!
private final com.example.inventory.infrastructure.InventoryRepository inventoryRepo;
public OrderCheckoutService(OrderRepository orderRepo, com.example.inventory.infrastructure.InventoryRepository inventoryRepo) {
this.orderRepo = orderRepo;
this.inventoryRepo = inventoryRepo;
}
public void checkout(UUID orderId) {
Order order = orderRepo.findById(orderId).orElseThrow();
order.confirm();
// Tight coupling: If the inventory DB is slow, locking, or offline, the sales checkout completely fails.
// We are losing sales because a downstream system is having issues.
for(OrderItem item : order.getItems()) {
inventoryRepo.decrementStock(item.getProductId(), item.getQuantity());
}
orderRepo.save(order);
}
}
Why It’s Wrong: This explicitly violates the autonomy of Bounded Contexts. The Sales domain is now intricately tied to the data schema, transaction locks, and uptime of the Inventory domain. Transaction boundaries are blurred across massive logical divides, meaning a small bug in inventory tracking takes down the company’s ability to accept money.
✅ Fixed Code: Event-Driven Integration
package com.example.sales.application;
import com.example.sales.domain.OrderRepository;
import com.example.sales.domain.Order;
import com.example.sales.domain.OrderConfirmedEvent;
import org.springframework.context.ApplicationEventPublisher;
import java.util.UUID;
// Good: Bounded contexts integrate securely via asynchronous Domain Events.
public class OrderCheckoutService {
private final OrderRepository orderRepo;
private final ApplicationEventPublisher eventPublisher;
public OrderCheckoutService(OrderRepository orderRepo, ApplicationEventPublisher eventPublisher) {
this.orderRepo = orderRepo;
this.eventPublisher = eventPublisher;
}
public void checkout(UUID orderId) {
Order order = orderRepo.findById(orderId).orElseThrow();
order.confirm();
orderRepo.save(order);
// Decoupled: Sales just announces that an order was placed to the rest of the system.
// The Inventory context will listen for this event asynchronously (e.g., via Kafka or Spring Events)
// and update its own stock independently. Checkout succeeds even if inventory is temporarily down.
eventPublisher.publishEvent(new OrderConfirmedEvent(order.getId(), order.getItems()));
}
}
Key Takeaway: Bounded contexts must remain autonomous; integrate them using asynchronous domain events rather than direct data access or blocking synchronous RPC calls.

Mistake 10: Not Testing Domain Logic in Isolation
Domain logic is the heart and soul of your application. It contains the strict rules that make your business money and protect it from liability. If you have to boot up a full Spring Application Context, connect to a PostgreSQL database via Testcontainers, and spin up a web server just to test if an order can be canceled or if a discount applies, your domain is hopelessly coupled to infrastructure and framework code.
❌ Bad Code Example: Testing with Frameworks
package com.example.ecommerce.domain;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
// Bad: Loading the entire Spring framework just to test a simple domain rule. Slow and extremely fragile.
@SpringBootTest
class OrderDomainTest {
@Autowired
private OrderRepository repository;
@Test
void cannotCancelShippedOrder() {
// Testing domain logic by pushing data through the database
Order order = new Order(UUID.randomUUID(), "CUST-1");
// Simulating state via database save
order.setStatus("SHIPPED");
repository.save(order);
// Re-fetching from database
Order savedOrder = repository.findById(order.getId()).orElseThrow();
assertThrows(IllegalStateException.class, () -> {
savedOrder.cancel(); // This is the single domain rule we actually care about testing!
});
}
}
Why It’s Wrong: These integration tests take seconds (or minutes) to run instead of milliseconds. When a test suite takes 15 minutes to run, developers stop running them locally. Furthermore, the test is testing the ORM mappings, the database connection, and the domain rule simultaneously. If the test fails, you don’t know if the domain logic is wrong, or if a database constraint was violated, leading to high maintenance costs and flaky test suites.
✅ Fixed Code: Pure Unit Testing
package com.example.ecommerce.domain;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
// Good: The domain model is a POJO (Plain Old Java Object). Tests run in less than a millisecond.
class OrderTest {
@Test
void cannotCancelShippedOrder() {
// Arrange: Pure Java memory setup without any framework dependencies
Order order = new Order(UUID.randomUUID(), "CUST-1");
// Assuming we have a rich domain method to transition state for testing or proper domain setup
order.markAsShipped();
// Act & Assert: Testing pure Java memory state isolation
IllegalStateException exception = assertThrows(IllegalStateException.class, () -> {
order.cancel();
});
assertEquals("Order has already shipped and cannot be canceled", exception.getMessage());
}
}
Key Takeaway: Your core domain model should have zero dependencies on frameworks or libraries (like Spring or JPA annotations where avoidable), allowing you to verify complex business rules with blazingly fast, isolated unit tests.
Summary: The Ultimate DDD Beginner Checklist (Best Practices)
To ensure you are consistently on the right track with Domain-Driven Design in your Java applications, refer back to this comprehensive checklist before finalizing your architectural decisions:
- Are your entities protecting their own state? (No public setters, all validation logic resides inside the entities).
- Is DDD actually necessary for this module? (Avoid DDD for simple CRUD dashboards or generic domains).
- Does the code read like business documentation? (Use Ubiquitous Language, actively avoid technical jargon).
- Are your Aggregates small, focused, and performant? (Reference by ID, avoid massive cascading object graphs).
- Are you mapping to DTOs for all external APIs? (Never leak internal domain models to the presentation layer).
- Do only Aggregate Roots have repositories? (Hide child entities from direct database access to enforce invariants).
- Is your project structured primarily by business capability? (Package by Bounded Context first, not just technical layers).
- Did you talk to real domain experts before writing code? (Software models must follow business reality, not engineering assumptions).
- Are your Bounded Contexts heavily decoupled? (Use asynchronous domain events, avoid shared databases or blocking calls).
- Can you test your core domain logic without starting Spring Boot? (Use pure POJO unit testing for core business rules).
Frequently Asked Questions (FAQ)
1. What is absolutely the most common mistake developers make when first learning DDD?
The Anemic Domain Model is universally the most common pitfall. Developers coming from heavily data-centric backgrounds naturally create entities that are just database row representations (getters and setters only), pushing all vital business logic into bloated, procedural service classes. This utterly defeats the object-oriented nature of DDD and leads to scattered logic.
2. How do I actually know if my project is complex enough to require DDD?
A great rule of thumb is to look deeply at the business rules. If your application primarily takes data from a web form and saves it directly to a database table (CRUD), DDD is massive overkill. However, if state transitions require checking complex conditions, validating against multiple historical rules, integrating with external enterprise systems, and protecting strict invariants (like a banking ledger transaction or logistics routing), DDD will shine and pay for itself over time.
3. Should every single class in my application be part of the Ubiquitous Language?
No, not necessarily. Ubiquitous Language primarily applies to the Core Domain Layer (Entities, Value Objects, Domain Events, and Domain Services). Infrastructure classes (like PostgresOrderRepositoryImpl or S3FileUploader) and Presentation classes (like OrderRestController) will naturally contain technical terminology because their specific domain is technology, not business.
4. How do I prevent a God Aggregate from forming in my database?
Focus obsessively on transaction boundaries. Ask yourself: “Does changing entity A *strictly require* entity B to be updated in the exact same database transaction for the business to remain legally or practically consistent?” If the answer is no, they belong in separate aggregates. Use string or UUID IDs to reference other aggregates, and rely on eventual consistency via Domain Events for cascading updates.
5. Can I realistically use frameworks like JPA/Hibernate with a Rich Domain Model?
Yes, though it requires some intentional compromises. JPA technically requires a no-argument constructor (which you can safely make protected to hide it from your domain code). You might also need to use field access (annotating private fields instead of getters) to allow the ORM to hydrate the object directly from the database without relying on public setters. It takes careful design, but they can and do coexist effectively in large enterprises.
Check out the companion code on GitHub to see all these exact fixes implemented in a working Spring Boot 4.0.0 environment.
