Skip to main content

Evolving Models

Strategies for refactoring and improving domain models as understanding deepens

TL;DR

Domain models should continuously evolve as understanding improves and business needs change. Refactoring improves internal structure without changing external behavior. When understanding fundamentally changes, split contexts into separate models or merge tightly coupled ones. Version APIs and domain events to support long-term evolution without breaking clients. Continuous small refactorings are vastly superior to periodic large rewrites. Treat model evolution as an ongoing conversation with domain experts, not a one-time activity.

Learning Objectives

  • Recognize when domain models need refinement or restructuring
  • Perform safe refactorings without breaking client code
  • Split contexts when they grow too large or fundamentally diverge
  • Merge contexts when they're tightly coupled and share language
  • Version domain contracts (APIs and events) for long-term evolution
  • Plan model evolution as a continuous improvement process
  • Balance evolution speed with stability and backward compatibility

Motivating Scenario

You built an Orders context that handles regular orders, subscriptions, renewals, and billing cycles. Initially, it seemed like one cohesive domain. But as the business grew, you realized subscriptions operate under different rules: recurring charges instead of one-time payments, renewal workflows instead of fulfillment, churn management, upgrade/downgrade flows. Your Order aggregate is becoming bloated trying to handle both scenarios. Adding new subscription features requires understanding order logic, and order changes risk breaking subscriptions. You need to evolve your model: split Orders and Subscriptions into separate contexts with independent models, while maintaining their necessary communication through events and APIs.

Core Concepts

Model Evolution Patterns

Types of Model Evolution:

  1. Incremental Refactoring: Improve structure without changing behavior
  2. Context Splitting: One context becomes two due to diverging understanding
  3. Context Merging: Separate contexts were wrong—merge them back
  4. API Versioning: Support old clients while introducing new features
  5. Event Versioning: Handle domain event evolution over time
  6. Language Evolution: Ubiquitous language changes as understanding deepens

Practical Example: Refactoring and Splitting

# Initial model—worked for MVP but growing pains

class Order(AggregateRoot):
"""Handles everything: regular orders AND subscriptions."""

def __init__(self, order_id, customer_id, order_type):
self.order_id = order_id
self.customer_id = customer_id
self.order_type = order_type # 'one_time' or 'subscription'
self.items = []
self.status = OrderStatus.DRAFT
self.total = 0
self.renewal_interval = None # Only for subscriptions
self.last_charged = None # Only for subscriptions
self.next_renewal = None # Only for subscriptions
self.cancellation_reason = None # Only for subscriptions

def confirm_purchase(self):
"""Confirm one-time order."""
if self.order_type == 'subscription':
raise CannotConfirmSubscriptionAsOrder()
self.status = OrderStatus.CONFIRMED

def start_subscription(self, interval_days: int):
"""Start a subscription."""
if self.order_type == 'one_time':
raise CannotStartSubscriptionOnOneTimeOrder()
self.renewal_interval = interval_days
self.next_renewal = datetime.now() + timedelta(days=interval_days)
self.status = OrderStatus.ACTIVE

def renew_subscription(self):
"""Auto-renew subscription."""
if self.order_type != 'subscription':
raise NotASubscription()
self.last_charged = datetime.now()
self.next_renewal = datetime.now() + timedelta(days=self.renewal_interval)
# Charge happens elsewhere...

def cancel_subscription(self, reason: str):
"""Cancel subscription."""
if self.order_type != 'subscription':
raise NotASubscription()
self.status = OrderStatus.CANCELLED
self.cancellation_reason = reason

# This aggregate is becoming a god object—too many responsibilities

class OrderRepository:
"""Single repo handling both one-time and subscription orders."""

def save(self, order: Order):
"""Unclear: is this for fulfillment or billing?"""
pass

# Client code must know about both scenarios
class OrderService:
def process_order(self, order_id):
order = order_repo.get(order_id)

if order.order_type == 'one_time':
self._process_one_time_order(order)
elif order.order_type == 'subscription':
self._process_subscription(order)

def _process_one_time_order(self, order):
payment_service.charge_once(order)
fulfillment_service.ship(order)
notification_service.send_confirmation(order)

def _process_subscription(self, order):
billing_service.setup_recurring(order)
# Different logic entirely, but in same method
notification_service.send_subscription_welcome(order)

When to Refactor / Split / Merge

Refactor (Safe, Small Changes)
  1. Extract a service or value object for clarity
  2. Rename classes to better match ubiquitous language
  3. Reorganize package structure
  4. Extract invariant validation into dedicated class
  5. No change to external APIs or contracts
  6. Safe because it's internal restructuring
Split Context (Larger Change)
  1. Aggregate handles two fundamentally different domains
  2. Language diverges—different teams use different terminology
  3. Business rules are contradictory between scenarios
  4. Deployment cycles need independence
  5. Scaling requirements differ
  6. Requires versioning APIs and events between split contexts
Merge Contexts (Rare)
  1. Two contexts always change together—always coupled
  2. Same team owns both—no organizational independence
  3. Sharing one aggregate root would be natural
  4. Language is unified—no terminology confusion
  5. Minimal inter-context communication

Patterns and Pitfalls

Patterns and Pitfalls

Throw away entire domain model and rewrite from scratch. Creates risk, long delay before value delivery, team knowledge loss, uncertain outcome.

Fix: Refactor incrementally. Make small improvements continuously. Refactoring is safer than rewriting.

Change API contracts suddenly, breaking client code. Clients scramble to update, deployment coordination required.

Fix: Version APIs and events. Support old and new versions temporarily. Provide migration period. Communicate breaking changes well in advance.

Code smells appear: aggregate too large, service has too many responsibilities, tests are hard to write. Ignored for months.

Fix: Address design issues early when they're cheap to fix. Small refactorings frequently are better than waiting.

Technical team decides to split a context without talking to domain experts. New structure doesn't match business reality.

Fix: Domain experts should drive evolution decisions. Refactoring should improve alignment with business understanding.

Budget 10-20% of capacity each sprint for refactoring. Make small improvements constantly. Keep codebase healthy.

How to Use: Every sprint, identify one small refactoring. Extract a class, rename for clarity, reorganize a package. Prevents decay.

When implementing new feature, refactor first to make it easier. New feature becomes smaller because structure is cleaner.

How to Use: Before implementing feature, refactor to make the feature code simple. Feature drives structure improvement.

Support multiple API versions simultaneously. v1 and v2 clients coexist. v1 eventually deprecated and removed.

How to Use: New version available for 6 months before old version deprecated. Clients have time to upgrade.

Include version in events. Handlers support multiple versions. Old events upconverted to new format.

How to Use: OrderConfirmed.version = 2. Add upconversion logic for old events.

Design Review Checklist

  • Has domain understanding improved since last review?
  • Does the model still match the ubiquitous language?
  • Are responsibilities clearly divided (low coupling)?
  • Can you add new features without major refactoring?
  • Are aggregates the right size (not too large)?
  • Do bounded contexts have clear, non-overlapping purposes?
  • Are API and event contracts versioned?
  • Do tests cover the domain logic completely?
  • Have domain experts reviewed recent changes?
  • Is there a plan for next evolution step?
  • Can one team deploy without coordinating with others?
  • Are design issues being addressed early?

Self-Check

  1. How often should you refactor? Continuously, in small increments. Not episodically in large projects. Budget 10-20% of capacity. Small refactorings prevent decay better than periodic overhauls.

  2. When is it safe to refactor? When you have good test coverage. Tests provide safety net. Refactoring without tests is risky.

  3. How do you know a model needs evolving? When adding features feels hard. When code doesn't express ubiquitous language. When tests are complicated. When experts ask for clarification. When the aggregate tries to handle too many scenarios.

  4. What's the difference between refactoring and redesign? Refactoring: internal structure changes, external behavior and contracts stay the same. Redesign: fundamental rethinking, may break contracts, risks higher.

  5. How do you handle breaking changes? Version your contracts (APIs, events). Support old and new versions for transition period. Communicate in advance. Give clients time to upgrade.

ℹ️

One Takeaway: Domain models should continuously improve as understanding deepens. Refactor frequently in small increments. When understanding fundamentally changes, split or merge contexts. Version contracts to support evolution without breaking clients. Model evolution is not a one-time activity—it's an ongoing conversation with the domain.

Next Steps

  1. Bounded Contexts: Understand context boundaries that guide evolution
  2. Event Versioning: Learn strategies for evolving domain events
  3. API Versioning: Master backward compatibility in APIs
  4. Continuous Refactoring: Make refactoring a regular practice

References

  • Evans, E. (2003). Domain-Driven Design. Addison-Wesley.
  • Fowler, M. (2018). Refactoring (2nd ed.). Addison-Wesley.
  • Vernon, V. (2013). Implementing Domain-Driven Design. Addison-Wesley.