npx claudepluginhub elct9620/claudekit --plugin codingThis skill uses the workspace's default tool permissions.
- Need to persist domain objects? → Use **schema** for database table design
Guides Domain-Driven Design for complex business logic: aggregates, bounded contexts, ubiquitous language, value objects, entities, and TypeScript implementations with invariants.
Apply DDD principles to model business domains, design aggregates, and establish clear language across teams. Use when modeling complex business logic or integrating domain experts.
Share bugs, ideas, or general feedback.
DDD is layered on top of Clean Architecture's Entities layer, not an alternative to it. CA decides where business rules live (the innermost layer); DDD decides how they are structured (Entity, Value Object, Aggregate, Domain Event).
This skill assumes the decision to use DDD has already been made. If the system is small, CRUD-heavy, and has no real invariants or aggregates, plain CA without DDD is a better fit — the Entities layer is just entities/ holding simple business objects. Consult clean-architecture for that decision before reaching for this skill.
| 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 — use plain Clean Architecture instead and keep entities simple.
| 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 |
| 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 |
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).
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:
# All discount types implement the same interface
class PercentageDiscount
def compute(order)
order.subtotal * (rate / 100.0)
end
end
class BuyOneGetOneFreeDiscount
def compute(order)
# Same interface — extracts line items from the order context
eligible = order.line_items.select { |li| li.product_id == product_id }
eligible.sum { |li| (li.quantity / 2) * li.unit_price.amount }
end
end
# Caller — no case statement, no type checking
class Order
def apply_discount(discount)
@discount_amount = discount.compute(self)
end
end
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 # compared by identity, not value
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
| 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 |
| 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 |
| 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 |
| 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 |
Aggregates are the hardest part to get right. Let them emerge from use case requirements rather than designing them upfront:
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.
| 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.
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?
Bounded Contexts define where a model applies. The same real-world concept may have different representations in different contexts.
| 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 |
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ 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 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 is pure Clean Architecture with no aggregates or ubiquitous-language tension, the directory should be entities/, not domain/. See clean-architecture for the style decision and naming guide — this section only applies once DDD has been chosen.
Splitting a monolithic model into bounded contexts is incremental work. Don't try to do it all at once.
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 Events capture side effects and enable loose coupling between aggregates and contexts.
| 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 |
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.