Domain-Driven Design Is Just Renaming Things You Already Have
After 47 years of building systems that still somehow run in production, Iβve watched every architectural trend wash ashore like driftwood. MVC. SOA. Microservices. Reactive. And now the undisputed king of them all: Domain-Driven Design.
DDD is special. It takes your perfectly functional UserService.java β the one thatβs been running in production since 2011 β and replaces it with an UserAggregateRootDomainEntityCommandHandlerFacadeImpl.java that does the exact same thing, requires four new Maven modules, and needs a 90-minute onboarding session before any junior engineer can touch it.
This is called architectural maturity. Iβve been recommending it for years.
What DDD Actually Is
Eric Evans wrote the βblue bookβ in 2003. Itβs 560 pages. No one has finished it. I own three copies. Iβve read the table of contents on two of them.
The core idea: software should model the business domain using a Ubiquitous Language shared between developers and domain experts.
In practice: six-hour meetings where nobody agrees on what a βCustomerβ is.
// Before DDD: works, readable, gets the job done
public class UserService {
public User getById(long id) {
return repo.findById(id);
}
public void deactivate(long id) {
User user = repo.findById(id);
user.setActive(false);
repo.save(user);
}
}
// After DDD: identical logic, costs 3 sprints to explain
public class UserAggregateRootCommandHandlerFacadeImpl
implements AggregateRootCommandHandler<
UserAggregateRoot,
UserIdentityValueObject,
UserDeactivationCommandSpecification> {
@Override
public UserAggregateRoot handle(
UserIdentityValueObject userId,
UserDeactivationCommandSpecification command,
UserBoundedContextExecutionContext ctx) {
UserAggregateRoot aggregate = userAggregateRootRepository
.findByIdentity(userId)
.orElseThrow(() -> new UserAggregateRootNotFoundInBoundedContextDomainException(
"No UserAggregateRoot found for identity " + userId.getValue()
+ " in bounded context: " + ctx.getBoundedContextName()
));
aggregate.applyDeactivationCommand(command);
userAggregateRootRepository.persist(aggregate);
return aggregate;
}
}
The second version has 28 lines and does user.setActive(false). The extra 26 lines are the architecture. Thatβs where the value lives.
Trust me.
The Ubiquitous Language: Everyone Uses the Same Words Differently
The Ubiquitous Language guarantees that developers and business people use identical terminology. One dictionary. No ambiguity. Pure clarity.
Hereβs your terminology six months after DDD adoption:
| Business Team | Dev Team | DBA | API Docs | Mobile Team |
|---|---|---|---|---|
| Customer | UserAggregateRoot | tbl_user_master |
account_holder |
person_entity |
| Order | PurchaseCommandEvent | tbl_order_hdr |
transaction_record |
cart_submission |
| Cancel | DeactivationDomainCommand | is_active = 0 |
archive_entity |
delete_action |
| βThe systemβ | ProductionEnvironment | PROD schema |
backend_service |
βthe APIβ |
Ubiquitous Language achieved. Everyone is confused in precisely the same way.
βDogbert, what exactly is a Ubiquitous Language?β βItβs the language everyone uses but nobody understands. Like legal documents, but with more interfaces.β β Dogbert, consulting at $400/hr, Dilbert
Bounded Contexts: Formally Agreeing to Disagree
A Bounded Context is an area where your model makes sense. Outside that context, the same words mean something else. This is fine and normal and definitely scalable.
Before DDD:
-- One users table. Everyone knows what it is.
SELECT * FROM users WHERE id = 42;
After DDD, row 42 in the users table is known as:
UserAggregateRootβ UserManagement Bounded ContextSecurityPrincipalβ Authentication Bounded ContextAccountHolderβ Billing Bounded ContextRecipientIdentityβ Notification Bounded ContextUserViewβ Reporting Bounded Contexttbl_user_masterβ Legacy Bounded Context (canβt touch it)UserEntityβ New Rewrite Bounded Context (someone started this in January)
All seven are kept in sync via events that fail silently on Kafka every other Tuesday.
XKCD 927 nailed it years ago: whenever there are N competing standards, someone invents a new one to unify them all. DDD is the unified standard for modeling your users table. We now have N+1.
Aggregates: Database Constraints, But Done in Java at Runtime
The relational database has enforced referential integrity since before I had hair. Primary keys. Foreign keys. Check constraints. Transactions. All free.
DDD says: what if we rebuilt that manually? In business logic? During sprint 14?
// SQL: enforces this for free, forever, atomically
CREATE TABLE order_items (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INT NOT NULL CHECK (quantity > 0)
);
// DDD Aggregate: you enforce it yourself now. Good luck.
public class OrderAggregateRoot extends AbstractAggregateRoot<OrderId> {
private final List<OrderLineItemEntity> lineItems = new ArrayList<>();
private OrderStatusValueObject status;
public void addLineItem(ProductIdValueObject productId, QuantityValueObject qty) {
// Validation the database would do automatically, for free
if (qty.isNegative()) {
throw new InvalidQuantityDomainException("Really?");
}
// Business rule formerly enforced by a transaction
if (this.status.equals(OrderStatusValueObject.COMPLETED)) {
throw new CannotModifyCompletedOrderDomainException(
"Cannot add items to a completed order. " +
"See business rule BIZ-2847 in the 2019 spec doc nobody can find."
);
}
this.lineItems.add(new OrderLineItemEntity(productId, qty));
// Register domain event (fires if we remember to handle it)
this.registerEvent(
new OrderLineItemAddedDomainEvent(this.id, productId, qty, LocalDateTime.now())
);
}
}
Everything the database handled automatically is now your problem, in Java, during business hours, in code that will have bugs.
Value Objects: Wrapping Primitives in Career Security
Why store an email as a String when you can:
public final class EmailAddressValueObject {
private final String value;
private EmailAddressValueObject(String value) {
Objects.requireNonNull(value);
if (!value.contains("@")) {
throw new InvalidEmailAddressValueObjectDomainException(
"'" + value + "' is not a valid EmailAddressValueObject " +
"within the UserManagement BoundedContext"
);
}
this.value = value.toLowerCase().trim();
}
public static EmailAddressValueObject of(String raw) {
return new EmailAddressValueObject(raw);
}
// getValue(), equals(), hashCode(), toString()...
// ~60 more lines
public String getValue() {
return this.value; // It's the String. It was always the String.
}
}
// Before:
String email = "user@example.com"; // 1 line
// After:
EmailAddressValueObject email = EmailAddressValueObject.of("user@example.com");
// 1 line, plus 80 lines of infrastructure, plus a new module, plus a mapper
Type safety. Domain expressiveness. Immutability. Carpal tunnel from typing ValueObject after every class name. The full DDD experience.
The Hexagonal Architecture Tax
You canβt just do DDD. You need Hexagonal Architecture too. Also called Ports & Adapters. Also called Onion Architecture. Also called Clean Architecture. Also called βWhy Are There 14 Layers For A CRUD App?β
Before DDD:
src/
βββ controllers/ β HTTP hits here
βββ services/ β logic lives here
βββ repositories/ β SQL happens here
After DDD + Hexagonal Architecture:
src/
βββ domain/
β βββ model/
β β βββ aggregates/
β β βββ entities/
β β βββ valueobjects/
β β βββ shared/kernel/
β βββ events/
β β βββ domain/
β β βββ integration/
β βββ commands/
β βββ queries/
β βββ ports/
β βββ inbound/
β βββ outbound/
βββ application/
β βββ usecases/
β βββ commandhandlers/
β βββ queryhandlers/
β βββ eventhandlers/
βββ infrastructure/
βββ adapters/
β βββ inbound/
β β βββ rest/
β β βββ messaging/
β βββ outbound/
β βββ persistence/
β β βββ jpa/
β β βββ mappers/
β βββ messaging/
βββ configuration/
The application does exactly what it did before. A new engineer now needs three months to find where email validation happens.
Is it worth the time? According to my calculations: if your app serves 200 internal users, will be rewritten in 18 months anyway, and has 3 engineers β no. According to DDD consultants: always.
How To Sell DDD to Your Team
- Say βalign with the business domainβ in every meeting
- Draw boxes connected by arrows on a whiteboard
- Write βBounded Contextβ in each box
- Call the arrows βanti-corruption layersβ
- Order the blue book (display prominently; do not read)
- Rename every class to end in
AggregateRoot,ValueObject, orDomainEvent - Create a
#domain-modelingSlack channel - Run weekly 3-hour βalignment sessionsβ
- After 6 months: announce youβve βidentified the bounded contextsβ
- After 12 months: begin questioning whether you chose the right bounded contexts
- After 18 months: start the greenfield rewrite using the correct bounded contexts
Nothing in production has changed. The architecture diagram looks spectacular.
βWally, what did DDD accomplish this quarter?β βWe renamed the UserService to the UserAggregateRootDomainCommandHandlerFacade.β βAnd what does it do differently?β βIt has a longer name.β β Wally explains the Q3 roadmap to the PHB
Career Implications
Add these to your LinkedIn immediately: Domain-Driven Design, Bounded Contexts, Aggregate Roots, Value Objects, Domain Events, CQRS, Event Sourcing, Hexagonal Architecture, Ports & Adapters, Anti-Corruption Layer.
You are now a Domain Architect. Rate: 3x current. The code doesnβt matter. The vocabulary does.
The PHB once asked me what weβd achieved after a year-long DDD refactor. I showed him the architecture diagram. He printed it and put it in the quarterly board presentation. Budget approved for Year 2.
The author has been modeling the βUser Domainβ since 1994. Current UserAggregateRoot has 847 methods. The original UserService had 12. Both execute SELECT * FROM users WHERE id = ?.