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 abalance
,invoices
, and apayment_method
. Its primary verb ischarge
. - in the Support Context, an
Account
is a customer profile. It hascontact_emails
, asupport_tier
, andopen_tickets
. Its primary verb isescalate
. - In the Authentication Context, an
Account
is a credential set. It has ausername
,password_digest
, andlast_login_at
. Its primary verb isauthenticate
.
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
To this:
app/
├── models/
│ ├── billing/
│ │ ├── account.rb
│ │ └── invoice.rb
│ ├── support/
│ │ ├── account.rb
│ │ └── ticket.rb
│ └── authentication/
│ ├── account.rb
│ └── credential.rb
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
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
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
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
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)
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
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:
- 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. - Eliminated Name Collisions:
Billing::Account
andSupport::Account
can evolve independently. Changing a billing attribute never breaks a support feature. - 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. - 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)