CrackedRuby CrackedRuby

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