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.
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.
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”.
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.
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.
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.
app/. The folder layout is the architecture.Thanks for reading. If you’ve got thoughts, send them my way.