When I first started building Ruby on Rails applications, the simplicity was a major draw. I could quickly turn ideas into working software. But as those applications grew, things changed. Models became stuffed with unrelated methods, controllers handled too much logic, and testing turned slow and brittle. The standard MVC structure, perfect for smaller projects, started to show its limits under the weight of complex business rules.
This is a common story in software development. The initial elegance fades as complexity increases. I learned that to manage this, I needed to think differently about organizing code. This led me to Domain-Driven Design. DDD is an approach that focuses on modeling the core business concepts directly in the software. It provides a set of patterns to tackle complexity head-on. Over the years, I’ve applied several of these patterns to large Rails applications with great success.
I want to share seven of these patterns with you. I’ll explain each one simply, as if I’m talking to a colleague, and provide detailed code examples from my own work. My aim is to show you how these ideas can make your large Rails codebase more understandable and easier to change.
In a big system, different departments often use the same words to mean different things. In an e-commerce app, the “product” team cares about physical attributes and stock levels. The marketing team cares about discounts and descriptions. Mixing these ideas in one place creates confusion.
Bounded Contexts solve this by drawing clear boundaries around each specific business area. Each context has its own models and language. In Rails, I use engines to create these boundaries. An engine is like a mini-application inside your main app.
Here is how I set one up for an inventory domain.
# Create a new engine: rails plugin new inventory --mountable
# lib/inventory/engine.rb
module Inventory
class Engine < ::Rails::Engine
isolate_namespace Inventory
end
end
# config/routes.rb in the main application
Rails.application.routes.draw do
mount Inventory::Engine, at: '/inventory'
end
The isolate_namespace line is key. It ensures that all controllers, models, and views inside the Inventory module are sealed off. This means Inventory::Product is a completely separate class from any Product class elsewhere.
Inside this engine, I define models that make sense for inventory.
# app/models/inventory/product.rb
module Inventory
class Product < ApplicationRecord
# Explicit table name avoids conflicts
self.table_name = 'inventory_products'
# Associations stay within the context
has_many :stock_items
has_many :reservations
validates :sku, presence: true, uniqueness: true
# Domain logic lives here
def in_stock?
available_quantity.positive?
end
def available_quantity
total_stock = stock_items.sum(:quantity)
total_reserved = reservations.where(status: 'active').sum(:quantity)
total_stock - total_reserved
end
# A command method with clear failure states
def reserve(quantity, for_order_id:)
if available_quantity >= quantity
reservations.create!(
quantity: quantity,
order_id: for_order_id,
status: 'active'
)
true
else
false
end
end
end
end
# app/models/inventory/stock_item.rb
module Inventory
class StockItem < ApplicationRecord
belongs_to :product
belongs_to :warehouse
validates :quantity, numericality: { greater_than_or_equal_to: 0 }
end
end
I once worked on a system where the sales and inventory teams were constantly stepping on each other’s code. By splitting them into separate engines, each team could develop and test their part independently. The mental load decreased immediately.
Not everything in your domain has a unique identity. Some things are just values. A customer has an identity—their customer ID. But their email address or a money amount is just a value. If two customers have the same email, they are the same value. If you have ten dollars, it’s the same value as another ten dollars.
In Rails, we mostly deal with Active Record objects, which are entities. But for values, I often use plain Ruby objects. They are simpler and safer because they don’t change.
Here’s how I model a money value object.
# app/domain/value_objects/money.rb
class Money
# Make the object immutable after creation
attr_reader :amount, :currency
def initialize(amount, currency = 'USD')
@amount = BigDecimal(amount.to_s)
@currency = currency.to_s.upcase
freeze # Prevents any changes to this object
end
# Define equality based on value, not object identity
def ==(other)
other.is_a?(Money) &&
amount == other.amount &&
currency == other.currency
end
alias eql? ==
# For use in hashes
def hash
[amount, currency].hash
end
# Basic arithmetic operations that return new objects
def +(other)
check_currency_match(other)
Money.new(amount + other.amount, currency)
end
def -(other)
check_currency_match(other)
Money.new(amount - other.amount, currency)
end
def *(multiplier)
Money.new(amount * multiplier, currency)
end
def to_s
format('%.2f %s', amount, currency)
end
private
def check_currency_match(other)
raise CurrencyMismatchError, "Currencies must match" unless currency == other.currency
end
end
# Use it in an entity
class OrderLineItem < ApplicationRecord
# Store money as a serialized object or split into columns
serialize :price, Money
def total_price
price * quantity
end
end
# In a controller or service
item = OrderLineItem.new(quantity: 2)
item.price = Money.new(19.99, 'USD')
puts item.total_price # Outputs: 39.98 USD
Using value objects like this made tax calculations in one of my projects much cleaner. Instead of passing around floats and currency codes separately, I passed a single Money object. It reduced errors and made the code read like the business language.
Business rules often involve a group of objects that must stay consistent. You can’t have an order line item without an order. You can’t reduce inventory without recording where it went. An Aggregate is a cluster of objects treated as a single unit. One entity is the root, and you only interact with the aggregate through this root.
In Rails, this means being careful with associations and transactions.
Consider an Order aggregate. The order is the root, and its line items are part of it. A rule might be that an order’s total must always be positive.
class Order < ApplicationRecord
has_many :line_items, dependent: :destroy
# Ensure all changes happen together
def add_product(product_id, unit_price, quantity = 1)
ActiveRecord::Base.transaction do
item = line_items.find_or_initialize_by(product_id: product_id)
if item.persisted?
item.quantity += quantity
else
item.quantity = quantity
item.unit_price = unit_price
end
item.save!
recalculate_total!
end
end
def remove_product(product_id)
ActiveRecord::Base.transaction do
line_items.where(product_id: product_id).destroy_all
recalculate_total!
end
end
def recalculate_total!
new_total = line_items.sum('quantity * unit_price')
update_column(:total_amount, new_total) # Bypass callbacks if needed for speed
end
# A business operation on the aggregate
def place
raise "Order must have items" if line_items.empty?
raise "Total must be positive" unless total_amount.positive?
update!(status: 'placed', placed_at: Time.current)
# Perhaps publish an event here
end
end
# Usage
order = Order.new(customer_id: 123)
order.add_product(456, Money.new(29.99))
order.add_product(456, Money.new(29.99)) # Adds quantity
order.place
I used this pattern for a booking system. A Booking aggregate included the booking itself, passenger details, and seats. Any change to passengers or seats had to go through the Booking root. This prevented the system from getting into an invalid state, like double-booking a seat.
Active Record gives us a lot of query methods right on the model. But sometimes, you need more control over how data is fetched. A Repository provides a clean interface for retrieving and storing domain objects. It hides the details of the data source.
For most cases in Rails, the model class itself is a fine repository. But when queries get complex, or you’re integrating with other systems, a separate repository class helps.
# app/repositories/order_repository.rb
class OrderRepository
def find(id)
Order.includes(:customer, line_items: :product).find(id)
end
def find_recent_for_customer(customer_id, limit: 10)
Order
.where(customer_id: customer_id)
.where('placed_at > ?', 30.days.ago)
.order(placed_at: :desc)
.limit(limit)
.to_a
end
def save(order)
order.save
end
def delete(order)
order.destroy
end
# A complex query encapsulated
def find_high_value_orders(threshold_amount)
Order
.joins(:line_items)
.group('orders.id')
.having('SUM(line_items.quantity * line_items.unit_price) > ?', threshold_amount)
.where(status: 'completed')
.to_a
end
end
# Using it in a service
class CustomerDashboardService
def initialize(customer_id, order_repository: OrderRepository.new)
@customer_id = customer_id
@order_repository = order_repository
end
def recent_order_summary
orders = @order_repository.find_recent_for_customer(@customer_id)
# Build a summary object
end
end
In an application that talked to both a main database and a legacy reporting system, I created repositories for each data source. The domain services didn’t need to know where the data came from. This made testing much easier, as I could swap in a fake repository.
Some business operations are naturally procedural. They involve multiple domain objects and don’t fit neatly into a single entity. These are perfect for Domain Services. A domain service is a stateless object that performs a specific domain task.
I use these to keep my models thin and focused on their own data and rules.
Imagine a process that checks out a shopping cart. It needs to verify inventory, calculate tax, apply promotions, and create an order. That’s too much for a Cart model.
# app/services/checkout_service.rb
class CheckoutService
class CheckoutFailed < StandardError; end
def initialize(cart_id, customer_id)
@cart = Cart.find(cart_id)
@customer = Customer.find(customer_id)
end
def call
# This service coordinates multiple steps
reserved_items = reserve_inventory
order = create_order(reserved_items)
charge_payment(order)
finalize_order(order)
order
rescue => e
# Compensating actions: release inventory, etc.
release_inventory(reserved_items) if reserved_items
raise CheckoutFailed, "Checkout failed: #{e.message}"
end
private
def reserve_inventory
# Use the inventory bounded context
client = Inventory::Client.new
items = @cart.items.map { |i| { product_id: i.product_id, quantity: i.quantity } }
result = client.reserve_items(items)
raise CheckoutFailed, "Inventory reservation failed" unless result.success?
result.reserved_items # Return tokens or IDs for later use
end
def create_order(reserved_items)
Order.transaction do
order = Order.create!(
customer: @customer,
status: 'pending_payment'
)
@cart.items.each do |cart_item|
order.line_items.create!(
product_id: cart_item.product_id,
quantity: cart_item.quantity,
unit_price: cart_item.unit_price
)
end
order.associate_inventory_reservation(reserved_items)
order
end
end
def charge_payment(order)
payment_client = PaymentGateway::Client.new
amount = order.total_amount
response = payment_client.charge(@customer.payment_token, amount.cents, amount.currency)
raise CheckoutFailed, "Payment failed" unless response.success?
order.update!(payment_transaction_id: response.transaction_id)
end
def finalize_order(order)
order.update!(status: 'completed')
@cart.destroy!
# Maybe send a confirmation email via a background job
OrderConfirmationMailer.deliver_later(order.id)
end
def release_inventory(reserved_items)
client = Inventory::Client.new
client.release_items(reserved_items)
end
end
# Usage in a controller
def create
service = CheckoutService.new(current_cart.id, current_user.id)
@order = service.call
redirect_to @order, notice: 'Order placed successfully!'
rescue CheckoutService::CheckoutFailed => e
redirect_to cart_path, alert: e.message
end
This pattern was a game-changer for a subscription billing system I worked on. The process of renewing a subscription involved prorating, invoicing, and notifying. Putting all that in a SubscriptionRenewalService made the flow clear and testable.
Things happen in a system. An order is placed. A payment is received. These occurrences are important, and other parts of the system might need to react. Domain Events are a way to announce that something has happened, without the source needing to know who is listening.
This creates loose coupling. In Rails, I often use the ActiveSupport::Notifications system or a simple pub/sub gem.
# Define an event class
# app/events/order_placed_event.rb
OrderPlacedEvent = Struct.new(:order_id, :customer_id, :total_amount, :placed_at, keyword_init: true)
# In the service that places the order
class OrderService
def place_order(order_id)
order = Order.find(order_id)
order.place! # Assume this updates status
# Publish the event
event = OrderPlacedEvent.new(
order_id: order.id,
customer_id: order.customer_id,
total_amount: order.total_amount,
placed_at: order.placed_at
)
EventBus.publish('order.placed', event)
end
end
# A subscriber in another part of the app, perhaps in an engine
# config/initializers/event_subscribers.rb
EventBus.subscribe('order.placed') do |event|
# Update a read-optimized table for reporting
Analytics::OrderSnapshot.create!(
order_id: event.order_id,
customer_id: event.customer_id,
revenue: event.total_amount,
date: event.placed_at.to_date
)
# Trigger a background job for email
CustomerNotificationJob.perform_later(event.customer_id, 'order_placed', event.order_id)
end
# A simple event bus implementation
# app/core/event_bus.rb
module EventBus
@subscribers = Hash.new { |h, k| h[k] = [] }
def self.subscribe(event_name, &block)
@subscribers[event_name] << block
end
def self.publish(event_name, event)
@subscribers[event_name].each do |subscriber|
subscriber.call(event)
rescue => e
Rails.logger.error("Event subscriber failed: #{e.message}")
end
end
end
In a microservices architecture I worked with, events were crucial. The main Rails app would publish events like InvoiceGenerated, and separate services for email and analytics would consume them. This kept the core app focused and allowed other teams to move independently.
When your application talks to an external service or another bounded context, their models will be different. Let’s say you integrate with a legacy shipping system that has its own idea of an “order.” If you let their model leak into your code, it can corrupt your domain logic.
An Anti-corruption Layer sits between your domain and the external system. It translates their model into yours, and vice versa.
From the initial example, the Inventory::Client is an ACL. Let me show a more detailed version for a payment gateway.
# The external gateway has a complex API
# Our domain has a simple Payment concept
# app/clients/payment_gateway_client.rb
class PaymentGatewayClient
class GatewayError < StandardError; end
def initialize(api_key: Rails.configuration.payment_gateway.api_key)
@api_key = api_key
end
# Our domain method: charge a customer
def charge(customer_token, money)
# Translate our domain objects to the gateway's request format
request_body = {
source: customer_token,
amount: money.amount * 100, # Convert to cents
currency: money.currency.downcase,
description: "Charge for order"
}
response = make_request(:post, '/charges', request_body)
if response.success?
# Translate the gateway response to our domain
OpenStruct.new(
success: true,
transaction_id: response.body['id'],
amount_charged: Money.new(response.body['amount'] / 100.0, response.body['currency'].upcase)
)
else
OpenStruct.new(
success: false,
error_code: response.body['error']['code'],
message: "Gateway error: #{response.body['error']['message']}"
)
end
end
def refund(transaction_id, money)
# Similar translation logic
end
private
def make_request(method, path, body)
# Use Faraday or HTTParty
connection = Faraday.new(url: 'https://api.paymentgateway.com') do |conn|
conn.request :json
conn.response :json
conn.adapter Faraday.default_adapter
conn.headers['Authorization'] = "Bearer #{@api_key}"
end
connection.public_send(method, path, body)
end
end
# In our domain service
class PaymentService
def initialize(payment_gateway: PaymentGatewayClient.new)
@gateway = payment_gateway
end
def process_payment(order, customer_token)
result = @gateway.charge(customer_token, order.total_amount)
if result.success?
order.payments.create!(
transaction_id: result.transaction_id,
amount: result.amount_charged,
status: 'completed'
)
true
else
order.payments.create!(
amount: order.total_amount,
status: 'failed',
gateway_error: result.message
)
false
end
end
end
I used this pattern when integrating with a third-party CRM. Their data model was vast and messy. The anti-corruption layer took our clean Contact object, mapped it to their fifty-field API call, and then translated the response back. When they changed their API, we only had to update the client class.
These patterns have helped me build Rails applications that can scale in complexity. They require more thought upfront than throwing everything into app/models. But the payoff is immense. The code becomes a map of the business, which makes it easier for new developers to understand and for domain experts to discuss.
Start by identifying one bounded context in your application. Maybe it’s “billing” or “notifications.” Try isolating it with an engine. Then, look for a value object you can extract, like EmailAddress or PhoneNumber. Small steps lead to big improvements.
Remember, the goal isn’t to use every pattern everywhere. It’s to use the right pattern where it makes the code clearer and more aligned with the business. With these tools, you can keep your large Rails application maintainable for years to come. I’ve seen it work, and I believe it can work for you too.