10 Common DDD Mistakes and How to Avoid Them
This article reviews ten frequent misuses of Domain‑Driven Design—ranging from over‑engineering simple CRUD apps to misusing layers, aggregates, services, repositories, and events—provides concrete counter‑examples, explains why they happen, and offers practical corrective guidelines plus a self‑check checklist.
This piece concludes a series that covered DDD fundamentals, tactical and strategic concepts, and shares the ten most common pitfalls the author has observed when teams try to apply DDD.
Anti-Pattern 1: Using DDD for DDD’s Sake, Applying It to Any Project
Example: a simple data‑dictionary backend with five tables was split into dozens of packages (domain, application, infrastructure) and dozens of classes, inflating code size four‑fold.
public interface DictRepository extends Repository<Dict, DictId> { ... }
public class DictRepositoryImpl implements DictRepository { ... }
@Service public class DictAppService { ... }
public class DictDomainService { ... }
public class DictFactory { ... }
// The project actually only needs five functional classes.Correct approach: assess business complexity first; use traditional MVC for simple CRUD, reserve DDD for domains with complex rules, evolving models, and multi‑person collaboration.
Anti-Pattern 2: Equating DDD with Layered Architecture and Assuming Completion After Package Creation
Teams often create four packages (interfaces, application, domain, infrastructure) without defining dependencies, business logic placement, or aggregate boundaries. The result is a renamed three‑layer architecture with domain classes reduced to PO/DAO and business logic scattered in services.
Correct approach: focus on the domain model and boundaries; only after clear bounded contexts and well‑designed aggregates does layering make sense.
Anti-Pattern 3: PO/DTO Leaking into the Domain Layer, Turning Domain Objects into Data Containers
Example of a "PaySlip" class annotated with @Data that only holds fields and no behavior, with services converting PO to domain objects and back.
@Data
public class PaySlip {
private String id;
private String employeeId;
private BigDecimal grossAmount;
private BigDecimal taxAmount;
private BigDecimal netAmount;
private String status;
// No business methods; all logic lives in services.
}
@Service
public class SalaryCalculationService {
public void calculate(String employeeId, String cycle) {
PaySlipPO po = dao.findByEmployeeId(employeeId);
PaySlip p = convert(po); // PO → domain object, domain object is just fields
p.setNetAmount(p.getGrossAmount().subtract(p.getTaxAmount()));
dao.save(convert(p)); // back to PO
}
}Correct approach: aggregates must encapsulate their own behavior; avoid exposing setters, and keep PO/DTO in the infrastructure or interface layers.
Anti-Pattern 4: Aggregates Too Large or Too Small, Boundaries Decided Arbitrarily
Oversized aggregate example: Employee containing all salary slips, attendance records, performance evaluations, and contract histories, causing large loads and frequent concurrency conflicts.
Undersized aggregate example: splitting PaySlip and PaySlipLineItem into separate aggregates, forcing eventual consistency for operations that should be strongly consistent.
Correct approach: define aggregate boundaries based on transactional invariants—if a set of objects must stay strongly consistent, they belong to the same aggregate; otherwise, separate them.
Anti-Pattern 5: Domain Service Becomes a ‘Garbage Bin’, Dumping All Logic Inside
@Service
public class PaySlipDomainService {
public void addLineItem(PaySlip p, LineItemType t, Money m) {
// Should be a method on the aggregate root.
if (p.getStatus() != PaySlipStatus.PENDING) {
throw new DomainException("...");
}
p.getLineItems().add(new PaySlipLineItem(t, m));
recalculateTotal(p);
}
public void approve(PaySlip p) { ... }
public void calculateTax(PaySlip p) { ... }
// Dozens of methods, leaving the aggregate empty.
}Correct approach: place business methods on the aggregate root whenever they affect only that aggregate; reserve Domain Services for truly cross‑aggregate coordination or stateless algorithms.
Anti-Pattern 6: Business Rules Written in Application Service, All if/else in This Layer
@Service
public class PaySlipAppService {
@Transactional
public void approve(PaySlipId id) {
PaySlip p = repo.findById(id).orElseThrow();
// Business rule leaked into application layer
if (p.getStatus() != PaySlipStatus.CALCULATED) {
throw new IllegalStateException("Only calculated slips can be approved");
}
if (p.getNetAmount().isNegative()) {
throw new IllegalStateException("Net amount cannot be negative");
}
p.setStatus(APPROVED);
repo.save(p);
}
}Correct approach: move all validation and state‑change logic into the aggregate root (e.g., paySlip.approve()), leaving the Application Service to orchestrate calls, handle parameter checks, and enforce security.
Anti-Pattern 7: Repository Written as DAO Clone with 17 findBy Methods
Example: PaySlipRepository exposing numerous findByEmployeeIdAndStatusAndCycleGreaterThan methods, mixing PO and Map returns, and even update/delete operations—blurring the line between repository and DAO.
Correct approach: repository interfaces should expose only aggregate‑level operations such as findById, findByBusinessKey, and save, using value objects as parameters and returning full aggregates. Complex queries belong to a separate QueryService that returns DTOs.
Anti-Pattern 8: Overusing Domain Events, Emitting an Event for Every Action
Teams emit multiple events for a single user action (e.g., PaySlipCreated, PaySlipCreatedForApproval, PaySlipApprovalStarted, etc.) and even use events for internal calculations, making the code hard to trace.
Correct approach:
Use events only for cross‑bounded‑context eventual consistency.
Invoke methods directly for intra‑aggregate strong‑consistency logic.
Name events in past tense with clear business meaning.
Avoid events for trivial actions like updating a remark.
Anti-Pattern 9: Jumping Straight to CQRS + Event Sourcing Full Stack
Some teams adopt the entire CQRS, event sourcing, message‑driven architecture from day one, building command services, query services, event stores, MQ, Elasticsearch, Redis, and materialized views before any business feature is delivered.
Correct approach: evolve incrementally—start with a clean domain model and simple L1 CQRS (interface segregation); add read replicas, Elasticsearch, or event‑driven synchronization only when performance or consistency demands arise.
Anti-Pattern 10: Pursuing Perfect Purity, Refusing Any Compromise
In real projects, compromises are inevitable: batch scripts processing thousands of payslips, legacy systems requiring adapters, or rules that don’t fit any aggregate and must temporarily reside in a Domain Service.
Correct approach: keep the core domain model pure, but allow pragmatic shortcuts for edge cases, scripts, and non‑core queries. DDD is a tool for controlling complexity, not a magic that eliminates it.
Implementation Self-Check Checklist
Does this business domain really need DDD? If it’s simple CRUD, don’t force it.
Are bounded contexts clearly defined? Each BC should have its own model and ubiquitous language.
Do aggregate roots enforce their boundaries? External code must modify state only via business methods; internal collections should not expose mutable references.
Where are the business rules? Place them in aggregates; use Domain Services only for cross‑aggregate logic; avoid business if/else in Application Services.
Is the layering sound? Domain layer must be free of Spring, JPA, or HTTP dependencies; interfaces live in domain, implementations in infrastructure.
Is the Repository a true Repository? It should operate on aggregates, not tables, and avoid a proliferation of findBy methods.
Are commands and queries separated? Commands operate on aggregates; queries return DTOs via a QueryService.
Are events used correctly? Use them for cross‑BC eventual consistency; internal logic should call methods directly.
How is cross‑BC consistency handled? Use Saga orchestration or compensation, not forced 2PC.
Is the implementation incremental? Do not stack the entire CQRS/ES stack at the start; upgrade as needed.
Is the core domain clean while edges can compromise? Scripts, batch jobs, and non‑core queries can be pragmatic.
Does the team truly understand DDD? Code reviews should discuss models and boundaries, not just package placement.
Conclusion
The series aims to strip away the mysticism surrounding DDD in the Chinese community, offering concrete, pragmatic guidance rather than dogmatic rules or outright dismissal.
DDD is not a lofty architecture nor a religion; it is a set of tools to manage complexity in business‑critical domains. When applied thoughtfully—defining clear boundaries, keeping business logic in the model, and allowing pragmatic compromises—you’ll find it far more valuable than blindly following a four‑layer, ten‑class template.
Start with a complex core domain, define bounded contexts, build aggregates, and move business rules back into the model. That hands‑on experience beats reading ten articles.
When in doubt, revisit this series; the thinking process it promotes is universally applicable.
Keep tinkering, keep improving.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Tinker Programmer
Solving problems with code, sharing practical tech insights, and leveling up together!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
