DEV Community

Alex Aslam
Alex Aslam

Posted on

The Cartographer's Guide to Rails: Mapping Domains with Bounded Contexts

There’s a moment in every seasoned Rails developer’s journey when the app/models directory stops feeling like a well-organized toolbox and starts to resemble a junk drawer. You know the one. It’s where the User model, a proud and complex entity, lives next to a ReportGenerator concern, a PaymentService class, and a NotificationsHelper that’s seen things.

We started with a beautiful, coherent BlogPost model. Then came FeaturedBlogPost. Then SponsoredBlogPost. Then the BlogPost::ExportToNewsletter service object. Our once-simple domain has become a tangled web. We’ve been building a Monolith, and without a map, we’re getting lost in our own city.

It’s time to become cartographers.

This isn't a story about microservices or complex architecture. This is the story of bringing order to the monolith. It's the art of drawing boundaries within your application, not with new services, but with the elegant, underused tools already in your Rails kit: Modules and Namespaces. This is the art of the Bounded Context.

The Philosophical Foundation: What is a Bounded Context?

Before we write a line of code, we must internalize the concept. A Bounded Context is a conceptual boundary within your system where a particular model, a particular word, has a specific meaning.

Think of the word "Account".

  • In the Billing Context, an Account is a financial entity. It has a balance, invoices, and a payment_method. Its primary verb is charge.
  • in the Support Context, an Account is a customer profile. It has contact_emails, a support_tier, and open_tickets. Its primary verb is escalate.
  • In the Authentication Context, an Account is a credential set. It has a username, password_digest, and last_login_at. Its primary verb is authenticate.

Forcing all these meanings, all these attributes and behaviors, into a single Account model is what we call a God Object. It's a violation of the Single Responsibility Principle on a grand, domain-level scale. It couples unrelated parts of your business, making change dangerous and testing arduous.

A Bounded Context says: "Within this boundary, Account means this." It protects the integrity of a subdomain.

The Art of Enforcement: Modules as Your Brush and Chisel

Rails provides us with a powerful, yet beautifully simple, tool for drawing these boundaries: Modules. We won't be using them as mixins here, but as namespaces—a way to physically and logically group related concepts.

Let's translate our Account problem into code. Our goal is to move from this:

app/
├── models/
│   ├── account.rb          # 500 lines of chaos
│   ├── invoice.rb
│   └── support_ticket.rb
Enter fullscreen mode Exit fullscreen mode

To this:

app/
├── models/
│   ├── billing/
│   │   ├── account.rb
│   │   └── invoice.rb
│   ├── support/
│   │   ├── account.rb
│   │   └── ticket.rb
│   └── authentication/
│       ├── account.rb
│       └── credential.rb
Enter fullscreen mode Exit fullscreen mode

This structure screams its intent. The boundaries are visible from space.

Step 1: Crafting the Namespaced Models

Let's create our first bounded context: Billing.

app/models/billing/account.rb

module Billing
  class Account < ApplicationRecord
    # This table is `billing_accounts`
    self.table_name = "billing_accounts"

    has_many :invoices, class_name: "Billing::Invoice"
    validates :balance, presence: true

    def charge(amount)
      # ... Complex billing logic, isolated and safe.
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

app/models/billing/invoice.rb

module Billing
  class Invoice < ApplicationRecord
    # This table is `billing_invoices`
    self.table_name = "billing_invoices"

    belongs_to :account, class_name: "Billing::Account"
  end
end
Enter fullscreen mode Exit fullscreen mode

Notice the deliberate use of self.table_name. This isn't just about convention; it's a declaration. We are explicitly mapping our domain model to its specific persistence details. If you prefer Rails' conventions, your tables would be billing_accounts and billing_invoices.

Step 2: The Supporting Cast: Controllers and Jobs

The magic truly happens when we extend this boundary beyond models. A Bounded Context isn't just for data; it's for behavior.

app/controllers/billing/accounts_controller.rb

module Billing
  class AccountsController < ApplicationController
    def show
      # This finds a `Billing::Account`, scoped to its context.
      @account = Billing::Account.find(params[:id])
    end

    def charge
      @account.charge(params[:amount])
      # ... redirects and flashes
    end
  end
Enter fullscreen mode Exit fullscreen mode

app/jobs/billing/generate_invoice_job.rb

module Billing
  class GenerateInvoiceJob < ApplicationJob
    queue_as :billing

    def perform(account_id)
      account = Billing::Account.find(account_id)
      # ... Job logic that only knows about the Billing context
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Your routes now reflect this structure, making your API clean and intentional.

config/routes.rb

namespace :billing do
  resources :accounts, only: [:show, :create] do
    post :charge, on: :member
  end
  resources :invoices
end

# This gives us routes like:
# billing_account_path(@account)
# charge_billing_account_path(@account)
Enter fullscreen mode Exit fullscreen mode

The Masterpiece: A Glimpse of a Well-Mapped Domain

Let's see how this plays out in a real-world scenario. Imagine an admin panel where a support agent needs to comp a customer's account.

In the old, tangled world, this might be a single method Account#apply_credit that magically touches billing, sends an email, and creates a support log. It's a ripple effect of unknown consequences.

In our new, well-mapped world, the process is explicit and the boundaries are respected.

app/controllers/support/tickets_controller.rb

module Support
  class TicketsController < ApplicationController
    def resolve
      @ticket = Support::Ticket.find(params[:id])
      @support_account = Support::Account.find(@ticket.account_id)

      # We interact with the Billing context through a clear API
      billing_account = Billing::Account.find_by!(external_id: @support_account.billing_identifier)
      billing_account.credit(amount: 50, reason: "Customer service compensation")

      # We interact with the Notifications context through its API
      Notifications::CustomerMailer.with(account: @support_account).compensation_issued.deliver_later

      @ticket.close!
      redirect_to support_ticket_path(@ticket), notice: "Ticket resolved and customer was compensated."
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

This controller is not a God object. It's a orchestrator. It knows about the different contexts and coordinates between them, but it doesn't assume their internal responsibilities. The Billing::Account handles the financial rules. The Notifications::CustomerMailer handles the messaging. The Support::Ticket handles its own state.

This is the ultimate payoff: decoupled, testable, and coherent code.

The Payoff: Why We Endure the Refactor

This journey from chaos to clarity is not trivial. It requires deep thought about your domain. But the rewards are immense:

  1. Reduced Cognitive Load: A new developer can be pointed to app/models/billing and understand the entire financial subdomain without wading through unrelated authentication or support code.
  2. Eliminated Name Collisions: Billing::Account and Support::Account can evolve independently. Changing a billing attribute never breaks a support feature.
  3. Targeted Testing: Your test files can be namespaced too. test/models/billing/account_test.rb only needs to concern itself with billing logic. Your test suite becomes a reflection of your domain.
  4. Paved Path to Extraction: If the day comes that the Billing context needs to become a standalone service, the code is already a cohesive unit. The extraction is a mechanical process, not an archaeological dig.

The Cartographer's Call to Action

You are the architect of your application's destiny. The app/models directory is your canvas, and Modules are your brush. Stop accepting the tangled mess. Start drawing your maps.

Your first step is simple. Pick one area of your application that feels "tangled"—maybe it's payments, or content moderation, or user onboarding. Whiteboard it out. What are the core concepts? What are the verbs? Draw a boundary around them.

Then, create that first namespace. Move just one class. Feel the satisfaction of declaring a new, clean, intentional context. You are not just writing code; you are crafting a living map of your business domain. And that is the highest form of art in our craft.

Top comments (0)