| name | domain-modeling |
| description | Model business domains using DDD patterns: Entity, Value Object, Aggregate, Domain Event. Use when implementing business logic, defining domain concepts, or designing aggregate boundaries. Make sure to use this skill whenever the user works on business rules, creates domain objects, discusses bounded contexts, models workflows or processes, or asks about entity vs value object distinctions — even for simple CRUD features that involve domain invariants. |
Related Skills
- Need to persist domain objects? → Use schema for database table design
- Deciding how to structure classes around patterns? → Use design-patterns
- Deciding how the system is structured — layers, modules, where the domain layer lives? → Use architecture. Structure follows the model, so the aggregate boundaries you settle here are what it mirrors.
Relationship to architecture
Domain modelling and architecture are different kinds of problem. This skill decides what the domain concepts are — Entity, Value Object, Aggregate, Domain Event. architecture decides how the system is structured around them.
The link runs in one direction: structure follows the model. A bounded context's boundary normally aligns with the aggregate boundaries defined here, so this tactical work comes first and the strategic layout mirrors it — which is why architecture defers context partitioning to these boundaries rather than inventing its own.
This skill is for when the domain is thick enough to model explicitly — rich invariants, real aggregates, ubiquitous-language tension. If the system is small and CRUD-heavy with no invariants worth enforcing, the tactical vocabulary is overhead; keep the domain objects plain. Whether to add surrounding structure at all is architecture's force judgement, not a precondition for this skill.
Applicability Rubric
| Condition | Pass | Fail |
|---|
| Rich invariants | Business rules span multiple fields/objects and must hold together | Field-level validation only |
| Aggregate candidates | Objects that must change together to maintain a rule | Independent CRUD records |
| Ubiquitous language tension | Same word means different things to different teams/features | One obvious vocabulary |
| Complex process modeling | Multi-step workflow with state transitions | Simple create/read/update |
Apply when: At least one condition passes. If none pass, the domain is likely too thin for DDD's tactical vocabulary — keep the domain objects plain and let architecture decide the surrounding structure.
Core Principles
Building Blocks
| Pattern | Purpose | Characteristics |
|---|
| Entity | Object with identity | Has unique ID, lifecycle, mutable |
| Value Object | Immutable data | No ID, compared by value, immutable |
| Aggregate | Consistency boundary | Root entity + related objects |
| Domain Service | Stateless operations | Logic that doesn't belong to entities |
| Domain Event | Record of something happened | Immutable, past tense naming |
Pattern Selection Guide
| Question | Entity | Value Object | Aggregate |
|---|
| Has unique identity? | Yes | No | Root has |
| Identity across changes? | Persists | N/A | Root persists |
| How compared? | By ID | By value | By root ID |
| Mutability | Mutable | Immutable | Controlled |
| Consistency boundary? | No | No | Yes |
Snapshot vs Reference
When embedding data from another aggregate, decide: do you need the current value or the value at the time of the action?
| Strategy | When | Example |
|---|
| Reference (by ID) | Always need current data | Order.customerId → look up current customer |
| Snapshot (copy as VO) | Need historical accuracy | Order.shippingAddress → address at time of order |
Snapshots are Value Objects embedded in the aggregate. They never change even if the source changes later. Use snapshots for anything that affects business correctness if it were to change retroactively (prices, addresses, terms).
Polymorphic Value Objects
When multiple Value Objects share a common concept (e.g., different discount types, different address formats), they must all implement the same method signature. The calling code should never need to type-check or use case/switch statements — polymorphism handles dispatch.
| Principle | Right | Wrong |
|---|
| Same interface | All types implement compute(context) | Different methods per type |
| No type checking | Call discount.compute(order) directly | case discount.class dispatch |
| Uniform parameters | Pass a common context object | Each type needs different args |
| Open for extension | New type = new class, no caller changes | New type = modify case statement |
When different Value Object variants need different data to operate (e.g., a percentage discount needs the order total, but a BOGO discount needs line items), design the interface to accept a context that contains all relevant data. Each implementation extracts what it needs:
class PercentageDiscount
def compute(order)
order.subtotal * (rate / 100.0)
end
end
class BuyOneGetOneFreeDiscount
def compute(order)
eligible = order.line_items.select { |li| li.product_id == product_id }
eligible.sum { |li| (li.quantity / 2) * li.unit_price.amount }
end
end
class Order
def apply_discount(discount)
@discount_amount = discount.compute(self)
end
end
Implementation Examples
Value Object — immutable, validated at construction, compared by value:
class Money
attr_reader :amount, :currency
def initialize(amount, currency)
raise ArgumentError, "amount must be positive" if amount < 0
raise ArgumentError, "currency required" if currency.nil?
@amount = amount.freeze
@currency = currency.freeze
freeze
end
def ==(other)
amount == other.amount && currency == other.currency
end
def +(other)
raise "currency mismatch" unless currency == other.currency
Money.new(amount + other.amount, currency)
end
end
Entity — has identity, encapsulates business rules:
class LineItem
attr_reader :id, :product_id, :quantity, :unit_price
def initialize(id:, product_id:, quantity:, unit_price:)
raise ArgumentError, "quantity must be positive" if quantity <= 0
@id = id
@product_id = product_id
@quantity = quantity
@unit_price = unit_price
end
def subtotal
Money.new(unit_price.amount * quantity, unit_price.currency)
end
def ==(other)
id == other.id
end
end
Domain Event — immutable record of what happened:
class OrderPlaced
attr_reader :order_id, :customer_id, :total_amount, :occurred_at
def initialize(order_id:, customer_id:, total_amount:, occurred_at: Time.now)
@order_id = order_id
@customer_id = customer_id
@total_amount = total_amount
@occurred_at = occurred_at
freeze
end
end
Completion Rubric
Entity Design
| Criterion | Pass | Fail |
|---|
| Unique identifier | Has immutable ID | No identifier or mutable ID |
| Identity immutability | ID unchanged after creation | ID can be modified |
| Business rule encapsulation | Rules inside entity | Logic scattered outside |
| Invariant validation | Validates on state changes | No validation |
Value Object Design
| Criterion | Pass | Fail |
|---|
| Immutability | Cannot be modified after creation | Has setters or mutable state |
| Value equality | Compared by all attributes | Compared by reference |
| Self-validation | Validates on construction | Accepts invalid state |
| Side-effect free | No external state changes | Has side effects |
Aggregate Design
| Criterion | Pass | Fail |
|---|
| Clear root | Aggregate root identified | No clear entry point |
| Access control | External access through root only | Direct access to internals |
| Invariant enforcement | Consistency rules enforced | Invariants can be violated |
| Size appropriateness | Small and focused | Too large or unfocused |
Domain Event Design
| Criterion | Pass | Fail |
|---|
| Past tense naming | Named like OrderPlaced | Present/future tense |
| Complete data | Contains all relevant info | Missing important data |
| Immutability | Cannot be changed | Mutable fields |
| Timestamped | Has occurrence time | No timestamp |
Aggregate Design Steps
Aggregates are the hardest part to get right. Let them emerge from use case requirements rather than designing them upfront:
- Start from the use case — What operation does the user need to perform?
- Discover the invariant — What business rule must hold true for this operation to succeed?
- Find the minimum boundary — What's the smallest set of objects needed to enforce it?
- Choose the root — Which entity is the entry point for all modifications?
- Reference other aggregates by ID only — Never hold direct object references across aggregate boundaries
Aggregate boundaries should be discovered incrementally as use cases reveal which objects must change together. Avoid designing the full aggregate structure before writing the first test.
Aggregate Sizing Heuristic
| Sign | Too Large | Too Small |
|---|
| Performance | Loading is slow, locks contend | Invariants broken because data is split |
| Consistency | Transactions are long | Need distributed transactions |
| Change frequency | Unrelated changes conflict | Related changes require coordination |
Rule of thumb: If two objects must change together to maintain a business rule, they belong in the same aggregate. If they can change independently, they belong in separate aggregates linked by ID.
Example: Order Aggregate
Order (Aggregate Root)
├── OrderId (Value Object)
├── LineItem[] (Entity - has identity within Order)
│ ├── ProductId (Value Object - reference by ID, not object)
│ ├── Quantity (Value Object)
│ └── Price (Value Object - captured at order time)
├── Money totalAmount (Value Object)
└── OrderStatus (Value Object)
Invariant: totalAmount == sum(lineItems.map(price * quantity))
Invariant: at least one LineItem required
Why this boundary?
- LineItems must be inside Order because the total invariant depends on them
- Product is outside — referenced by ID — because it can change independently
- Customer is outside — referenced by ID — an Order doesn't need to enforce Customer rules
Bounded Context
Bounded Contexts define where a model applies. The same real-world concept may have different representations in different contexts.
Context Mapping Patterns
| Pattern | When to Use | Example |
|---|
| Shared Kernel | Two teams co-own a small model | Shared Money type |
| Customer-Supplier | Upstream serves downstream | Order context feeds Shipping context |
| Anti-Corruption Layer | Protect from external model leaking in | Wrap third-party payment API |
| Published Language | Contexts communicate via standard format | Domain Events as JSON |
Example: Same Concept, Different Models
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Sales Context │ │ Shipping Context │ │ Billing Context │
│ │ │ │ │ │
│ Customer: │ │ Recipient: │ │ Payer: │
│ - name │ │ - name │ │ - name │
│ - email │ │ - address │ │ - paymentMethod │
│ - preferences │ │ - phone │ │ - billingAddress│
└──────────────────┘ └──────────────────┘ └──────────────────┘
"Customer" in Sales cares about preferences, in Shipping it's a "Recipient" with an address, in Billing it's a "Payer" with payment info. Don't force one model to serve all contexts.
Directory Structure
Directory naming depends on how many bounded contexts the project has. Pick once at project start — mixing styles creates confusion.
| Project shape | Directory layout | Why |
|---|
| Single bounded context | One domain/ directory holding DDD building blocks | The whole app speaks one ubiquitous language; a context-named directory adds no information |
| Multiple bounded contexts | One directory per context (sales/, shipping/, billing/) each with its own domain model | Generic domain/ at the root would mix vocabularies and let cross-context coupling leak in unnoticed |
For the multi-context case, avoid these anti-patterns:
| Anti-pattern | Why it hurts |
|---|
Shared root domain/ holding every context | Blurs boundaries the contexts exist to enforce |
Generic models/ or entities/ at the root | Same problem, plus loses DDD vocabulary |
Mixing context directories with a shared domain/ | Developers can't tell which rules belong where |
Not doing DDD at all? If the project has no aggregates or ubiquitous-language tension, the directory should be entities/, not domain/, and this section does not apply. See architecture for the structural decision and naming guide — this section only applies once the domain is being modelled tactically.
Migrating from Monolith to Bounded Contexts
Splitting a monolithic model into bounded contexts is incremental work. Don't try to do it all at once.
- Identify seams — Find groups of fields that change together and are used by the same team or feature
- Introduce a facade — Keep the monolithic model but add context-specific interfaces on top of it
- Extract one context at a time — Move the simplest, most independent context out first
- Use events for synchronization — Contexts that used to share a database row now communicate via domain events
- Retire the monolith — Once all contexts are extracted, remove the original model
Step 1: Monolith Step 2: Facades Step 3: Extract
┌─────────────┐ ┌──────────────────┐ ┌────────┐ ┌────────┐
│ User │ │ ProfileFacade │ │Profile │ │ Auth │
│ - name │ → │ AuthFacade │ → │Context │ │Context │
│ - email │ │ BillingFacade │ └────────┘ └────────┘
│ - password │ │ ↓ delegates │ ┌────────┐
│ - card_info │ │ User (legacy) │ │Billing │
│ - prefs │ └──────────────────┘ │Context │
└─────────────┘ └────────┘
Key rule: Each step should be independently deployable and testable. If a step requires a big-bang migration, the boundary is in the wrong place.
Domain Event Patterns
Domain Events capture side effects and enable loose coupling between aggregates and contexts.
When to Raise Events
| Situation | Event | Why |
|---|
| State transition | OrderPlaced, OrderCancelled | Other contexts need to react |
| Business milestone | PaymentReceived | Triggers downstream workflows |
| Policy decision | CreditLimitExceeded | Audit trail and notifications |
Event Flow
Order.place()
→ raises OrderPlaced { orderId, customerId, items[], totalAmount, occurredAt }
→ Shipping context: creates Shipment
→ Billing context: initiates payment
→ Notification context: sends confirmation email
Each context handles the event independently — if Shipping fails, it doesn't roll back the Order.