Domain‐Driven Design (DDD) Best Practices - fast-programmer/outboxer GitHub Wiki

Domain-Driven Design (DDD) Best Practices

Introduction

This guide offers concise, opinionated best practices for applying Domain-Driven Design (DDD) in Ruby applications, using the Accountify::InvoiceService.issue method as a case study.


1. Model the Ubiquitous Language

Use the same language in code that you use with domain experts. Invoices are “issued,” not “created” or “saved.”

status: Invoice::Status::ISSUED

Avoid:

status: "created"  # vague and not domain-aligned

2. Use Value Objects for Precision

Use Money instead of Integer or Float to prevent rounding errors and increase clarity.

total_amount: Money.new(25000, "AUD")

Avoid:

total_amount: 250.00  # prone to floating point errors

3. Application Services as Aggregate Roots

Application services (e.g., InvoiceService) can act as aggregate roots at the service level by applying model changes and creating an event.

class InvoiceService
  def self.issue(...)
    ...
  end
end

This structure is useful when the aggregate logic spans multiple models or bounded contexts.


4. Keep Application Services Stateless

Use stateless services that coordinate domain logic, but do not hold state themselves.

Accountify::InvoiceService.issue(...)

5. Use Domain Events to Capture Facts

Create explicit domain events to document and react to domain changes.

event = InvoiceIssuedEvent.create!(...)

Benefits:

  • Enables event-driven architecture
  • Encourages decoupled systems

6. Use the Outbox Pattern for Reliability

Queue events in the same transaction as the state change.

Outboxer::Message.queue(messageable: event, queued_at: current_time)

Why:

  • Guarantees delivery of the event if the transaction commits

7. Explicit Time Handling

Always pass Time explicitly to ensure testability and deterministic behavior.

time: Time.current

8. Use Hash Returns for Simple Service Output

Return a hash with symbol keys for predictable service outcomes.

{ id: invoice.id, event_id: event.id }

9. Transactions Should Encapsulate All Writes

Use ActiveRecord::Base.transaction to ensure atomicity of the operation.

ActiveRecord::Base.transaction do
  ...
end

10. Avoid Premature Abstractions

Keep logic explicit and close to the domain until duplication or complexity forces a refactor.


Summary Checklist

  • Use Ubiquitous Language
  • Favor Value Objects
  • Use Application Services as Aggregate Roots
  • Keep Services Stateless
  • Emit Domain Events
  • Use Outbox Pattern
  • Pass Time Explicitly
  • Return Hashes
  • Wrap Writes in Transactions
  • Avoid Premature Abstraction

Final Thoughts

This guide exemplifies how applying DDD in Ruby can lead to more expressive, robust, and maintainable code. Let application services coordinate your domain logic and events, keep naming idiomatic, and align code structure with domain behavior.


Full Example

# app/models/invoice.rb
class Invoice < ApplicationRecord
  module Status
    ISSUED = "issued"
    DRAFT  = "draft"
    VOIDED = "voided"
  end
end

# app/models/invoice_issued_event.rb
class InvoiceIssuedEvent < ApplicationRecord
end

# app/services/accountify/invoice_service.rb
module Accountify
  class InvoiceService
    # Issues an invoice.
    #
    # @param total_amount [Money] The full amount of the invoice
    # @param time [Time] The Time dependency
    # @return [Hash{Symbol => Integer}] A hash with :id and :event_id
    def self.issue(total_amount:, time:)
      ActiveRecord::Base.connection_pool.with_connection do
        ActiveRecord::Base.transaction do
          current_time = time

          invoice = Invoice.create!(
            status: Invoice::Status::ISSUED,
            total_amount_fractional: total_amount.fractional,
            total_amount_currency_iso_code: total_amount.currency.iso_code,
            issued_at: current_time,
            created_at: current_time,
            updated_at: current_time
          )

          event = InvoiceIssuedEvent.create!(
            body: {
              invoice_id: invoice.id,
              issued_at: invoice.issued_at.iso8601,
              status: invoice.status,
              total_amount_cents: invoice.total_amount_fractional,
              currency: invoice.total_amount_currency_iso_code,
              created_at: current_time
            },
            created_at: current_time
          )

          Outboxer::Message.queue(messageable: event, queued_at: current_time)

          { id: invoice.id, event_id: event.id }
        end
      end
    end
  end
end