Overview
Domain-Driven Design (DDD) structures software to reflect the business domain it serves. Eric Evans introduced the methodology in 2003 to address the gap between business requirements and technical implementation. DDD treats the domain model as the central organizing principle of the application, making business logic explicit and keeping it separate from infrastructure concerns.
The approach works by identifying a core domain, creating a shared language between developers and domain experts, and structuring code around domain concepts rather than technical layers. A domain represents the subject area the software addresses—inventory management, financial trading, healthcare records—complete with its rules, processes, and terminology.
DDD divides into strategic and tactical patterns. Strategic patterns define boundaries between different parts of the system (bounded contexts), establish relationships between models, and identify the most valuable areas (core domain). Tactical patterns provide concrete building blocks for implementing the domain model: entities, value objects, aggregates, repositories, and domain services.
The methodology addresses several problems in software development. Business logic often scatters across controllers, database models, and utility classes, making it difficult to locate and modify. Technical implementations drift from business requirements over time. Developers and domain experts speak different languages, causing miscommunication. DDD concentrates business logic in explicit domain objects, maintains alignment through continuous collaboration, and creates a ubiquitous language shared by all stakeholders.
# Traditional approach - business logic scattered
class OrdersController
def create
order = Order.new(params[:order])
# Business rules mixed with controller logic
if order.total > 1000 && order.customer.purchases.count < 3
flash[:error] = "New customers cannot place large orders"
return
end
order.save
end
end
# DDD approach - business logic in domain
class Order
def place(inventory, pricing_service)
ensure_customer_can_place_order
calculate_total(pricing_service)
reserve_inventory(inventory)
self.status = :placed
end
private
def ensure_customer_can_place_order
raise OrderLimitExceeded if exceeds_customer_limit?
end
def exceeds_customer_limit?
customer.new? && line_items.sum(&:quantity) > LARGE_ORDER_THRESHOLD
end
end
Key Principles
Ubiquitous Language forms the foundation of DDD. Development teams and domain experts create a shared vocabulary that appears in conversations, documentation, and code. When domain experts discuss "fulfillment" or "backorder," these exact terms appear as class names, method names, and variable names. This linguistic consistency prevents translation errors and keeps the model aligned with business understanding.
# Ubiquitous language reflected in code
class Fulfillment
def initiate_for(order)
order.line_items.each do |item|
if inventory.available?(item.product, item.quantity)
create_pick_list(item)
else
create_backorder(item)
end
end
end
end
Model-Driven Design implements the conceptual domain model directly in code. Classes represent domain concepts. Methods perform business operations. Object relationships mirror real-world associations. The model serves as both design and implementation—changes to business requirements translate to changes in the model, and the model executes the business logic.
Bounded Context establishes clear boundaries where a particular model applies. A "Customer" in the sales context differs from a "Customer" in the support context. Each bounded context maintains its own model, with explicit integration points where contexts interact. This prevents model ambiguity and allows different parts of the system to optimize for their specific needs.
Context Mapping defines relationships between bounded contexts. An upstream context influences a downstream context through shared models, integration points, or published APIs. The mapping makes dependencies explicit and guides integration strategies.
Entities represent domain objects with unique identity that persists over time. Two customers with identical names and addresses remain distinct entities because each has a unique identifier. Entities track changes through their lifecycle while maintaining identity.
class Customer
attr_reader :id, :email
attr_accessor :name, :shipping_address
def initialize(id:, email:, name:)
@id = id
@email = email
@name = name
end
def ==(other)
other.is_a?(Customer) && other.id == id
end
def update_shipping_address(new_address)
@shipping_address = new_address
DomainEvents.publish(CustomerAddressChanged.new(customer_id: id))
end
end
Value Objects represent descriptive aspects of the domain without identity. A shipping address with street "123 Main St" and city "Portland" equals any other address with identical attributes. Value objects remain immutable—changing an address creates a new address object rather than modifying the existing one.
class Money
attr_reader :amount, :currency
def initialize(amount, currency)
@amount = amount.freeze
@currency = currency.freeze
freeze
end
def add(other)
raise CurrencyMismatch unless currency == other.currency
Money.new(amount + other.amount, currency)
end
def ==(other)
other.is_a?(Money) &&
amount == other.amount &&
currency == other.currency
end
end
Aggregates cluster related entities and value objects into consistency boundaries. One entity serves as the aggregate root—the only member accessible from outside. External objects hold references to the root, not to internal members. This encapsulation enforces invariants and prevents inconsistent state changes.
Repositories provide collection-like interfaces for retrieving and storing aggregates. The repository abstracts persistence, allowing domain logic to work with domain objects without database concerns. Repository methods use domain language: find_customer_by_email rather than execute_query.
Domain Services implement operations that don't naturally belong to entities or value objects. When business logic involves multiple aggregates or depends on external systems, a domain service coordinates the operation while keeping business rules explicit.
Ruby Implementation
Ruby's dynamic nature and expressive syntax align well with DDD principles. Ruby allows creating domain models that read naturally and clearly express business concepts without excessive ceremony.
Entity Implementation uses attribute readers for identity and business-meaningful accessors for state changes. Include equality comparison based on identity. Freeze the identity to prevent modification.
class Order
attr_reader :id, :customer_id, :placed_at
attr_accessor :status
def initialize(id:, customer_id:, placed_at: Time.now)
@id = id
@customer_id = customer_id
@placed_at = placed_at
@status = :draft
@line_items = []
end
def add_line_item(product, quantity, price)
raise OrderAlreadyPlaced if placed?
@line_items << LineItem.new(product: product, quantity: quantity, price: price)
end
def place
raise EmptyOrder if @line_items.empty?
@status = :placed
DomainEvents.publish(OrderPlaced.new(order_id: id))
end
def placed?
status == :placed
end
def total
@line_items.sum(&:subtotal)
end
end
Value Object Implementation freezes all attributes and the object itself. Implement equality comparison based on all attributes. Provide methods that return new instances rather than modifying state.
class DateRange
attr_reader :start_date, :end_date
def initialize(start_date, end_date)
raise InvalidDateRange if start_date > end_date
@start_date = start_date.freeze
@end_date = end_date.freeze
freeze
end
def include?(date)
date >= start_date && date <= end_date
end
def overlaps?(other)
start_date <= other.end_date && end_date >= other.start_date
end
def duration_in_days
(end_date - start_date).to_i + 1
end
def ==(other)
other.is_a?(DateRange) &&
start_date == other.start_date &&
end_date == other.end_date
end
alias eql? ==
def hash
[start_date, end_date].hash
end
end
Aggregate Implementation enforces that all modifications flow through the root. Internal entities remain private or protected. The root validates invariants before allowing state changes.
class ShoppingCart
attr_reader :id, :customer_id
def initialize(id:, customer_id:)
@id = id
@customer_id = customer_id
@items = []
end
def add_product(product, quantity)
existing_item = find_item(product.id)
if existing_item
existing_item.increase_quantity(quantity)
else
@items << CartItem.new(product: product, quantity: quantity)
end
enforce_item_limit
end
def remove_product(product_id)
@items.reject! { |item| item.product_id == product_id }
end
def update_quantity(product_id, new_quantity)
item = find_item(product_id)
raise ItemNotFound unless item
if new_quantity <= 0
remove_product(product_id)
else
item.update_quantity(new_quantity)
end
end
def total
@items.sum(&:subtotal)
end
def item_count
@items.sum(&:quantity)
end
private
def find_item(product_id)
@items.find { |item| item.product_id == product_id }
end
def enforce_item_limit
raise TooManyItems if item_count > 50
end
# CartItem is not exposed outside the aggregate
class CartItem
attr_reader :product_id, :product_name, :price, :quantity
def initialize(product:, quantity:)
@product_id = product.id
@product_name = product.name
@price = product.price
@quantity = quantity
end
def increase_quantity(amount)
@quantity += amount
end
def update_quantity(new_quantity)
@quantity = new_quantity
end
def subtotal
price * quantity
end
end
end
Repository Implementation separates persistence concerns from domain logic. The repository interface uses domain terminology and returns domain objects. Implementation details remain hidden behind the interface.
class OrderRepository
def initialize(data_store)
@data_store = data_store
end
def find_by_id(id)
data = @data_store.find_order(id)
return nil unless data
reconstitute_order(data)
end
def find_by_customer(customer_id)
data_list = @data_store.find_orders_by_customer(customer_id)
data_list.map { |data| reconstitute_order(data) }
end
def find_pending_orders
data_list = @data_store.find_orders_by_status('pending')
data_list.map { |data| reconstitute_order(data) }
end
def save(order)
data = serialize_order(order)
@data_store.save_order(data)
order
end
private
def reconstitute_order(data)
Order.new(
id: data[:id],
customer_id: data[:customer_id],
placed_at: data[:placed_at]
).tap do |order|
data[:line_items].each do |item_data|
order.add_line_item(
Product.new(id: item_data[:product_id]),
item_data[:quantity],
Money.new(item_data[:price], item_data[:currency])
)
end
order.status = data[:status]
end
end
def serialize_order(order)
{
id: order.id,
customer_id: order.customer_id,
placed_at: order.placed_at,
status: order.status,
line_items: order.line_items.map { |item| serialize_line_item(item) }
}
end
end
Domain Service Implementation coordinates operations across multiple aggregates or handles operations requiring external dependencies. The service accepts domain objects as parameters and returns domain objects or value types.
class OrderFulfillmentService
def initialize(inventory_repository, shipping_service)
@inventory_repository = inventory_repository
@shipping_service = shipping_service
end
def fulfill_order(order)
raise OrderNotPlaced unless order.placed?
allocation_result = allocate_inventory(order)
if allocation_result.fully_allocated?
shipment = create_shipment(order, allocation_result)
order.mark_as_fulfilled(shipment.tracking_number)
else
order.mark_as_partially_fulfilled(allocation_result.backordered_items)
end
allocation_result
end
private
def allocate_inventory(order)
AllocationResult.new.tap do |result|
order.line_items.each do |item|
available = @inventory_repository.available_quantity(item.product_id)
if available >= item.quantity
@inventory_repository.reserve(item.product_id, item.quantity)
result.add_allocated(item)
else
result.add_backordered(item, available)
end
end
end
end
def create_shipment(order, allocation_result)
@shipping_service.create_shipment(
recipient: order.shipping_address,
items: allocation_result.allocated_items,
service_level: order.shipping_service_level
)
end
end
Practical Examples
E-commerce Order Processing demonstrates aggregate design, value objects, and domain services working together to enforce business rules.
# Value Objects for business concepts
class ShippingAddress
attr_reader :street, :city, :state, :postal_code, :country
def initialize(street:, city:, state:, postal_code:, country:)
@street = street
@city = city
@state = state
@postal_code = postal_code
@country = country
freeze
end
def domestic?
country == 'US'
end
def ==(other)
other.is_a?(ShippingAddress) &&
street == other.street &&
city == other.city &&
state == other.state &&
postal_code == other.postal_code &&
country == other.country
end
end
# Aggregate Root
class Order
attr_reader :id, :customer_id, :placed_at, :shipping_address
LARGE_ORDER_THRESHOLD = Money.new(1000, 'USD')
MAX_LINE_ITEMS = 100
def initialize(id:, customer_id:)
@id = id
@customer_id = customer_id
@line_items = []
@status = :draft
@discounts = []
end
def add_item(product, quantity, unit_price)
raise OrderAlreadyPlaced if placed?
raise TooManyItems if @line_items.count >= MAX_LINE_ITEMS
existing = @line_items.find { |item| item.product_id == product.id }
if existing
existing.increase_quantity(quantity)
else
@line_items << LineItem.new(
product_id: product.id,
product_name: product.name,
quantity: quantity,
unit_price: unit_price
)
end
end
def set_shipping_address(address)
raise OrderAlreadyPlaced if placed?
@shipping_address = address
end
def apply_discount(discount)
raise OrderAlreadyPlaced if placed?
raise InvalidDiscount unless discount.applicable_to?(self)
@discounts << discount
end
def place(payment_method)
raise MissingShippingAddress unless @shipping_address
raise EmptyOrder if @line_items.empty?
raise InvalidPaymentMethod unless payment_method.valid?
@placed_at = Time.now
@status = :placed
@payment_method = payment_method
DomainEvents.publish(OrderPlaced.new(
order_id: id,
customer_id: customer_id,
total: total,
placed_at: @placed_at
))
end
def subtotal
@line_items.sum(&:line_total)
end
def discount_amount
@discounts.sum { |discount| discount.calculate(self) }
end
def total
subtotal.subtract(discount_amount)
end
def placed?
@status == :placed
end
def requires_signature?
total > LARGE_ORDER_THRESHOLD
end
end
# Domain Service
class OrderPricingService
def initialize(product_catalog, tax_calculator)
@product_catalog = product_catalog
@tax_calculator = tax_calculator
end
def calculate_total(order)
subtotal = calculate_subtotal(order)
discounts = calculate_discounts(order)
tax = @tax_calculator.calculate_tax(order)
shipping = calculate_shipping(order)
OrderTotal.new(
subtotal: subtotal,
discounts: discounts,
tax: tax,
shipping: shipping,
total: subtotal - discounts + tax + shipping
)
end
private
def calculate_subtotal(order)
order.line_items.sum do |item|
current_price = @product_catalog.current_price(item.product_id)
current_price.multiply(item.quantity)
end
end
def calculate_shipping(order)
base_rate = order.shipping_address.domestic? ?
Money.new(10, 'USD') : Money.new(25, 'USD')
if order.subtotal > Money.new(100, 'USD')
Money.new(0, 'USD')
else
base_rate
end
end
end
Reservation System shows bounded contexts, context mapping, and handling eventual consistency between contexts.
# Booking Context
module BookingContext
class Reservation
attr_reader :id, :room_id, :guest_id, :date_range
def initialize(id:, room_id:, guest_id:, date_range:)
@id = id
@room_id = room_id
@guest_id = guest_id
@date_range = date_range
@status = :pending
end
def confirm(payment_confirmation)
raise ReservationNotPending unless pending?
raise InvalidPayment unless payment_confirmation.valid?
@status = :confirmed
@confirmed_at = Time.now
DomainEvents.publish(ReservationConfirmed.new(
reservation_id: id,
room_id: room_id,
date_range: date_range,
guest_id: guest_id
))
end
def cancel(reason)
raise CannotCancelConfirmed if @status == :confirmed && non_refundable?
@status = :cancelled
@cancellation_reason = reason
DomainEvents.publish(ReservationCancelled.new(
reservation_id: id,
room_id: room_id,
date_range: date_range
))
end
def pending?
@status == :pending
end
end
class RoomAvailability
def initialize(room_id)
@room_id = room_id
@reservations = []
end
def available_for?(date_range)
!@reservations.any? { |res| res.date_range.overlaps?(date_range) }
end
def reserve(date_range, guest_id)
raise RoomNotAvailable unless available_for?(date_range)
reservation = Reservation.new(
id: generate_id,
room_id: @room_id,
guest_id: guest_id,
date_range: date_range
)
@reservations << reservation
reservation
end
end
end
# Housekeeping Context (different bounded context)
module HousekeepingContext
class RoomSchedule
attr_reader :room_id
def initialize(room_id)
@room_id = room_id
@cleaning_tasks = []
@blocked_dates = []
end
def schedule_checkout_cleaning(date)
@cleaning_tasks << CleaningTask.new(
room_id: room_id,
scheduled_date: date,
task_type: :checkout_cleaning
)
end
def block_for_maintenance(date_range, reason)
@blocked_dates << MaintenanceBlock.new(
date_range: date_range,
reason: reason
)
end
end
# Anti-Corruption Layer - translates booking concepts to housekeeping concepts
class BookingEventHandler
def initialize(room_schedule_repository)
@room_schedule_repository = room_schedule_repository
end
def handle_reservation_confirmed(event)
schedule = @room_schedule_repository.find_by_room(event.room_id)
checkout_date = event.date_range.end_date
schedule.schedule_checkout_cleaning(checkout_date)
@room_schedule_repository.save(schedule)
end
end
end
Financial Transaction Processing demonstrates domain events, specifications, and maintaining invariants across complex business rules.
class Account
attr_reader :id, :account_number, :balance
MINIMUM_BALANCE = Money.new(0, 'USD')
OVERDRAFT_LIMIT = Money.new(-500, 'USD')
def initialize(id:, account_number:, balance:)
@id = id
@account_number = account_number
@balance = balance
@transactions = []
end
def deposit(amount, description)
raise InvalidAmount if amount.amount <= 0
transaction = Transaction.credit(
account_id: id,
amount: amount,
description: description
)
apply_transaction(transaction)
end
def withdraw(amount, description)
raise InvalidAmount if amount.amount <= 0
new_balance = @balance.subtract(amount)
raise InsufficientFunds if new_balance < OVERDRAFT_LIMIT
transaction = Transaction.debit(
account_id: id,
amount: amount,
description: description
)
apply_transaction(transaction)
if new_balance < MINIMUM_BALANCE
DomainEvents.publish(AccountOverdrawn.new(
account_id: id,
overdraft_amount: new_balance.amount.abs
))
end
end
def transfer_to(target_account, amount, description)
withdraw(amount, "Transfer to #{target_account.account_number}: #{description}")
target_account.deposit(amount, "Transfer from #{account_number}: #{description}")
DomainEvents.publish(TransferCompleted.new(
from_account_id: id,
to_account_id: target_account.id,
amount: amount
))
end
private
def apply_transaction(transaction)
@balance = transaction.apply_to(@balance)
@transactions << transaction
end
end
class Transaction
attr_reader :id, :account_id, :amount, :type, :description, :occurred_at
def self.credit(account_id:, amount:, description:)
new(
id: generate_id,
account_id: account_id,
amount: amount,
type: :credit,
description: description
)
end
def self.debit(account_id:, amount:, description:)
new(
id: generate_id,
account_id: account_id,
amount: amount,
type: :debit,
description: description
)
end
def initialize(id:, account_id:, amount:, type:, description:)
@id = id
@account_id = account_id
@amount = amount
@type = type
@description = description
@occurred_at = Time.now
freeze
end
def apply_to(current_balance)
case @type
when :credit then current_balance.add(@amount)
when :debit then current_balance.subtract(@amount)
end
end
def credit?
@type == :credit
end
def debit?
@type == :debit
end
end
# Specification Pattern for complex business rules
class AccountSpecification
def self.eligible_for_premium?(account, transaction_history)
average_balance = calculate_average_balance(transaction_history)
monthly_deposits = count_monthly_deposits(transaction_history)
average_balance >= Money.new(5000, 'USD') &&
monthly_deposits >= 3
end
def self.requires_fraud_review?(transaction, account)
large_amount_spec = LargeTransactionSpec.new(Money.new(10000, 'USD'))
unusual_time_spec = UnusualTimeSpec.new
foreign_spec = ForeignTransactionSpec.new(account.home_country)
large_amount_spec.satisfied_by?(transaction) ||
(unusual_time_spec.satisfied_by?(transaction) &&
foreign_spec.satisfied_by?(transaction))
end
end
Design Considerations
When to Apply DDD depends on domain complexity and expected longevity. DDD adds development overhead through additional abstraction layers and careful modeling. Projects benefit from DDD when business rules are complex, subject to frequent change, or require close collaboration with domain experts. A simple CRUD application with minimal business logic gains little from DDD patterns.
Complex invariants signal DDD applicability. When an operation must validate rules across multiple entities, maintain consistency boundaries, or coordinate several steps, aggregates and domain services clarify these requirements. Projects where business logic currently scatters across controllers and database models indicate that extracting a domain layer would improve maintainability.
Long-term projects justify DDD's upfront modeling cost. The approach pays dividends when requirements evolve, team members change, or the system operates for years. Short-lived projects or prototypes may not recover the initial investment.
Bounded Context Boundaries require careful analysis. Contexts split along business capability boundaries rather than technical concerns. Different teams, different ubiquitous languages, or different rates of change indicate context boundaries. A retail system might separate inventory management, order processing, and customer support into distinct contexts—each with its own model optimized for its needs.
Context size affects maintainability. Too many contexts fragment the system and increase integration complexity. Too few contexts create bloated models trying to serve multiple purposes. Start with larger contexts and split them as linguistic conflicts or different change rates emerge.
Integration Strategy between contexts shapes system architecture. Shared kernel allows two contexts to share part of their domain model, requiring coordination between teams. Customer-supplier relationships establish upstream and downstream contexts with clear dependencies. Published language creates a well-documented interchange format that multiple contexts consume. Anti-corruption layer translates between contexts, protecting the downstream model from upstream changes.
Event-driven integration reduces coupling between contexts. Context A publishes domain events; Context B subscribes and updates its model asynchronously. This eventual consistency trades immediate consistency for independent deployment and reduced coupling.
Aggregate Design balances consistency and performance. Larger aggregates enforce more invariants within a single transaction but create concurrency bottlenecks. Smaller aggregates enable greater concurrency but require coordination across transactions for some invariants.
An order aggregate might include line items (enforcing "an order cannot exceed 100 items") but exclude customer information (customer details change independently of orders). The order references the customer by ID rather than containing the customer aggregate.
Transaction boundaries align with aggregates. One transaction modifies one aggregate instance. Operations spanning multiple aggregates use eventual consistency through domain events or explicit saga coordination.
Repository Granularity matches aggregate boundaries. Each aggregate type gets one repository. The order repository saves and retrieves complete order aggregates. Repositories never expose aggregate internals—no separate repository for line items when they belong to orders.
Query requirements sometimes conflict with aggregate design. The domain model optimizes for write operations and invariant enforcement. Read models (CQRS pattern) provide optimized query structures without compromising the domain model's integrity.
Common Patterns
Factory Pattern constructs complex aggregates with proper validation. The factory encapsulates creation logic, ensuring aggregates start in valid states.
class OrderFactory
def initialize(pricing_service, inventory_service)
@pricing_service = pricing_service
@inventory_service = inventory_service
end
def create_order(customer:, items:, shipping_address:)
order = Order.new(
id: generate_order_id,
customer_id: customer.id
)
items.each do |item_request|
product = @inventory_service.find_product(item_request[:product_id])
raise ProductNotFound unless product
raise InsufficientInventory unless @inventory_service.available?(product, item_request[:quantity])
price = @pricing_service.price_for(product, customer)
order.add_item(product, item_request[:quantity], price)
end
order.set_shipping_address(shipping_address)
order
end
end
Specification Pattern encapsulates business rules for reuse in validation, filtering, and selection logic.
class Specification
def satisfied_by?(candidate)
raise NotImplementedError
end
def and(other)
AndSpecification.new(self, other)
end
def or(other)
OrSpecification.new(self, other)
end
def not
NotSpecification.new(self)
end
end
class OverdueInvoiceSpec < Specification
def satisfied_by?(invoice)
invoice.due_date < Date.today && !invoice.paid?
end
end
class HighValueInvoiceSpec < Specification
def initialize(threshold)
@threshold = threshold
end
def satisfied_by?(invoice)
invoice.total >= @threshold
end
end
# Composite specifications
class AndSpecification < Specification
def initialize(left, right)
@left = left
@right = right
end
def satisfied_by?(candidate)
@left.satisfied_by?(candidate) && @right.satisfied_by?(candidate)
end
end
# Usage
overdue_and_high_value = OverdueInvoiceSpec.new.and(
HighValueInvoiceSpec.new(Money.new(1000, 'USD'))
)
urgent_invoices = invoices.select { |inv| overdue_and_high_value.satisfied_by?(inv) }
Domain Events communicate changes within and between bounded contexts. Events capture business occurrences in past tense: OrderPlaced, PaymentReceived, ShipmentDelivered.
class DomainEvent
attr_reader :occurred_at, :event_id
def initialize
@occurred_at = Time.now
@event_id = SecureRandom.uuid
end
end
class OrderPlaced < DomainEvent
attr_reader :order_id, :customer_id, :total
def initialize(order_id:, customer_id:, total:)
super()
@order_id = order_id
@customer_id = customer_id
@total = total
end
end
module DomainEvents
@handlers = Hash.new { |h, k| h[k] = [] }
def self.subscribe(event_class, &handler)
@handlers[event_class] << handler
end
def self.publish(event)
@handlers[event.class].each do |handler|
handler.call(event)
end
end
end
# Event handlers in different contexts
DomainEvents.subscribe(OrderPlaced) do |event|
email_service.send_order_confirmation(event.order_id)
end
DomainEvents.subscribe(OrderPlaced) do |event|
loyalty_service.award_points(event.customer_id, event.total)
end
Anti-Corruption Layer protects the domain model from external systems or legacy code. The layer translates between the domain's ubiquitous language and external representations.
class LegacyOrderAdapter
def initialize(legacy_system)
@legacy_system = legacy_system
end
def fetch_order(legacy_order_id)
legacy_data = @legacy_system.get_order(legacy_order_id)
Order.new(
id: generate_domain_id(legacy_data['order_num']),
customer_id: translate_customer_id(legacy_data['cust_id']),
placed_at: parse_legacy_date(legacy_data['order_dt'])
).tap do |order|
legacy_data['line_items'].each do |legacy_item|
order.add_item(
translate_product(legacy_item),
legacy_item['qty'],
Money.new(legacy_item['price'], 'USD')
)
end
end
end
def save_order(order)
legacy_data = {
'order_num' => translate_to_legacy_id(order.id),
'cust_id' => translate_to_legacy_customer_id(order.customer_id),
'order_dt' => format_legacy_date(order.placed_at),
'line_items' => order.line_items.map { |item| translate_line_item(item) }
}
@legacy_system.save_order(legacy_data)
end
end
Saga Pattern coordinates long-running business processes across multiple aggregates or bounded contexts. Each step publishes events; subsequent steps react to events and continue the process.
class OrderFulfillmentSaga
def initialize(order_repo, inventory_service, shipping_service, payment_service)
@order_repo = order_repo
@inventory_service = inventory_service
@shipping_service = shipping_service
@payment_service = payment_service
end
def start(order_id)
order = @order_repo.find_by_id(order_id)
# Step 1: Reserve inventory
reservation = @inventory_service.reserve_items(order.line_items)
if reservation.successful?
order.mark_inventory_reserved(reservation.id)
process_payment(order)
else
order.mark_as_failed("Insufficient inventory")
end
@order_repo.save(order)
end
private
def process_payment(order)
payment_result = @payment_service.charge(
order.customer_id,
order.total,
order.payment_method
)
if payment_result.successful?
order.mark_payment_received(payment_result.transaction_id)
arrange_shipment(order)
else
# Compensating transaction
@inventory_service.release_reservation(order.inventory_reservation_id)
order.mark_as_failed("Payment declined")
end
end
def arrange_shipment(order)
shipment = @shipping_service.create_shipment(
order.shipping_address,
order.line_items
)
order.mark_as_shipped(shipment.tracking_number)
end
end
Common Pitfalls
Anemic Domain Model occurs when domain objects contain only getters and setters with no business logic. All operations live in service classes, making the domain model a data structure rather than a behavior-rich model. This violates DDD's core principle of placing business logic in domain objects.
# Anemic model - avoid this
class Order
attr_accessor :id, :customer_id, :status, :line_items, :total
end
class OrderService
def place_order(order)
if order.line_items.empty?
raise "Cannot place empty order"
end
order.status = "placed"
order.total = calculate_total(order)
repository.save(order)
end
end
# Rich domain model - prefer this
class Order
def place
raise EmptyOrder if @line_items.empty?
@status = :placed
calculate_total
end
private
def calculate_total
@line_items.sum(&:subtotal)
end
end
Aggregate Boundary Violations happen when code directly modifies aggregate internals rather than using the root. This breaks encapsulation and can violate invariants.
# Wrong - bypassing aggregate root
order.line_items << LineItem.new(product, quantity, price)
order.line_items.first.quantity = 10
# Correct - through aggregate root
order.add_item(product, quantity, price)
order.update_item_quantity(product.id, 10)
Transaction Spanning Multiple Aggregates creates contention and coupling. DDD prefers eventual consistency between aggregates through domain events.
# Problematic - modifying multiple aggregates in one transaction
def process_order(order_id, customer_id)
order = order_repo.find(order_id)
customer = customer_repo.find(customer_id)
order.place
customer.add_order(order)
customer.update_lifetime_value(order.total)
order_repo.save(order)
customer_repo.save(customer)
end
# Better - use events for cross-aggregate updates
def process_order(order_id)
order = order_repo.find(order_id)
order.place
order_repo.save(order)
# Event handler in customer context updates customer
DomainEvents.publish(OrderPlaced.new(
order_id: order.id,
customer_id: order.customer_id,
total: order.total
))
end
Overuse of Domain Services moves too much logic out of entities and value objects. Domain services should coordinate operations across aggregates or depend on external systems. Operations naturally belonging to an entity should live there.
# Overuse of service
class OrderService
def calculate_total(order)
order.line_items.sum(&:subtotal)
end
def add_item(order, product, quantity)
order.line_items << LineItem.new(product, quantity)
end
end
# Better - logic in entity
class Order
def total
@line_items.sum(&:subtotal)
end
def add_item(product, quantity)
@line_items << LineItem.new(product, quantity)
end
end
Ignoring Bounded Context Boundaries creates a single large model trying to serve multiple purposes. Different contexts need different models optimized for their needs.
Repository Returning Partial Aggregates breaks aggregate integrity. Repositories must return complete, valid aggregates.
# Wrong - partial aggregate
class OrderRepository
def find_basic_info(id)
Order.new(id: id, customer_id: customer_id)
# Missing line items
end
end
# Correct - complete aggregate
class OrderRepository
def find_by_id(id)
data = fetch_order_with_items(id)
reconstitute_complete_order(data)
end
end
Using Entity IDs for Equality in Value Objects destroys value object semantics. Value objects compare by attributes, not identity.
Bidirectional Associations between aggregates create coupling. Use unidirectional references through IDs instead.
# Problematic bidirectional association
class Order
attr_accessor :customer
end
class Customer
attr_accessor :orders
end
# Better - unidirectional reference
class Order
attr_reader :customer_id
end
# Repository loads customer if needed
customer = customer_repo.find_by_id(order.customer_id)
Reference
Core Building Blocks
| Building Block | Purpose | Identity | Mutability |
|---|---|---|---|
| Entity | Domain object with unique identity | Has unique ID | Mutable |
| Value Object | Descriptive aspect without identity | No unique ID | Immutable |
| Aggregate | Consistency boundary grouping entities | Root has ID | Mutable |
| Domain Service | Operation across aggregates or external dependency | Stateless | N/A |
| Repository | Collection-like interface for aggregates | N/A | N/A |
| Factory | Complex object construction | N/A | N/A |
| Domain Event | Record of business occurrence | Event ID | Immutable |
Strategic Patterns
| Pattern | Description | Use When |
|---|---|---|
| Bounded Context | Explicit boundary where model applies | Different teams or ubiquitous languages |
| Context Map | Relationships between bounded contexts | Multiple contexts need integration |
| Core Domain | Most valuable differentiating capability | Identifying where to focus effort |
| Generic Subdomain | Supporting capability without differentiation | Standard solutions exist |
| Shared Kernel | Shared part of model between contexts | Two contexts tightly coupled |
| Customer-Supplier | Upstream context supplies downstream | Clear dependency relationship |
| Anti-Corruption Layer | Translation between contexts | Protecting domain from external models |
| Published Language | Well-documented interchange format | Multiple consumers need integration |
Tactical Patterns
| Pattern | Ruby Implementation | Key Characteristics |
|---|---|---|
| Entity | Class with attr_reader :id and freeze id | Equality by ID, mutable state |
| Value Object | Class with freeze and immutable attributes | Equality by attributes, no ID |
| Aggregate Root | Entity controlling access to aggregate | Single transaction boundary |
| Repository | Interface with domain-language methods | Returns domain objects, hides persistence |
| Specification | Class with satisfied_by? method | Composable, reusable business rules |
| Domain Event | Immutable class with occurred_at | Past tense name, published after commit |
Common Invariant Patterns
| Invariant Type | Implementation Approach |
|---|---|
| Single aggregate invariant | Validate in aggregate root method |
| Cross-entity within aggregate | Validate in aggregate root, check all members |
| Cross-aggregate invariant | Use eventual consistency with domain events |
| Business rule requiring external data | Validate in domain service |
| Uniqueness constraint | Check in repository before save |
Repository Method Patterns
| Method Type | Example | Returns |
|---|---|---|
| Find by identity | find_by_id(id) | Single aggregate or nil |
| Find by attribute | find_by_email(email) | Single aggregate or nil |
| Find multiple | find_by_customer(customer_id) | Array of aggregates |
| Query by specification | find_all_matching(spec) | Array of aggregates |
| Persistence | save(aggregate) | Saved aggregate |
Event Naming Conventions
| Convention | Example | Usage |
|---|---|---|
| Past tense | OrderPlaced, PaymentReceived | Indicates completed action |
| Business language | CustomerRelocated not AddressChanged | Uses domain terminology |
| Specific | ItemAddedToCart not CartChanged | Precise about what occurred |
| Context-specific | In ordering: OrderShipped, In shipping: ShipmentDispatched | Respects bounded context language |
Aggregate Design Rules
| Rule | Rationale |
|---|---|
| Model one aggregate per transaction boundary | Maintains consistency within aggregate |
| Reference other aggregates by ID only | Reduces coupling, enables independent loading |
| Use eventual consistency between aggregates | Improves scalability and concurrency |
| Keep aggregates small | Reduces contention, improves performance |
| Design aggregates around invariants | Groups elements that must be consistent |
| Load complete aggregate from repository | Ensures validity of reconstituted aggregate |