DDD Architecture Refactoring in Practice: From Anemic Models to Self-Validating Aggregates

🌏 閱讀中文版本


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

Definition: Domain-Driven Design (DDD) is a software design methodology introduced by Eric Evans in 2003. Its core idea is encapsulating complex business logic within the Domain Model rather than scattering it across application-layer services. The Aggregate Root is DDD’s central concept, representing the complete boundary of a business concept.

Real example:

1Before:
2Item Entity
3Just 23 lines, all getters/setters, zero business logic
4ItemService
5373 lines, all validation: lead time checks, status transitions, supplier validation
6After DDD:
7150+ lines, includes addSupplier(), updateStatus() business methods
8150 lines, only coordinates; validation lives in Item class

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

Key Insight: The core value of DDD refactoring isn’t reducing code volume—it’s centralizing business rules in one place, making code easier to understand and test. If your Service layer exceeds 300 lines and depends on multiple Repositories, that’s a strong refactoring signal.


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

1Example (Manufacturing ERP):
2Item (Material)
3Complex business rules (lead time, state transitions)
4should be aggregate root
5Order
6Complex business rules (workflow management)
7Supplier
8Moderate complexity (some validation)

Step 2: Ask yourself three questions about each concept

1Q1: Does it have complex business rules?
2Item example:
3Yes: "Lead time cannot exceed 180 days"
4Yes: "Discontinued materials can't get new suppliers"
5Yes: "Material state transitions follow a specific workflow"
6Conclusion
7Item could be an aggregate root
8Category example:

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

Key Insight: The core criterion for aggregate boundaries: “Does the child entity get deleted with the aggregate root?” If yes (like ItemSupplier), include it in the aggregate; if no (like InventoryLot), it should be an independent aggregate root.


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

Key Insight: The biggest payoff from validation migration is “testability.” When validation lives in the aggregate root, you can test with new Item() directly—no Repository mocking needed. This transforms unit tests from “integration tests in disguise” into genuine unit tests.


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):

1Q: Does child entity get deleted together with aggregate root?
2Yes
3should include (like ItemSupplier)
4No
5should NOT include (like InventoryLot)
6Q: Can child entity only be accessed through aggregate root?
7should include
8should be independent aggregate root

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:

1Q: Is this validation a "business rule that must always be followed"?
2Yes
3put in aggregate root, non-negotiable
4No
5put in Service/Controller, flexible per context
6Q: Does this validation involve "multiple aggregate roots"?
7put in Service layer (coordination across boundaries)
8put in aggregate root

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

1Week 1-2 (refactoring phase): Code ↑ 20-30%
2Why? Adding EventListeners, Domain Events, business methods
3This is normal; don't panic
4Week 3-4 (stabilization): Code
5flat
6Why? New Event code offset by simplified Service code
7Month 2 (promotion): Code
820-30%

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

1If you see these, stop and reconsider:
2Aggregate root becomes "gigantic" (10+ child entities)
3Your boundary definition is wrong; needs splitting
4Aggregate root starts depending on Services (like inventoryService.check())
5Validation logic leaked back into Service; reorganize
6Event listeners start synchronously calling multiple other Services
7Event-driven decoupling failed; review aggregate boundaries
8New features still require modifying ItemService, InventoryService, ProcurementService

Getting Started: What You Should Do Next

Refactoring is incremental, not all-at-once.

What you should do this week

【Today】 Assess your project

  1. Open your most complex Service (usually has intricate business logic)
  2. Count: How many lines? How many Repository dependencies?
  3. Identify: Which validations should live in the aggregate root?

【This week】 Design your refactoring plan

  1. Discuss with team: “Which is our most critical aggregate root?”
  2. Draw current dependency diagram
  3. Draw target dependency diagram (should go from “star” shape to “independent”)

【Next week】 Test refactoring on one small feature

  1. Pick a “new” feature (not modifying existing logic)
  2. Implement it using the new aggregate root design
  3. Use checklist from section 4: did I get it right?

【Then】 Decide on full rollout

  1. If successful → gradually migrate other features
  2. If unsuccessful → revert; analyze why (likely aggregate boundary issue)

What NOT to do

1Don't refactor the entire system at once
2Can't deliver new features during refactoring
3Too risky; hard to pinpoint problems
4Don't expect code to shrink immediately
5Will grow short-term (new Events, Listeners)
6Shrinks long-term (event-driven reduces core code changes)
7Don't over-engineer
8Don't need Event Sourcing, CQRS yet

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.


Sources

  1. Eric Evans – Domain-Driven Design: Tackling Complexity in the Heart of Software (2003) – The original DDD book that defined Aggregate Roots, Bounded Contexts, and other core concepts. Amazon
  2. Martin Fowler – DomainDrivenDesign – A concise introduction to DDD concepts. martinfowler.com
  3. Martin Fowler – AnemicDomainModel – Explains why “anemic models” are considered an anti-pattern. martinfowler.com
  4. Vaughn Vernon – Implementing Domain-Driven Design (2013) – Practical DDD implementation guide with Java examples and event-driven architecture. Amazon
  5. Spring Framework – Domain Events – Spring Data’s documentation on domain event support. spring.io