🌏 閱讀中文版本
Quick Definition: What Is DDD?
Before diving into refactoring steps, let’s clear up common misconceptions.
❌ DDD is NOT: – A complex theory that requires weeks of study – A pattern that requires adding 50% more code – Something that must include Event Sourcing, CQRS, or other advanced concepts – A from-scratch architecture (requiring a complete rewrite)
✅ DDD actually IS: – A code organization approach: moving business logic from the Service layer down to the Entity layer – Core principle: letting Entities self-validate and self-decide, rather than being mere data containers – Result: Service layers become thin, code becomes testable, modules decouple naturally
Real example:
Before:
Item Entity → Just 23 lines, all getters/setters, zero business logic
ItemService → 373 lines, all validation: lead time checks, status transitions, supplier validation
After DDD:
Item Entity → 150+ lines, includes addSupplier(), updateStatus() business methods
ItemService → 150 lines, only coordinates; validation lives in Item class
Result → Total code: 396 → 300 lines (↓24%), and it's much clearer
Why Your Code Becomes Messy
Don’t start with theory. First, examine your current pain.
The Root Cause Tree
The symptoms are bloated Service layers and tight coupling, but what’s the underlying cause?
【Symptom】Service layer code bloat (300+ lines)
↓
【Root Cause A】Business validations scattered across the Service layer
│
├─ Example: "Lead time validation" lives in ItemService.addSupplier()
├─ Cost: Modifying validation logic easily misses other places
├─ Consequence: Same rule appears in Service, Controller, and Validator—versions diverge
│
└─ Code looks like:
@Service
public class ItemService {
public void addSupplier(Long itemId, SupplierDTO dto) {
if (dto.getLeadTime() > 180) { // ❌ Validation in Service
throw new Exception("Lead time exceeds limit");
}
itemSupplierRepository.save(...);
}
}
【Root Cause B】Crossing aggregate boundaries to directly manipulate multiple Repositories
│
├─ Example: ItemService uses ItemSupplierRepository, ItemSpecRepository, ItemUnitRepository simultaneously
├─ Cost: New devs don't understand "what things should save together atomically"
├─ Consequence: Can't write effective unit tests (logic is scattered)
│
└─ Code looks like:
@Service
@RequiredArgsConstructor
public class ItemService {
private final ItemRepository itemRepository;
private final ItemSupplierRepository itemSupplierRepository; // ❌ Multiple Repos
private final ItemSpecRepository itemSpecRepository; // ❌ Multiple Repos
private final ItemUnitRepository itemUnitRepository; // ❌ Multiple Repos
public void createItem(CreateItemRequest req) {
Item item = itemRepository.save(...);
req.getSuppliers().forEach(s -> itemSupplierRepository.save(...));
req.getSpecs().forEach(s -> itemSpecRepository.save(...));
// Problem: if exception occurs mid-way, only partial data saved → inconsistency
}
}
【Root Cause C】Tight coupling between modules
│
├─ Example: ItemService directly calls inventoryService.initializeLots() and procurementService.createItem()
├─ Cost: One requirement change touches 5 modules; test complexity explodes
├─ Consequence: High deployment risk; every release requires full regression testing
│
└─ Code looks like:
@Service
public class ItemService {
private final InventoryService inventoryService;
private final ProcurementService procurementService;
public void createItem(CreateItemRequest req) {
Item item = itemRepository.save(...);
inventoryService.initializeLots(item.getId()); // ❌ Tight coupling
procurementService.createItem(item.getId()); // ❌ Tight coupling
}
}
【Derived Symptom】New features take forever to develop
↓
Root cause: Because of A, B, and C above, every new feature touches multiple places
【Derived Symptom】Onboarding new developers takes 2 weeks
↓
Root cause: Business logic scattered everywhere; no single place says "here's what Item's rules are"
Self-Diagnosis: How many of these do you have? – ✅ Have A (scattered validation) – ✅ Have B (multiple Repository coupling) – ✅ Have C (Service-to-Service coupling)
All three match? → DDD refactoring is strongly recommended
DDD Refactoring: Step-by-Step Operational Guide
Now let’s refactor. No theory—just how to do it.
Step One: Identify Aggregate Boundaries
An aggregate root is DDD’s core concept. Simply put: an aggregate root represents the complete boundary of a business concept.
What exactly to do
Step 1: List your core business concepts
Example (Manufacturing ERP):
✓ Item (Material) → Complex business rules (lead time, state transitions) → should be aggregate root
✓ Order → Complex business rules (workflow management) → should be aggregate root
✓ Supplier → Moderate complexity (some validation) → should be aggregate root
✓ User → Simple (basic attributes) → maybe not DDD
✓ Category → Simple (pure data) → doesn't need DDD
Step 2: Ask yourself three questions about each concept
Q1: Does it have complex business rules?
Item example:
✅ Yes: "Lead time cannot exceed 180 days"
✅ Yes: "Discontinued materials can't get new suppliers"
✅ Yes: "Material state transitions follow a specific workflow"
Conclusion → Item could be an aggregate root
Category example:
❌ No: No special validation logic
❌ No: Just stores a name and order
Conclusion → Category is just data, doesn't need DDD
Q2: Does it have child entities? Do these children always change together?
Item example:
✅ Has ItemSupplier (supplier), changed when? Only when Item changes
✅ Has ItemSpec (specification), changed when? Only when Item changes
✅ Has ItemUnit (unit), changed when? Only when Item changes
Conclusion → ItemSupplier, ItemSpec, ItemUnit should be child aggregates in Item
(Can't exist independently, can't be queried directly by others)
InventoryLot example:
❌ No: InventoryLot can exist independently
❌ No: Item updates don't require InventoryLot updates (they're decoupled)
❌ No: InventoryLot is queried independently by inventory module
Conclusion → InventoryLot should be its own aggregate root, not part of Item
Q3: Do you need multiple Repositories when modifying it?
Current situation (problem):
Saving an Item currently requires:
✅ itemRepository.save(item)
✅ itemSupplierRepository.saveAll(suppliers)
✅ itemSpecRepository.saveAll(specs)
✅ itemUnitRepository.saveAll(units)
Problem → Boundary definition is wrong! Should only use one Repository
Target after refactoring:
✅ itemRepository.save(item) ← cascades to save suppliers, specs, units
Conclusion → ItemSupplier etc. should be child aggregates of Item; no separate Repository needed
Step 3: Draw aggregate root boundaries
┌──────────────────────────────────┐
│ Item Aggregate Root │
├──────────────────────────────────┤
│ Basic Info: │
│ • code (String) material code│
│ • name (String) material name│
│ • status (Enum) state │
│ • leadTime (Integer) lead time │
│ │
│ Child Aggregates (access-only │
│ through Item): │
│ • suppliers: Set<ItemSupplier> │
│ • specs: Set<ItemSpec> │
│ • units: Set<ItemUnit> │
│ │
│ Business Methods (contain │
│ validation logic): │
│ • addSupplier(ItemSupplier) │
│ • removeSupplier(Long id) │
│ • updateStatus(ItemStatus) │
│ • validate() │
└──────────────────────────────────┘
⚠️ Critical: ItemSupplier, ItemSpec, ItemUnit should NOT have separate Repositories
They're only accessible through Item.addSupplier(), Item.addSpec(), etc.
Judge if you got it right
✅ Signs of correct aggregate boundary identification:
□ Child entities in aggregate can't be queried independently (must go through aggregate)
□ Modifying aggregate requires only one Repository (ItemRepository)
□ Aggregate has business validation logic (not all in Service)
□ Child entities aren't modified independently from outside the aggregate
Step Two: Migrate Validation Logic to the Aggregate Root
This is the core of DDD refactoring.
What exactly to do
Open your ItemService and find all validation logic:
// ❌ Before (all validation in Service)
@Service
@RequiredArgsConstructor
public class ItemService {
private final ItemRepository itemRepository;
private final ItemSupplierRepository itemSupplierRepository;
public void addSupplier(Long itemId, SupplierDTO dto) {
Item item = itemRepository.findById(itemId).orElseThrow();
// Validation 1: Lead time check in Service
if (dto.getLeadTime() > 180) {
throw new Exception("Supplier lead time cannot exceed 180 days");
}
// Validation 2: Status check in Service
if (item.getStatus() == ItemStatus.DISCONTINUED) {
throw new Exception("Can't add suppliers to discontinued materials");
}
ItemSupplier supplier = new ItemSupplier(
dto.getSupplierId(),
dto.getLeadTime()
);
itemSupplierRepository.save(supplier); // ❌ Cross-boundary operation
}
}
Migrate step by step to the aggregate root:
// ✅ After (validation in aggregate root)
@Entity
@Table(name = "items")
@Data
@NoArgsConstructor
public class Item {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String code;
private String name;
@Enumerated(EnumType.STRING)
private ItemStatus status;
// Child aggregate (cascade management)
@OneToMany(mappedBy = "item", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<ItemSupplier> suppliers = new HashSet<>();
// ✅ Business method: aggregate validates itself
public void addSupplier(ItemSupplier supplier) {
// Validation 1: Lead time check in aggregate root
if (supplier.getLeadTime() > 180) {
throw new DomainException("Supplier lead time cannot exceed 180 days");
}
// Validation 2: Status check in aggregate root
if (this.status == ItemStatus.DISCONTINUED) {
throw new DomainException("Can't add suppliers to discontinued materials");
}
// Add supplier (aggregate protects its boundary)
this.suppliers.add(supplier);
supplier.setItem(this);
}
public void removeSupplier(Long supplierId) {
suppliers.removeIf(s -> s.getId().equals(supplierId));
}
public void updateStatus(ItemStatus newStatus) {
// State transition validation
if (this.status == ItemStatus.DISCONTINUED && newStatus != ItemStatus.DISCONTINUED) {
throw new DomainException("Can't reactivate discontinued materials");
}
this.status = newStatus;
}
}
Simplify Service layer to only coordinate:
// ✅ Refactored ItemService (now thin)
@Service
@RequiredArgsConstructor
public class ItemDomainService {
private final ItemRepository itemRepository;
public void addSupplier(Long itemId, Long supplierId, Integer leadTime) {
Item item = itemRepository.findById(itemId).orElseThrow();
// Service only coordinates; aggregate validates
ItemSupplier supplier = new ItemSupplier(supplierId, leadTime);
item.addSupplier(supplier); // aggregate validates; throws if invalid
// Only one Repository; child aggregates cascade-save
itemRepository.save(item);
}
}
Now you can write unit tests:
@Test
public void testAddSupplierWithExcessiveLeadTime() {
// ✅ Test aggregate logic directly, no Repository mocking needed
Item item = new Item();
item.setCode("ABC123456");
item.setStatus(ItemStatus.ACTIVE);
ItemSupplier supplier = new ItemSupplier();
supplier.setLeadTime(200); // exceeds limit
// Should throw exception
assertThrows(DomainException.class,
() -> item.addSupplier(supplier)
);
}
@Test
public void testAddSupplierToDiscontinuedItem() {
// ✅ Test state transition validation
Item item = new Item();
item.setStatus(ItemStatus.DISCONTINUED);
ItemSupplier supplier = new ItemSupplier();
supplier.setLeadTime(100);
assertThrows(DomainException.class,
() -> item.addSupplier(supplier)
);
}
Judge if you got it right
✅ Signs of successful validation migration:
□ All business validation now lives in Item class
□ ItemService contains no business validation code
□ ItemService code size significantly reduced
□ Can unit-test aggregate root independently (no Repository mocking)
□ Deleted itemSupplierRepository, itemSpecRepository, etc. dependencies
Step Three: Decouple Modules Using Events (Event-Driven)
Likely your ItemService still has code like this:
public void createItem(CreateItemRequest req) {
Item item = Item.createNew(req.getCode(), req.getName());
itemRepository.save(item);
// ❌ Direct call to other modules' Services (tight coupling)
inventoryService.initializeLots(item.getId());
procurementService.createItem(item.getId());
}
Cross-module dependencies should decouple via events.
What exactly to do
Step 1: Define domain events
// Base class (Spring already has ApplicationEvent)
public abstract class DomainEvent {
private final Instant occurredAt;
protected DomainEvent() {
this.occurredAt = Instant.now();
}
public Instant getOccurredAt() {
return occurredAt;
}
}
// Concrete events
public class ItemCreatedEvent extends DomainEvent {
private final Long itemId;
private final String itemCode;
private final String itemName;
public ItemCreatedEvent(Long itemId, String code, String name) {
super();
this.itemId = itemId;
this.itemCode = code;
this.itemName = name;
}
public Long getItemId() { return itemId; }
public String getItemCode() { return itemCode; }
public String getItemName() { return itemName; }
}
public class ItemStatusChangedEvent extends DomainEvent {
private final Long itemId;
private final ItemStatus newStatus;
public ItemStatusChangedEvent(Long itemId, ItemStatus newStatus) {
super();
this.itemId = itemId;
this.newStatus = newStatus;
}
public Long getItemId() { return itemId; }
public ItemStatus getNewStatus() { return newStatus; }
}
Step 2: Publish events from aggregate root
@Entity
@Table(name = "items")
public class Item {
// ... other fields
@Transient
private List<DomainEvent> domainEvents = new ArrayList<>();
// ✅ Factory method: publishes event when creating new material
public static Item createNew(String code, String name) {
Item item = new Item();
item.code = code;
item.name = name;
item.status = ItemStatus.ACTIVE;
// Publish event: material created
item.domainEvents.add(
new ItemCreatedEvent(item.id, code, name)
);
return item;
}
// ✅ Publishes event when status changes
public void updateStatus(ItemStatus newStatus) {
if (this.status == ItemStatus.DISCONTINUED && newStatus != ItemStatus.DISCONTINUED) {
throw new DomainException("Can't reactivate discontinued materials");
}
this.status = newStatus;
// Publish event: material status changed
domainEvents.add(new ItemStatusChangedEvent(this.id, newStatus));
}
// Get and clear events
public List<DomainEvent> getDomainEvents() {
return new ArrayList<>(domainEvents);
}
public void clearDomainEvents() {
domainEvents.clear();
}
}
Step 3: Service publishes events (doesn’t call other Services)
// ❌ Before
@Service
public class ItemService {
private final InventoryService inventoryService;
private final ProcurementService procurementService;
public void createItem(CreateItemRequest req) {
Item item = Item.createNew(req.getCode(), req.getName());
itemRepository.save(item);
// ❌ Direct call, tight coupling
inventoryService.initializeLots(item.getId());
procurementService.createItem(item.getId());
}
}
// ✅ After
@Service
@RequiredArgsConstructor
public class ItemDomainService {
private final ItemRepository itemRepository;
private final ApplicationEventPublisher eventPublisher;
public void createItem(CreateItemCommand cmd) {
Item item = Item.createNew(cmd.getCode(), cmd.getName());
itemRepository.save(item);
// ✅ Publish events; other modules listen independently
item.getDomainEvents().forEach(eventPublisher::publishEvent);
item.clearDomainEvents();
}
}
Step 4: Other modules listen independently
// Inventory module (doesn't need to know Item exists)
@Component
@RequiredArgsConstructor
public class ItemCreatedEventListener {
private final InventoryLotService inventoryLotService;
@EventListener
public void onItemCreated(ItemCreatedEvent event) {
// After material creation, initialize inventory lots
inventoryLotService.initializeLots(event.getItemId());
}
}
// Procurement module (doesn't need to know Item exists)
@Component
@RequiredArgsConstructor
public class ItemStatusChangedEventListener {
private final ProcurementService procurementService;
@EventListener
public void onItemStatusChanged(ItemStatusChangedEvent event) {
if (event.getNewStatus() == ItemStatus.DISCONTINUED) {
// When material discontinued, cancel pending purchase orders
procurementService.cancelOpenOrders(event.getItemId());
}
}
}
Judge if you got it right
✅ Signs of successful module decoupling:
□ ItemService no longer imports other modules' Services
□ New requirements (like "do X when material discontinued") can be added via EventListener
□ Can add new features without modifying ItemService
□ Can publish and test modules independently
Common Pitfalls and Failure Cases
When refactoring with DDD, watch out for these traps.
Pitfall 1: “Larger aggregate roots are better”
❌ Failure case:
You try to stuff all related Entities into the aggregate root:
@Entity
public class Item {
// Directly related (✅ should include)
private Set<ItemSupplier> suppliers;
private Set<ItemSpec> specs;
private Set<ItemUnit> units;
// Indirectly related (❌ should NOT include)
private Set<InventoryLot> inventoryLots; // ← This belongs to inventory module!
private Set<ProcurementOrder> procurementOrders; // ← This belongs to procurement module!
}
Why it fails: – Saving Item cascades to 5+ tables; lock contention increases; performance degrades – Item aggregate becomes a “god object”; hard to understand – Changes to Item unintentionally affect inventory/procurement logic; coupling increases instead of decreasing
✅ Correct approach:
@Entity
public class Item {
// Only directly related business relationships
private Set<ItemSupplier> suppliers; // ✅ Supplier is bound to material
private Set<ItemSpec> specs; // ✅ Spec is bound to material
private Set<ItemUnit> units; // ✅ Unit is bound to material
// Don't include other modules' concepts
// InventoryLot is initialized via event, not in aggregate root
}
Decision criteria (don’t blindly expand aggregate roots):
Q: Does child entity get deleted together with aggregate root?
Yes → should include (like ItemSupplier)
No → should NOT include (like InventoryLot)
Q: Can child entity only be accessed through aggregate root?
Yes → should include
No → should be independent aggregate root
Q: When modifying child entity, are we always modifying the aggregate root?
Yes → should include
No → should be independent
Pitfall 2: “Validation should be comprehensive”
❌ Failure case:
You move every conceivable validation into the aggregate root:
@Entity
public class Item {
public void addSupplier(ItemSupplier supplier) {
// Validation 1: aggregate-boundary rule ✅
if (supplier.getLeadTime() > 180) {
throw new DomainException("Lead time exceeds limit");
}
// Validation 2: aggregate-boundary rule ✅
if (this.status == ItemStatus.DISCONTINUED) {
throw new DomainException("Can't add suppliers to discontinued material");
}
// Validation 3: cross-aggregate (❌ problems start)
if (inventoryService.hasStock(this.id)) {
throw new DomainException("Can't change suppliers for material with stock");
}
// Validation 4: cross-aggregate (❌ problems continue)
if (procurementService.hasOpenOrder(this.id)) {
throw new DomainException("Can't change suppliers with pending orders");
}
this.suppliers.add(supplier);
}
}
Why it fails: – Aggregate root depends on InventoryService, ProcurementService (breaks decoupling) – Unit tests need to mock many external services; tests become brittle – Aggregate root’s responsibility gets muddled; business rules hard to maintain
✅ Correct approach:
Aggregate root only validates “within-boundary” rules:
@Entity
public class Item {
public void addSupplier(ItemSupplier supplier) {
// ✅ Only validate aggregate-boundary rules
if (supplier.getLeadTime() > 180) {
throw new DomainException("Supplier lead time cannot exceed 180 days");
}
if (this.status == ItemStatus.DISCONTINUED) {
throw new DomainException("Can't add suppliers to discontinued material");
}
this.suppliers.add(supplier);
supplier.setItem(this);
}
}
Cross-aggregate validation stays in Service layer, and is optional:
@Service
@RequiredArgsConstructor
public class ItemDomainService {
private final ItemRepository itemRepository;
private final InventoryService inventoryService;
private final ProcurementService procurementService;
public void addSupplierWithFullValidation(Long itemId, Long supplierId, Integer leadTime) {
Item item = itemRepository.findById(itemId).orElseThrow();
ItemSupplier supplier = new ItemSupplier(supplierId, leadTime);
// Aggregate root validation (required)
item.addSupplier(supplier);
// Cross-aggregate validation (application-layer constraint, optional)
if (inventoryService.hasStock(item.getId())) {
logger.warn("Material {} has stock, but allowing supplier change", item.getId());
}
itemRepository.save(item);
}
}
Decision criteria:
Q: Is this validation a "business rule that must always be followed"?
Yes → put in aggregate root, non-negotiable
No → put in Service/Controller, flexible per context
Q: Does this validation involve "multiple aggregate roots"?
Yes → put in Service layer (coordination across boundaries)
No → put in aggregate root
Q: Is this validation an "application-layer constraint" (can be bypassed)?
Yes → put in Controller or optional Service check
Pitfall 3: “All Services should disappear”
❌ Failure case:
You think DDD means Service layer isn’t needed:
// ❌ Calling aggregate root directly from Controller
@RestController
@RequestMapping("/items")
public class ItemController {
private final ItemRepository itemRepository;
private final ApplicationEventPublisher eventPublisher;
@PostMapping
public void createItem(CreateItemRequest req) {
Item item = Item.createNew(req.getCode(), req.getName());
itemRepository.save(item);
// Event publishing logic scattered in Controller
item.getDomainEvents().forEach(eventPublisher::publishEvent);
}
}
Why it fails: – Controller shouldn’t know “how to publish events”; responsibilities are muddled – Event publishing logic scattered across Controllers; hard to maintain – Can’t do global business process coordination
✅ Correct approach:
Service layer still exists, but with different responsibilities:
@Service
@RequiredArgsConstructor
public class ItemDomainService {
private final ItemRepository itemRepository;
private final ApplicationEventPublisher eventPublisher;
// Service responsibility: coordinate aggregate root and event publishing
public void createItem(CreateItemCommand cmd) {
Item item = Item.createNew(cmd.getCode(), cmd.getName());
itemRepository.save(item);
// Service layer handles event publishing
item.getDomainEvents().forEach(eventPublisher::publishEvent);
}
public void addSupplier(Long itemId, Long supplierId, Integer leadTime) {
Item item = itemRepository.findById(itemId).orElseThrow();
ItemSupplier supplier = new ItemSupplier(supplierId, leadTime);
item.addSupplier(supplier);
itemRepository.save(item);
// Service layer also handles event publishing
item.getDomainEvents().forEach(eventPublisher::publishEvent);
}
}
@RestController
@RequestMapping("/items")
public class ItemController {
private final ItemDomainService itemDomainService;
@PostMapping
public void createItem(CreateItemRequest req) {
itemDomainService.createItem(
new CreateItemCommand(req.getCode(), req.getName())
);
}
}
Decision criteria:
✅ Signs of successful Service layer refactoring:
□ Is there a dedicated place to publish events? Yes (in Service)
□ Did Service layer code reduce or disappear? Reduced (still exists)
□ Does Controller still call Service? Yes
□ Does Service only coordinate, not contain logic? Yes
Pitfall 4: “DDD means code will immediately decrease”
❌ False expectation:
Before: Item (23 lines) + ItemService (373 lines) = 396 lines
After: Item (120 lines) + ItemService (180 lines) + Event (50 lines) + Listener (100 lines)
= 450 lines
"Wait, code increased! Did DDD lie to me?"
✅ Truth: DDD refactoring’s code growth curve
Week 1-2 (refactoring phase): Code ↑ 20-30%
Why? Adding EventListeners, Domain Events, business methods
This is normal; don't panic
Week 3-4 (stabilization): Code → flat
Why? New Event code offset by simplified Service code
Month 2 (promotion): Code ↓ 20-30%
Why? Event-driven architecture reduces new feature impact on core code
Code growth slows as new features only add Listeners
Overall trend: up → flat → down
Don’t measure by one week’s line count. Instead measure:
✅ Measure these indicators (not line count):
□ Did validation logic in ItemService decrease?
□ Do new features no longer require ItemService changes?
□ Can new devs understand business rules faster?
□ Did unit test ratio increase?
□ Did module dependencies decrease?
Refactoring Checklist
After refactoring, use this checklist to judge “did I get it right?”
Is aggregate root design correct?
□ Aggregate root has business validation logic (not just getters/setters)
□ Modifying aggregate requires only one Repository
□ Can't query child entities independently by ID (must go through aggregate)
□ Aggregate root logic is unit-testable
□ Child entities can't exist independently (deleted with aggregate root)
Is Service layer simplification successful?
□ Main Service code is < 50% of the original
□ Service contains no business validation (all in aggregate)
□ Every Service method is < 30 lines (concise)
□ Service doesn't import other modules' Services anymore
□ Can implement new features without modifying Service
Is module decoupling effective?
□ Service layer doesn't import other modules' Services
□ New requirements can be added via EventListener (no existing code changes)
□ Can unit-test modules independently (mocking Events)
□ Modules can deploy independently
□ "Do X when Y event" changes need only 1 modification point (new Listener)
Warning: Signs of failed refactoring
⚠️ If you see these, stop and reconsider:
□ Aggregate root becomes "gigantic" (10+ child entities)
→ Your boundary definition is wrong; needs splitting
□ Aggregate root starts depending on Services (like inventoryService.check())
→ Validation logic leaked back into Service; reorganize
□ Event listeners start synchronously calling multiple other Services
→ Event-driven decoupling failed; review aggregate boundaries
□ New features still require modifying ItemService, InventoryService, ProcurementService
→ Event-driven decoupling didn't work; re-examine boundaries
Getting Started: What You Should Do Next
Refactoring is incremental, not all-at-once.
What you should do this week
【Today】 Assess your project
- Open your most complex Service (usually has intricate business logic)
- Count: How many lines? How many Repository dependencies?
- Identify: Which validations should live in the aggregate root?
【This week】 Design your refactoring plan
- Discuss with team: “Which is our most critical aggregate root?”
- Draw current dependency diagram
- Draw target dependency diagram (should go from “star” shape to “independent”)
【Next week】 Test refactoring on one small feature
- Pick a “new” feature (not modifying existing logic)
- Implement it using the new aggregate root design
- Use checklist from section 4: did I get it right?
【Then】 Decide on full rollout
- If successful → gradually migrate other features
- If unsuccessful → revert; analyze why (likely aggregate boundary issue)
What NOT to do
❌ Don't refactor the entire system at once
→ Can't deliver new features during refactoring
→ Too risky; hard to pinpoint problems
❌ Don't expect code to shrink immediately
→ Will grow short-term (new Events, Listeners)
→ Shrinks long-term (event-driven reduces core code changes)
❌ Don't over-engineer
→ Don't need Event Sourcing, CQRS yet
→ Master basic DDD (aggregate roots, events) first
Final Words
The key to DDD refactoring isn’t “finishing”—it’s “getting it right.”
You got it right when: – ✅ Aggregate roots contain validation; Service layer is thin – ✅ New features don’t require changing multiple modules – ✅ New developers understand business rules faster – ✅ Unit test coverage increased – ✅ Module dependencies decreased
If you find yourself heading toward “aggregate root gigantism” or “Service calling Service,” stop. Go back to section 3 and re-read the pitfalls.
When you get it right, the code speaks for itself.