Skip to main content

Domain-Driven Design: Building Domain-Centric Applications

Created: March 19, 2026 Larry Qu 15 min read

Introduction

Domain-Driven Design (DDD) is a software development approach that focuses on modeling complex business domains through close collaboration between technical and domain experts. Introduced by Eric Evans in 2003, DDD provides patterns and practices for tackling complexity in systems with rich business rules.

DDD is not about technology—it’s about understanding the business domain deeply and reflecting that understanding in code. The core premise is that the most important part of software is the domain model, and everything else (UI, database, infrastructure) should serve that model.

Key benefits of DDD:

  • Shared Language: Ubiquitous language bridges communication between developers and domain experts
  • Business Alignment: Code structure mirrors business concepts
  • Complexity Management: Bounded contexts isolate complexity
  • Maintainability: Clear domain models are easier to understand and modify
  • Flexibility: Well-designed aggregates enable safe concurrent modifications

This guide covers strategic design (bounded contexts, context mapping), tactical patterns (entities, value objects, aggregates, repositories, domain events), and practical implementation patterns for building domain-centric applications.

Strategic Design: Bounded Contexts

Strategic design addresses how to organize large, complex domains into manageable pieces.

Bounded Context

A bounded context is an explicit boundary within which a domain model is defined and applicable. The same term can mean different things in different contexts.

Example: “Customer” in different contexts:

# Sales Context
class Customer:
    """Customer in sales context - focused on purchasing."""
    def __init__(self, customer_id: str, name: str, credit_limit: Decimal):
        self.id = customer_id
        self.name = name
        self.credit_limit = credit_limit
        self.orders: List[Order] = []
    
    def can_place_order(self, order_total: Decimal) -> bool:
        """Check if customer can place order based on credit."""
        current_debt = sum(o.total for o in self.orders if not o.is_paid)
        return current_debt + order_total <= self.credit_limit

# Support Context
class Customer:
    """Customer in support context - focused on service."""
    def __init__(self, customer_id: str, name: str, support_tier: str):
        self.id = customer_id
        self.name = name
        self.support_tier = support_tier  # basic, premium, enterprise
        self.tickets: List[SupportTicket] = []
    
    def get_response_sla(self) -> timedelta:
        """Get SLA based on support tier."""
        sla_map = {
            'basic': timedelta(hours=48),
            'premium': timedelta(hours=24),
            'enterprise': timedelta(hours=4)
        }
        return sla_map[self.support_tier]

# Shipping Context
class Customer:
    """Customer in shipping context - focused on delivery."""
    def __init__(self, customer_id: str, name: str, addresses: List[Address]):
        self.id = customer_id
        self.name = name
        self.addresses = addresses
        self.preferred_carrier: Optional[str] = None
    
    def get_default_shipping_address(self) -> Address:
        """Get default address for shipping."""
        return next((a for a in self.addresses if a.is_default), self.addresses[0])

Context Mapping

Context maps show relationships between bounded contexts.

# Anti-Corruption Layer (ACL)
# Protects domain model from external system changes

class LegacyCustomerDTO:
    """Data from legacy system."""
    cust_no: str
    full_name: str
    credit_amt: str  # String in legacy system!
    status_cd: str   # Cryptic codes

class CustomerAdapter:
    """ACL: Translate legacy data to domain model."""
    
    def to_domain(self, dto: LegacyCustomerDTO) -> Customer:
        """Convert legacy DTO to domain Customer."""
        return Customer(
            customer_id=dto.cust_no,
            name=dto.full_name,
            credit_limit=Decimal(dto.credit_amt),
            status=self._translate_status(dto.status_cd)
        )
    
    def _translate_status(self, status_code: str) -> CustomerStatus:
        """Translate cryptic codes to domain enum."""
        mapping = {
            'A': CustomerStatus.ACTIVE,
            'S': CustomerStatus.SUSPENDED,
            'C': CustomerStatus.CLOSED
        }
        return mapping.get(status_code, CustomerStatus.UNKNOWN)
    
    def from_domain(self, customer: Customer) -> LegacyCustomerDTO:
        """Convert domain Customer to legacy DTO."""
        return LegacyCustomerDTO(
            cust_no=customer.id,
            full_name=customer.name,
            credit_amt=str(customer.credit_limit),
            status_cd=self._translate_status_to_code(customer.status)
        )

Tactical Patterns: Building Blocks

Tactical patterns are the building blocks for implementing domain models.

Entities: Objects with Identity

Entities have a unique identity that persists over time, even if their attributes change.

from dataclasses import dataclass
from datetime import datetime
from typing import Optional
import uuid

class Entity:
    """Base entity with identity and equality based on ID."""
    
    def __init__(self, id: str = None):
        self.id = id or str(uuid.uuid4())
    
    def __eq__(self, other):
        if not isinstance(other, type(self)):
            return False
        return self.id == other.id
    
    def __hash__(self):
        return hash(self.id)

class User(Entity):
    """User entity - identity matters more than attributes."""
    
    def __init__(self, email: str, name: str, id: str = None):
        super().__init__(id)
        self._email = email
        self._name = name
        self._created_at = datetime.utcnow()
        self._is_active = True
        self._email_verified = False
    
    @property
    def email(self) -> str:
        return self._email
    
    @property
    def name(self) -> str:
        return self._name
    
    @property
    def is_active(self) -> bool:
        return self._is_active
    
    def change_email(self, new_email: str):
        """Change email - requires re-verification."""
        if "@" not in new_email:
            raise ValueError("Invalid email format")
        
        self._email = new_email
        self._email_verified = False
    
    def verify_email(self):
        """Mark email as verified."""
        self._email_verified = True
    
    def activate(self):
        """Activate user account."""
        if not self._email_verified:
            raise ValueError("Cannot activate unverified account")
        self._is_active = True
    
    def deactivate(self):
        """Deactivate user account."""
        self._is_active = False

# Two users with same attributes but different IDs are different
user1 = User(email="[email protected]", name="John", id="1")
user2 = User(email="[email protected]", name="John", id="2")
assert user1 != user2  # Different entities

# Same user after attribute change is still the same entity
user1.change_email("[email protected]")
user1_reloaded = User(email="[email protected]", name="John", id="1")
assert user1 == user1_reloaded  # Same entity

Value Objects: Objects Defined by Attributes

Value objects have no identity—they’re defined entirely by their attributes. They’re immutable and interchangeable.

@dataclass(frozen=True)
class Money:
    """Value object - immutable and defined by values."""
    amount: Decimal
    currency: str
    
    def __post_init__(self):
        if self.amount < 0:
            raise ValueError("Amount cannot be negative")
        if not self.currency or len(self.currency) != 3:
            raise ValueError("Currency must be 3-letter code")
    
    def add(self, other: 'Money') -> 'Money':
        """Add money - returns new instance."""
        if self.currency != other.currency:
            raise ValueError("Cannot add different currencies")
        return Money(self.amount + other.amount, self.currency)
    
    def multiply(self, factor: Decimal) -> 'Money':
        """Multiply money - returns new instance."""
        return Money(self.amount * factor, self.currency)
    
    def __str__(self):
        return f"{self.amount} {self.currency}"

@dataclass(frozen=True)
class Address:
    """Value object for address."""
    street: str
    city: str
    state: str
    zip_code: str
    country: str
    
    def __post_init__(self):
        if not self.street or not self.city:
            raise ValueError("Street and city are required")
    
    def is_in_country(self, country: str) -> bool:
        return self.country.upper() == country.upper()

@dataclass(frozen=True)
class DateRange:
    """Value object for date range."""
    start: datetime
    end: datetime
    
    def __post_init__(self):
        if self.start >= self.end:
            raise ValueError("Start must be before end")
    
    def contains(self, date: datetime) -> bool:
        """Check if date is within range."""
        return self.start <= date <= self.end
    
    def overlaps(self, other: 'DateRange') -> bool:
        """Check if ranges overlap."""
        return self.start <= other.end and other.start <= self.end
    
    def duration(self) -> timedelta:
        """Get duration of range."""
        return self.end - self.start

# Value objects are interchangeable
price1 = Money(Decimal('99.99'), 'USD')
price2 = Money(Decimal('99.99'), 'USD')
assert price1 == price2  # Same value = equal

# Immutability
total = price1.add(price2)  # Returns new instance
assert price1.amount == Decimal('99.99')  # Original unchanged

Aggregates: Consistency Boundaries

Aggregates are clusters of entities and value objects treated as a single unit for data changes. The aggregate root is the only entry point.

from typing import List
from decimal import Decimal

class OrderLine:
    """Entity within Order aggregate."""
    
    def __init__(self, product_id: str, product_name: str, 
                 quantity: int, unit_price: Money):
        if quantity <= 0:
            raise ValueError("Quantity must be positive")
        
        self.product_id = product_id
        self.product_name = product_name
        self.quantity = quantity
        self.unit_price = unit_price
    
    @property
    def total(self) -> Money:
        """Calculate line total."""
        return self.unit_price.multiply(Decimal(self.quantity))
    
    def change_quantity(self, new_quantity: int):
        """Change quantity - must be positive."""
        if new_quantity <= 0:
            raise ValueError("Quantity must be positive")
        self.quantity = new_quantity

class Order(Entity):
    """Aggregate root - controls access to OrderLines."""
    
    def __init__(self, customer_id: str, id: str = None):
        super().__init__(id)
        self._customer_id = customer_id
        self._lines: List[OrderLine] = []
        self._status = OrderStatus.DRAFT
        self._created_at = datetime.utcnow()
        self._submitted_at: Optional[datetime] = None
    
    @property
    def customer_id(self) -> str:
        return self._customer_id
    
    @property
    def status(self) -> 'OrderStatus':
        return self._status
    
    @property
    def lines(self) -> List[OrderLine]:
        """Return copy to prevent external modification."""
        return self._lines.copy()
    
    def add_line(self, product_id: str, product_name: str,
                 quantity: int, unit_price: Money):
        """Add line to order - only through aggregate root."""
        if self._status != OrderStatus.DRAFT:
            raise ValueError("Cannot modify submitted order")
        
        # Check if product already in order
        existing = next((l for l in self._lines if l.product_id == product_id), None)
        
        if existing:
            existing.change_quantity(existing.quantity + quantity)
        else:
            line = OrderLine(product_id, product_name, quantity, unit_price)
            self._lines.append(line)
    
    def remove_line(self, product_id: str):
        """Remove line from order."""
        if self._status != OrderStatus.DRAFT:
            raise ValueError("Cannot modify submitted order")
        
        self._lines = [l for l in self._lines if l.product_id != product_id]
    
    def change_line_quantity(self, product_id: str, new_quantity: int):
        """Change quantity of existing line."""
        if self._status != OrderStatus.DRAFT:
            raise ValueError("Cannot modify submitted order")
        
        line = next((l for l in self._lines if l.product_id == product_id), None)
        if not line:
            raise ValueError(f"Product {product_id} not in order")
        
        line.change_quantity(new_quantity)
    
    def submit(self):
        """Submit order - transition to submitted state."""
        if self._status != OrderStatus.DRAFT:
            raise ValueError("Order already submitted")
        
        if not self._lines:
            raise ValueError("Cannot submit empty order")
        
        self._status = OrderStatus.SUBMITTED
        self._submitted_at = datetime.utcnow()
    
    def cancel(self):
        """Cancel order."""
        if self._status == OrderStatus.SHIPPED:
            raise ValueError("Cannot cancel shipped order")
        
        if self._status == OrderStatus.CANCELLED:
            raise ValueError("Order already cancelled")
        
        self._status = OrderStatus.CANCELLED
    
    def ship(self):
        """Mark order as shipped."""
        if self._status != OrderStatus.SUBMITTED:
            raise ValueError("Can only ship submitted orders")
        
        self._status = OrderStatus.SHIPPED
    
    @property
    def total(self) -> Money:
        """Calculate order total."""
        if not self._lines:
            return Money(Decimal('0'), 'USD')
        
        # All lines must have same currency
        currency = self._lines[0].unit_price.currency
        total_amount = sum(line.total.amount for line in self._lines)
        return Money(total_amount, currency)

class OrderStatus:
    """Order status enum."""
    DRAFT = 'draft'
    SUBMITTED = 'submitted'
    SHIPPED = 'shipped'
    CANCELLED = 'cancelled'

# Usage - all modifications go through aggregate root
order = Order(customer_id='cust-123')
order.add_line('prod-1', 'Widget', 2, Money(Decimal('10.00'), 'USD'))
order.add_line('prod-2', 'Gadget', 1, Money(Decimal('25.00'), 'USD'))
order.change_line_quantity('prod-1', 3)
order.submit()

# Cannot modify lines directly - they're encapsulated
# order.lines[0].quantity = 10  # Won't work - lines() returns copy

Domain Events

from dataclasses import dataclass
from datetime import datetime

@dataclass
class DomainEvent:
    """Domain event - something that happened."""
    event_id: str
    event_type: str
    occurred_at: datetime
    aggregate_id: str

@dataclass
class OrderSubmitted(DomainEvent):
    order_id: str
    user_id: str
    total: float

class Order(AggregateRoot):
    """Aggregate that publishes events."""
    
    def __init__(self, user_id: str):
        super().__init__()
        self._user_id = user_id
        self._items = []
        self._status = "pending"
        self._events = []
    
    def submit(self):
        self._status = "submitted"
        
        event = OrderSubmitted(
            event_id=str(uuid.uuid4()),
            event_type="order.submitted",
            occurred_at=datetime.utcnow(),
            aggregate_id=self.id,
            order_id=self.id,
            user_id=self._user_id,
            total=self.total
        )
        self._events.append(event)
    
    def pull_events(self) -> List[DomainEvent]:
        events = self._events.copy()
        self._events.clear()
        return events

Conclusion

DDD helps align code with business concepts. Use entities for things with identity, value objects for immutable values, aggregates for consistency boundaries, and domain events for communication. Start with simple domain models and evolve as understanding grows.

Resources

  • “Domain-Driven Design” by Eric Evans
  • “Implementing Domain-Driven Design” by Vaughn Vernon

Repositories: Persistence Abstraction

Repositories provide collection-like interface for accessing aggregates, hiding persistence details.

from abc import ABC, abstractmethod
from typing import Optional, List

class OrderRepository(ABC):
    """Repository interface - domain layer."""
    
    @abstractmethod
    def get(self, order_id: str) -> Optional[Order]:
        """Get order by ID."""
        pass
    
    @abstractmethod
    def save(self, order: Order) -> None:
        """Save order."""
        pass
    
    @abstractmethod
    def find_by_customer(self, customer_id: str) -> List[Order]:
        """Find orders by customer."""
        pass
    
    @abstractmethod
    def find_pending_orders(self) -> List[Order]:
        """Find all pending orders."""
        pass

class SQLOrderRepository(OrderRepository):
    """Repository implementation - infrastructure layer."""
    
    def __init__(self, session):
        self.session = session
    
    def get(self, order_id: str) -> Optional[Order]:
        """Load order from database."""
        row = self.session.query(OrderModel).filter_by(id=order_id).first()
        
        if not row:
            return None
        
        # Reconstruct domain object from database model
        order = Order(customer_id=row.customer_id, id=row.id)
        order._status = row.status
        order._created_at = row.created_at
        order._submitted_at = row.submitted_at
        
        # Load order lines
        for line_row in row.lines:
            order._lines.append(OrderLine(
                product_id=line_row.product_id,
                product_name=line_row.product_name,
                quantity=line_row.quantity,
                unit_price=Money(Decimal(line_row.unit_price), line_row.currency)
            ))
        
        return order
    
    def save(self, order: Order) -> None:
        """Save order to database."""
        # Find or create database model
        order_model = self.session.query(OrderModel).filter_by(id=order.id).first()
        
        if not order_model:
            order_model = OrderModel(id=order.id)
            self.session.add(order_model)
        
        # Update attributes
        order_model.customer_id = order.customer_id
        order_model.status = order.status
        order_model.created_at = order._created_at
        order_model.submitted_at = order._submitted_at
        
        # Update lines
        order_model.lines.clear()
        for line in order.lines:
            line_model = OrderLineModel(
                product_id=line.product_id,
                product_name=line.product_name,
                quantity=line.quantity,
                unit_price=str(line.unit_price.amount),
                currency=line.unit_price.currency
            )
            order_model.lines.append(line_model)
        
        self.session.commit()
    
    def find_by_customer(self, customer_id: str) -> List[Order]:
        """Find orders by customer."""
        rows = self.session.query(OrderModel).filter_by(customer_id=customer_id).all()
        return [self.get(row.id) for row in rows]
    
    def find_pending_orders(self) -> List[Order]:
        """Find pending orders."""
        rows = self.session.query(OrderModel).filter_by(status=OrderStatus.SUBMITTED).all()
        return [self.get(row.id) for row in rows]

# Usage - domain layer doesn't know about database
class OrderService:
    def __init__(self, order_repo: OrderRepository):
        self.order_repo = order_repo
    
    def submit_order(self, order_id: str):
        order = self.order_repo.get(order_id)
        if not order:
            raise ValueError("Order not found")
        
        order.submit()
        self.order_repo.save(order)

Domain Events: Communicating State Changes

Domain events represent something that happened in the domain. They enable loose coupling between aggregates.

from dataclasses import dataclass
from datetime import datetime
from typing import List
import uuid

@dataclass
class DomainEvent:
    """Base domain event."""
    event_id: str
    event_type: str
    occurred_at: datetime
    aggregate_id: str
    aggregate_type: str

@dataclass
class OrderSubmitted(DomainEvent):
    """Event: Order was submitted."""
    customer_id: str
    order_total: Money
    line_count: int

@dataclass
class OrderShipped(DomainEvent):
    """Event: Order was shipped."""
    customer_id: str
    tracking_number: str
    carrier: str

class Order(Entity):
    """Aggregate that publishes domain events."""
    
    def __init__(self, customer_id: str, id: str = None):
        super().__init__(id)
        self._customer_id = customer_id
        self._lines: List[OrderLine] = []
        self._status = OrderStatus.DRAFT
        self._domain_events: List[DomainEvent] = []
    
    def submit(self):
        """Submit order and raise event."""
        if self._status != OrderStatus.DRAFT:
            raise ValueError("Order already submitted")
        
        if not self._lines:
            raise ValueError("Cannot submit empty order")
        
        self._status = OrderStatus.SUBMITTED
        self._submitted_at = datetime.utcnow()
        
        # Raise domain event
        event = OrderSubmitted(
            event_id=str(uuid.uuid4()),
            event_type='OrderSubmitted',
            occurred_at=datetime.utcnow(),
            aggregate_id=self.id,
            aggregate_type='Order',
            customer_id=self._customer_id,
            order_total=self.total,
            line_count=len(self._lines)
        )
        self._domain_events.append(event)
    
    def ship(self, tracking_number: str, carrier: str):
        """Ship order and raise event."""
        if self._status != OrderStatus.SUBMITTED:
            raise ValueError("Can only ship submitted orders")
        
        self._status = OrderStatus.SHIPPED
        
        event = OrderShipped(
            event_id=str(uuid.uuid4()),
            event_type='OrderShipped',
            occurred_at=datetime.utcnow(),
            aggregate_id=self.id,
            aggregate_type='Order',
            customer_id=self._customer_id,
            tracking_number=tracking_number,
            carrier=carrier
        )
        self._domain_events.append(event)
    
    def pull_domain_events(self) -> List[DomainEvent]:
        """Get and clear domain events."""
        events = self._domain_events.copy()
        self._domain_events.clear()
        return events

# Event handlers
class OrderEventHandler:
    """Handle order events."""
    
    def __init__(self, email_service, inventory_service):
        self.email_service = email_service
        self.inventory_service = inventory_service
    
    def handle(self, event: DomainEvent):
        """Dispatch event to appropriate handler."""
        if isinstance(event, OrderSubmitted):
            self._handle_order_submitted(event)
        elif isinstance(event, OrderShipped):
            self._handle_order_shipped(event)
    
    def _handle_order_submitted(self, event: OrderSubmitted):
        """Handle order submitted event."""
        # Send confirmation email
        self.email_service.send_order_confirmation(
            customer_id=event.customer_id,
            order_id=event.aggregate_id,
            total=event.order_total
        )
        
        # Reserve inventory
        self.inventory_service.reserve_for_order(event.aggregate_id)
    
    def _handle_order_shipped(self, event: OrderShipped):
        """Handle order shipped event."""
        # Send shipping notification
        self.email_service.send_shipping_notification(
            customer_id=event.customer_id,
            tracking_number=event.tracking_number,
            carrier=event.carrier
        )

# Usage in application service
class OrderApplicationService:
    def __init__(self, order_repo: OrderRepository, event_handler: OrderEventHandler):
        self.order_repo = order_repo
        self.event_handler = event_handler
    
    def submit_order(self, order_id: str):
        """Submit order and publish events."""
        order = self.order_repo.get(order_id)
        if not order:
            raise ValueError("Order not found")
        
        order.submit()
        self.order_repo.save(order)
        
        # Publish domain events
        for event in order.pull_domain_events():
            self.event_handler.handle(event)

Domain Services

Domain services contain domain logic that doesn’t naturally fit in entities or value objects.

class PricingService:
    """Domain service for pricing calculations."""
    
    def __init__(self, discount_repo, tax_calculator):
        self.discount_repo = discount_repo
        self.tax_calculator = tax_calculator
    
    def calculate_order_total(self, order: Order, customer: Customer) -> Money:
        """Calculate order total with discounts and tax."""
        # Subtotal
        subtotal = order.total
        
        # Apply customer discounts
        discount = self.discount_repo.get_customer_discount(customer.id)
        if discount:
            discount_amount = subtotal.multiply(discount.percentage)
            subtotal = subtotal.add(discount_amount.multiply(Decimal('-1')))
        
        # Calculate tax
        tax_amount = self.tax_calculator.calculate_tax(
            amount=subtotal,
            customer_address=customer.billing_address
        )
        
        # Total
        return subtotal.add(tax_amount)

class TransferService:
    """Domain service for money transfers between accounts."""
    
    def transfer(self, from_account: BankAccount, to_account: BankAccount,
                 amount: Money):
        """Transfer money between accounts."""
        if from_account.currency != to_account.currency:
            raise ValueError("Cannot transfer between different currencies")
        
        if from_account.balance < amount:
            raise ValueError("Insufficient funds")
        
        # Withdraw from source
        from_account.withdraw(amount, f"Transfer to {to_account.id}")
        
        # Deposit to destination
        to_account.deposit(amount, f"Transfer from {from_account.id}")

Ubiquitous Language

Ubiquitous language is a shared vocabulary between developers and domain experts. Use the same terms in code, conversations, and documentation.

Bad - Technical jargon:

class UserRecord:
    def persist_to_db(self):
        pass
    
    def fetch_related_records(self):
        pass

Good - Domain language:

class Customer:
    def save(self):
        pass
    
    def get_orders(self):
        pass

Example ubiquitous language for e-commerce:

  • Customer: Person who purchases products
  • Order: Customer’s request to purchase products
  • Order Line: Individual product in an order
  • Submit: Customer finalizes order for processing
  • Ship: Order is sent to customer
  • Cancel: Order is terminated before shipping
  • Inventory: Available products for sale
  • Reserve: Hold inventory for an order

Layered Architecture for DDD

DDD typically uses layered architecture to separate concerns.

┌─────────────────────────────────────┐
│   Presentation Layer (UI/API)      │  ← Controllers, Views
├─────────────────────────────────────┤
│   Application Layer                 │  ← Use cases, orchestration
├─────────────────────────────────────┤
│   Domain Layer                      │  ← Entities, Value Objects, Aggregates
├─────────────────────────────────────┤
│   Infrastructure Layer              │  ← Repositories, External services
└─────────────────────────────────────┘
# Domain Layer - Pure business logic
class Order(Entity):
    def submit(self):
        if not self._lines:
            raise ValueError("Cannot submit empty order")
        self._status = OrderStatus.SUBMITTED

# Application Layer - Use case orchestration
class OrderApplicationService:
    def __init__(self, order_repo, email_service, inventory_service):
        self.order_repo = order_repo
        self.email_service = email_service
        self.inventory_service = inventory_service
    
    def submit_order(self, order_id: str):
        """Submit order use case."""
        # Load aggregate
        order = self.order_repo.get(order_id)
        
        # Execute domain logic
        order.submit()
        
        # Save changes
        self.order_repo.save(order)
        
        # Side effects
        self.email_service.send_confirmation(order.customer_id, order.id)
        self.inventory_service.reserve(order.id)

# Infrastructure Layer - Technical implementation
class SQLOrderRepository(OrderRepository):
    def save(self, order: Order):
        # Database-specific code
        pass

# Presentation Layer - HTTP API
@app.post("/orders/{order_id}/submit")
def submit_order_endpoint(order_id: str, service: OrderApplicationService):
    try:
        service.submit_order(order_id)
        return {"status": "success"}
    except ValueError as e:
        return {"status": "error", "message": str(e)}, 400

Best Practices

  1. Start with the domain - Understand business before writing code
  2. Use ubiquitous language - Same terms in code and conversations
  3. Keep aggregates small - Large aggregates are hard to maintain
  4. Enforce invariants in aggregates - Business rules in domain objects
  5. Use value objects liberally - Immutability reduces bugs
  6. Repositories return aggregates - Not individual entities
  7. Domain events for side effects - Decouple aggregates
  8. Separate domain from infrastructure - Pure domain logic
  9. Model explicitly - Make implicit concepts explicit
  10. Iterate with domain experts - Continuous refinement

When to Use DDD

Use DDD when:

  • Domain is complex with rich business rules
  • Long-term project with evolving requirements
  • Close collaboration with domain experts is possible
  • Team has DDD experience or willingness to learn
  • Business logic is core competitive advantage

Don’t use DDD when:

  • Simple CRUD operations suffice
  • Domain is trivial or well-understood
  • Short-term project or prototype
  • Team lacks DDD knowledge and time to learn
  • Technical challenges outweigh domain complexity

Common Pitfalls

Anemic Domain Model: Entities with only getters/setters, logic in services

# Bad - Anemic
class Order:
    def get_status(self):
        return self.status
    
    def set_status(self, status):
        self.status = status

class OrderService:
    def submit_order(self, order):
        if not order.get_items():
            raise ValueError("Empty order")
        order.set_status("submitted")

# Good - Rich domain model
class Order:
    def submit(self):
        if not self._items:
            raise ValueError("Empty order")
        self._status = OrderStatus.SUBMITTED

Leaking Domain Logic: Business rules in application or presentation layers

# Bad - Logic in application layer
class OrderService:
    def submit_order(self, order_id):
        order = self.repo.get(order_id)
        if not order.items:  # Business rule in service!
            raise ValueError("Empty order")
        order.status = "submitted"

# Good - Logic in domain
class Order:
    def submit(self):
        if not self._items:  # Business rule in aggregate
            raise ValueError("Empty order")
        self._status = OrderStatus.SUBMITTED

Large Aggregates: Too many entities in one aggregate

# Bad - Large aggregate
class Customer(AggregateRoot):
    orders: List[Order]  # Hundreds of orders!
    addresses: List[Address]
    payment_methods: List[PaymentMethod]
    preferences: CustomerPreferences

# Good - Separate aggregates
class Customer(AggregateRoot):
    # Only customer data
    pass

class Order(AggregateRoot):
    customer_id: str  # Reference, not embedded
    # Order data

Conclusion

Domain-Driven Design provides patterns and practices for building software that reflects complex business domains. Use strategic design (bounded contexts, context mapping) to manage large-scale complexity, and tactical patterns (entities, value objects, aggregates, repositories, domain events) to implement rich domain models. Develop ubiquitous language with domain experts, enforce business rules in aggregates, use value objects for immutability, and separate domain logic from infrastructure concerns. DDD excels in complex domains with evolving requirements but adds overhead that may not be justified for simple CRUD applications.

Resources

Comments

Share this article

Scan to read on mobile

👍 Was this article helpful?