ruby

7 Domain-Driven Design Patterns That Tame Complexity in Large Rails Applications

Learn how 7 Domain-Driven Design patterns can transform complex Ruby on Rails apps into maintainable, scalable codebases. Start building cleaner Rails apps today.

7 Domain-Driven Design Patterns That Tame Complexity in Large Rails Applications

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.

Keywords: Domain-Driven Design Rails, DDD Ruby on Rails, Rails application architecture, Rails design patterns, bounded contexts Rails, Rails engines tutorial, value objects Ruby, aggregate pattern Rails, repository pattern Rails, domain services Rails, domain events Rails, anti-corruption layer Rails, Rails large application structure, Rails MVC limitations, complex Rails applications, Rails bounded contexts engines, Ruby on Rails DDD patterns, Rails service objects, Rails code organization, domain modeling Rails, Rails application scalability, Rails engine namespacing, Ruby value objects immutable, Rails aggregate root, Rails repository pattern example, domain-driven design patterns explained, Rails transaction management, Rails pub sub events, event-driven Rails architecture, Rails bounded context example, Rails engine isolate namespace, Ruby on Rails business logic, Rails model bloat solution, Rails fat model refactoring, Rails clean architecture, Rails payment gateway integration, anti-corruption layer example Rails, Rails domain events ActiveSupport, DDD bounded contexts explained, Rails application design best practices, Ruby on Rails enterprise patterns, Rails checkout service example, Rails code complexity management, Rails maintainable codebase, domain-driven design Ruby example, Rails service layer pattern, Rails query repository, Rails external API integration, Ruby on Rails scalable architecture, DDD aggregate pattern example



Similar Posts
Blog Image
Why Should You Use CanCanCan for Effortless Web App Permissions?

Unlock Seamless Role-Based Access Control with CanCanCan in Ruby on Rails

Blog Image
Mastering Rust's Const Generics: Compile-Time Graph Algorithms for Next-Level Programming

Discover how Rust's const generics revolutionize graph algorithms, enabling compile-time checks and optimizations for efficient, error-free code. Dive into type-level programming.

Blog Image
Is Draper the Magic Bean for Clean Rails Code?

Décor Meets Code: Discover How Draper Transforms Ruby on Rails Presentation Logic

Blog Image
Building Global Rails Apps: 7 Essential Gems for Professional Internationalization

Learn to internationalize Rails apps with 7 essential Ruby gems. From basic i18n setup to database translations, URL localization & team management tools. Complete guide with code examples.

Blog Image
Modern JavaScript Patterns for Rails Developers: From Import Maps to Turbo Streams

Learn practical JavaScript patterns for Rails applications. Discover import maps, Stimulus controllers, lazy loading, and Turbo Streams to build maintainable interactive apps. Start simple, scale smart.

Blog Image
How Can Ruby Transform Your File Handling Skills into Wizardry?

Unleashing the Magic of Ruby for Effortless File and Directory Management