DDD With Ruby on Rails

How I apply DDD inside a Rails monolith without abandoning ActiveRecord. Value objects, bounded context namespacing, and Rails Event Store, as they showed up in real codebases.

The first time I tried to do “proper DDD” on a Rails app, I deleted ActiveRecord from the domain layer and built hand-rolled repositories that read like a Java tutorial. It took two weeks. The team hated it. The codebase nearly doubled in size and nobody could find anything. I rolled the whole thing back on a Friday morning and we went to lunch. Yeah. That’s the day I stopped fighting the framework.

So this is the version of DDD I actually run inside Rails apps, after years of getting it wrong. The take: keep ActiveRecord, push domain logic out of callbacks, namespace by bounded context, and let Rails Event Store carry the integration seams. Pragmatism over purity, every time.

Keep ActiveRecord, push behavior up

The fastest way to ruin a Rails DDD project is to wrap every User model in a UserEntity that holds the same fields and adds nothing. You end up with two of everything and a translation layer in the middle. Don’t.

Instead, treat the ActiveRecord class as the aggregate root when it makes sense, and move behavior off callbacks and into explicit methods. Callbacks are where domain rules go to die. I’ve debugged enough after_save chains to be allergic.

# app/billing/subscription.rb
module Billing
  class Subscription < ApplicationRecord
    self.table_name = "billing_subscriptions"

    has_many :invoices, class_name: "Billing::Invoice"

    def cancel!(reason:, at: Time.current)
      raise AlreadyCancelled if cancelled_at.present?
      raise InvalidReason if reason.blank?

      transaction do
        update!(cancelled_at: at, cancellation_reason: reason)
        DomainEvents.publish(Billing::Events::SubscriptionCancelled.new(
          subscription_id: id, reason: reason, at: at
        ))
      end
    end
  end
end

Two things to notice. The state change and the event publish sit inside one transaction. And the method has a name, not a callback. If you grep the codebase for cancel! you find the only place cancellation happens. Try doing that with before_save :handle_cancellation_logic.

Value objects pay rent

The cheapest win in a Rails DDD effort is wrapping primitives. Money first, then anything else that’s been silently passed around as a string or a float. ActiveRecord plays well here through composed_of or a custom serializer.

# app/shared/money.rb
class Money
  include Comparable
  attr_reader :amount_cents, :currency

  def initialize(amount_cents, currency)
    raise ArgumentError, "cents must be Integer" unless amount_cents.is_a?(Integer)
    @amount_cents = amount_cents
    @currency = currency.to_s.upcase
  end

  def +(other)
    raise CurrencyMismatch unless currency == other.currency
    Money.new(amount_cents + other.amount_cents, currency)
  end

  def <=>(other)
    return nil unless currency == other.currency
    amount_cents <=> other.amount_cents
  end

  def to_s = format("%.2f %s", amount_cents / 100.0, currency)
end

# app/billing/invoice.rb
module Billing
  class Invoice < ApplicationRecord
    composed_of :total,
      class_name: "Money",
      mapping: [%w[total_cents amount_cents], %w[currency currency]]
  end
end

The integer cents thing is not academic. On a boutique fitness product we shipped at an agency I led, refunds were computed in three places, all using floats, and 11 percent of historical orders ended up off by a cent or more. Quietly wrong for months. The fix was one Money value object and a hard rule that no controller, no job, no webhook handler does arithmetic on price outside the domain. Took two days to migrate. The runbook from that incident still leads with “money is never a float, not even in a DTO”.

Namespace by bounded context

Stop organizing by Rails convention (models/, controllers/) and start organizing by context. app/billing/, app/catalog/, app/identity/, each one with its own models, services, and events. Same Rails app, same database if you want, but the seams are visible.

app/
  billing/
    subscription.rb
    invoice.rb
    services/cancel_subscription.rb
    events/subscription_cancelled.rb
  catalog/
    product.rb
    services/publish_product.rb
  identity/
    user.rb
    services/register_user.rb

Cross-context calls go through services, not by reaching into another context’s ActiveRecord class. Yes you technically can. No it’s not a context if you do. The rule I tell teams: if Billing references Catalog::Product from inside a query, you don’t have two contexts, you have one context with two folders.

Rails Event Store as the seam

Once you have contexts, the next problem is how they talk. In-process method calls couple them tighter than you want. Rails Event Store is the answer I keep landing on. Publish a domain event when a context-internal write completes, and let other contexts subscribe.

# config/initializers/event_store.rb
Rails.configuration.event_store = RailsEventStore::Client.new

# app/billing/services/cancel_subscription.rb
module Billing
  class CancelSubscription
    def call(subscription_id:, reason:)
      sub = Subscription.find(subscription_id)
      sub.cancel!(reason: reason)
      Rails.configuration.event_store.publish(
        Events::SubscriptionCancelled.new(data: {
          subscription_id: sub.id, reason: reason
        }),
        stream_name: "Billing::Subscription$#{sub.id}"
      )
    end
  end
end

# app/notifications/subscribers.rb
Rails.configuration.event_store.subscribe(
  ->(event) { Notifications::SendCancellationEmail.new.call(event) },
  to: [Billing::Events::SubscriptionCancelled]
)

The notification context doesn’t know billing exists. It listens for an event it cares about. Replace it tomorrow with a different consumer, nothing in billing changes. That decoupling is the whole point.

War story: callbacks that fanned out

At the digital product agency I led engineering at, a client product had a User model with seven after_save callbacks. One of them enqueued a welcome email. Another updated a Redis counter. A third synced a Mailchimp segment. A fourth audit-logged. The product manager asked for a script to bulk-update a flag on 200,000 users. We ran it overnight. It triggered 1.4 million side effects. The Mailchimp API key got rate-limited and stayed limited for the next 18 hours.

First wrong fix was to add if: :should_run? guards on each callback. That worked for that one bug and broke three other features that depended on the callbacks running on update. The real fix was to delete the callbacks and replace them with explicit service objects called from the controllers that actually wanted the side effect. The bulk-update script then did the write directly, no fan-out. Boring. Correct. We pulled this pattern into every Rails app on the portfolio after that.

Takeaways

  • Keep ActiveRecord. Wrap behavior in explicit methods, not callbacks. Callbacks fan out and you’ll regret them.
  • Value objects pay rent immediately. Money first. Integer cents, no exceptions.
  • Namespace by bounded context inside app/. The folder layout is the architecture.
  • Cross-context communication goes through Rails Event Store, not direct ActiveRecord reads.
  • DDD purity that fights the framework loses. Be pragmatic. The team has to live in this code.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.