When Rails gives you a schema instead of a boundary
Let's have a look at the typical SaaS application created with Rails where different parts of the application communicate with each other without strong boundaries:

The schema makes sense; every part of the diagram is interested in "something" from the other parts, for example:
- Reporting needs financial information from Billing, interesting metrics from the Core domain, and the personal data from Identity.
- Core domain needs to know who is performing the action from Identity and if the person is eligible for that from Billing.
- Billing wants to know who receives the invoice from Identity, and the usage that drives the price from the Core domain.
There can be unlimited combinations based on the core domain of your application; these are just the sample ones that I often saw in the last few years when working on larger legacy applications.
The schema is right; the dependencies exist. The communication is what makes it harder and harder to maintain the application and to onboard new developers.
Broken onboarding
When I joined Arkency, I was surprised by the rule they have regarding the onboarding process: you ship your first change to the project on the very first day, and it’s not about the fix for a typo in the README but the contribution to the core of the system.
This can be challenging when you join a large Rails codebase. Over the last dozen or so years, I joined or onboarded people to tens of Rails projects of various sizes and complexity. Mostly, it was somewhat painful for a few reasons.
You often jump into a rabbit hole
You look at the controller which invokes a service object. The service object operates on two models and manipulates the data. You find out that the models have some callbacks that trigger jobs and call other service objects. After a few minutes, you don’t even remember what the name of the controller was that started the whole journey.
Exploring a Rails codebase that does not enforce strong boundaries is like following a single wire in a server room where nobody labeled anything. You trace it behind the rack, it disappears into a bundle with forty others, and when it comes out the far side you can no longer tell which one was yours.
You try to understand the larger context, but it’s not possible. Rails code isn’t confusing line by line; each hop it’s perfectly readable. That’s the trap.
You pull the string but can't predict the outcome
After you did the research, you finally had some context and made the changes. Tests are passing, code looks good and reasonable. You pulled the string, and nothing happened in front of you, but you have no idea what happened behind you.
The safety mechanism is not the architecture but the memory of the person who's been there three years. That person is a single point of failure; they don't scale, and they eventually leave, which means shipping on day one isn't really enabled by the codebase; somebody else's memory underwrites it.
The green suite feels like proof, but a test suite has the same shape as the code it covers. No seams in the app, no seams in the specs — they cover the paths someone already thought of. Onboarding is the state of not knowing which paths those are.
Boundaries that schema can't give you
It starts with identifying the business's subdomains. Those are not your invention, they describe how the company actually works, and which part of it is the reason customers pay you rather than a competitor. What your decision is where you draw the lines between them. In DDD, those lines are called bounded contexts, and the honest definition is this: a bounded context is the space in which one word means exactly one thing.
Take the most innocent word in your schema. To Identity, a user is a credential holder - an email, a password digest, a confirmed session. To Billing, that same person is a seat, something that costs money on the first of the month. To Reporting, they are a dimension, a label on a row. And in the core domain, they are whoever does the actual work your product exists for. Four meanings. One users table, one User class, and a has_many for every one of them.
There is no clever model that makes those four agree, and you should stop trying to find one. A deactivated user is still a billable seat until the period ends. An invited user who never accepted is a row in Identity and nothing in Billing. A boundary is the admission that both are right, in their own space.
Departments work the same way. They talk to each other constantly, but through agreed handovers: a request, a report, a form. Nobody walks into another department's filing cabinet. Rails, by default, gives every context a key to every cabinet - and that is the convenience you give up on purpose. Most of the time, it means not declaring the association you could have declared.
The boundary is a decision, not a dependency
There is no gem for this. A bounded context is not a class you inherit from or a mixin you include - it has no representation in code at all. It is a directory and an agreement about what may cross its edge.
app/
identity/
user.rb
credential.rb
billing/
seat.rb
subscription.rb
Charge_for_period.rb
reporting/
Inside, the objects are ordinary Ruby: a constructor, a few public methods, the rules the business actually described. Billing::Seat knows an account, an occupant identifier, and the dates it was occupied. It does not know what an email address is, it cannot be logged in as, and it has no opinion about passwords - because none of that has ever changed an invoice.
module Billing
class Seat
def initialize(account_id:, occupant_id:, occupied_from:)
# ..
end
def billable_days_in(period)
# …
end
end
end
No callbacks, no belongs_to, no inheritance from anything. Most of these files would run outside Rails, and that is a feature rather than a party trick: the rules of your core domain stop depending on a framework, a database, and a params hash.
What makes it a boundary is not the code, it's the restraint. Billing::Seat never mentions Identity::User. Nothing in the framework will stop you from writing that reference - Rails will happily autoload it. The boundary exists exactly as long as the team decides it does.
Being honest about the cost
Two decisions get confused here, and it's worth keeping them apart. Drawing a boundary is one thing: a directory, and a rule about what may cross its edge. Giving up ActiveRecord inside that boundary is another, and it is the expensive one — you lose where, includes, validations, the whole free CRUD.
The first is cheap and almost always worth it. The second depends on what's inside. Your core subdomain is where the complexity lives and where the business earns its advantage; that's where plain objects pay for themselves. A generic subdomain rarely is. Billing can be a Rails model talking to Stripe and still sit behind a proper boundary - it just won't have hand-written domain objects behind it.
This is also why discovering subdomains matters before anything else: it's the thing that tells you where to spend. And it's why the Rails way remains the right answer in plenty of places. Domain-Driven Rails doesn't mean ceremony everywhere.
There is one more cost, and it's the one nobody budgets for. It isn't the missing where - it's the translation at the edges. When a user is deactivated, Identity is done. Billing still has a seat that stays billable until the end of the period, and somebody has to sit down and write that mapping by hand. It feels like duplication. It is not. That mapping is the place where the two meanings of "user" are forced to meet, and forced to be explicit about how they differ. That's the bill; it arrives later than you expect, and it is precisely what you're paying for: the ambiguity gets caught in code you wrote on purpose, instead of being passed along quietly the way it was in the report nobody questioned.