The Rails monolith, and the boundary it does not give you
A Rails monolith stays one application right up to the day somebody has to answer which part of it
owns invoices, and nothing in Rails answers that. The framework has no concept of a module that
owns a table, no concept of a public interface between two halves of app/models, and no way to
fail a build because the reporting code reached into the billing code. That absence is what people
are actually asking about when they type "ruby on rails monolith", and it is a different question
from whether the monolith was the right call.
Everything below ran on this laptop: Ruby 4.0.5, Rails 8.1.3.1, PostgreSQL 17.7 on port 15432,
puma 8.0.2, packwerk 3.3.1, Apple M2 Max with 12 cores. The scratch application is a
rails new --database=postgresql with two domains in it, Billing and Shipping, and the numbers
come out of bin/rails runner, /usr/sbin/ab and a Minitest file that is printed in full further
down.
What one process buys, and it is one thing
The argument for a monolith is not fewer moving parts or a simpler deploy. Both are true and both
are recoverable at some cost. The thing that is not recoverable is the transaction. Two writes in
two domains inside one ActiveRecord::Base.transaction are atomic, and the same two writes with a
process boundary between them are not, whatever is bolted on afterwards.
Here is the block, run twice. The first version calls Billing in process. The second sends the
same insert to a puma process on 127.0.0.1 that writes to the same table with raw PG#exec_params:
begin
ActiveRecord::Base.transaction do
Billing.invoice!(order_id: 1, cents: 4200)
Shipping::Shipment.create!(order_id: 1, status: "ready")
raise ActiveRecord::Rollback.new("nope") if true
end
rescue => e
puts e.class
end
puts "in-process invoices=#{Billing::Invoice.count} shipments=#{Shipping::Shipment.count}"
begin
ActiveRecord::Base.transaction do
Net::HTTP.post_form(URI("http://127.0.0.1:9393/invoice?order_id=2"), {})
Shipping::Shipment.create!(order_id: 2, status: "ready")
raise "boom"
end
rescue => e
puts "#{e.class}: #{e.message}"
end
puts "over-http invoices=#{Billing::Invoice.count} shipments=#{Shipping::Shipment.count}"
bin/rails runner script_rollback.rb printed:
in-process invoices=0 shipments=0
RuntimeError: boom
over-http invoices=1 shipments=0
One invoice with no shipment against it, and no exception anywhere near the billing service to tell
it so. That row is the entire bill for the boundary, and paying it means a compensating write, a
reconciliation job, or an outbox table, in every direction, for every pair of writes that has to
agree. Nothing in the first version needed any of that, and the reason is BEGIN.
The call cost, and where the 3,000x goes
The other argument for one process is speed, and it is weaker than it sounds. Four measurements, all
on 127.0.0.1, all with the connection already open, n = 2000 except where noted:
n = 2000, all on 127.0.0.1, PostgreSQL 17.7 on port 15432
module method, no query : 0 us/call
raw pg SELECT, same process : 50 us/call
Billing.paid? (Active Record): 199 us/call
GET /paid on loopback puma : 132 us/call
network+puma overhead : 83 us/call
The first line is under the resolution of the benchmark. A separate run of 1,000,000 iterations put a plain module method call at 40 ns, and repeats of it landed between 40 and 56 ns. So a method call is roughly 3,000 times cheaper than a loopback HTTP request, which is the number everyone quotes, and it is correct and almost never the number that matters. Run the table five times and the two database lines move by about 10 percent either way, because this laptop was not idle; the ordering in it did not change in any run.
Look at the third and fourth lines. Billing.paid?(7), which is Invoice.exists?(order_id: 7)
through Active Record, cost 199 us. The same question asked over HTTP cost 132 us, because the
service on the other end is 20 lines of Rack running PG#exec_params and never builds a relation or
instantiates a model. The HTTP version won. The network and puma together accounted for 83 us of
it, and Active Record's own overhead on a single-row existence check was larger than that.
/usr/sbin/ab -n 2000 -c 1 -k against the same endpoint, run immediately after the table so both
saw the same machine load, reported 9792.35 requests per second and 0.102 ms per request. A
Net::HTTP client looping over its own already-started keep-alive connection, in the same minute,
measured 164 us. ab was the faster of the two by about 62 us per call, and that gap is the useful
part: ab is a C client whose own per-request cost rounds to nothing, so it measures what the service
can take, while Net::HTTP spends its 62 us building a request object and parsing a response in
Ruby before and after the socket does anything. Set that 62 us against the 83 us the hop itself cost
in the table: the client library you call the service with is the same order of expense as the
network you were worried about. Neither number supports "the network is too slow", and performance is not the reason to
keep one process.
Where domain code goes, and the directory that quietly does nothing
The obvious first move is a directory per domain under app, and it fails in a way that takes a
while to notice. app/billing/invoice.rb holding module Billing; class Invoice; end; end does not
define Billing::Invoice:
expected file app/billing/invoice.rb to define constant Invoice, but didn't
Hold on, I am eager loading the application.
Rails adds every direct subdirectory of app to the autoload paths as a root, so app/billing
became a root of its own and Zeitwerk expected the file to define Invoice at the top level. Printing
Rails.autoloaders.main.dirs after adding the directory shows it sitting there alongside
app/models. There is more on how that resolution works in
Rails autoloading with Zeitwerk; the fix here is one level of nesting:
app/domains/billing.rb
app/domains/billing/invoice.rb
app/domains/shipping.rb
app/domains/shipping/dispatch.rb
app/domains/shipping/shipment.rb
app/domains is the root, so app/domains/billing/invoice.rb maps to Billing::Invoice and no
configuration is needed at all. The namespace file earns its place by carrying the table prefix:
module Billing
def self.table_name_prefix
"billing_"
end
def self.paid?(order_id)
Invoice.exists?(order_id: order_id)
end
end
Billing::Invoice
billing_invoices
shipping_shipments
That is the whole of what Rails gives you for free, and it is worth having. billing_invoices and
shipping_shipments in a \dt listing are a schema that says who owns what, which is more than most
monoliths of this age can say.
isolate_namespace does not isolate constants
A mountable engine is the other in-repo answer, and it draws a line that is thinner than its name
suggests. This site runs two of them, engines/seo_monitor and engines/boilerplate_documentation,
and engines/seo_monitor/lib/seo_monitor/engine.rb:10 is isolate_namespace SeoMonitor. What that
buys is real: SeoMonitor.table_name_prefix answers "seo_monitor_", routes and helpers are
namespaced, and the engine's controllers do not collide with the host's.
What it does not buy is any restriction on constant lookup. Pointing packwerk at a copy of this
repository with engines/seo_monitor declared as a package and no dependencies turned up nine
references straight through the supposed boundary:
Every one of them, file:line:column on the left and the constant packwerk resolved on the right,
the two columns of each violation pasted together:
engines/seo_monitor/lib/seo/intents.rb:95:16 ::Boilerplate::Repository
engines/seo_monitor/lib/seo/intents.rb:95:49 ::Boilerplate::Repository
engines/seo_monitor/lib/seo/intents.rb:96:26 ::Boilerplate::Hub
engines/seo_monitor/lib/seo/intents.rb:98:27 ::Setting
engines/seo_monitor/lib/seo/intents.rb:140:8 ::Boilerplate::Repository
engines/seo_monitor/lib/seo/report.rb:157:18 ::Boilerplate::Repository
engines/seo_monitor/lib/seo/report.rb:157:51 ::Boilerplate::Repository
engines/seo_monitor/lib/seo/report.rb:158:28 ::Boilerplate::Hub
engines/seo_monitor/lib/seo/report.rb:165:28 ::Setting
Setting is an ApplicationRecord in the host application. An engine with isolate_namespace on it
reached into it twice, Ruby resolved it without a word, and the suite stayed green. An engine is a
packaging unit with its own routes and its own migrations. Treating it as an enforcement mechanism is
the mistake, and it is an easy one to make because the method is called isolate_namespace.
Making the boundary fail the build
packwerk is the part that actually enforces something, and the first thing to know about it is that
a fresh install enforces nothing. bundle exec packwerk init writes a root package.yml containing
enforce_dependencies: false, and on a tree with Shipping::Dispatch calling Billing::Invoice
directly, packwerk check answered:
📦 Packwerk is inspecting 35 files
...................................
📦 Finished in 0.42 seconds
No offenses detected
No stale violations detected
Green, and wrong. Enforcement starts when a directory gets its own package.yml with
enforce_dependencies: true and a dependencies list, at which point the same tree says:
app/domains/shipping/dispatch.rb:4:26
Dependency violation: ::Billing::Invoice belongs to 'app/domains/billing', but 'app/domains/shipping' does not specify a dependency on 'app/domains/billing'.
Are the constant and its references in the right packages?
Inference details: this is a reference to ::Billing::Invoice which seems to be defined in app/domains/billing/invoice.rb.
1 offense detected
packwerk check exits 1 on that and 0 when clean, which is the only property that makes it a gate
rather than a report. The first run also caught something worth writing down: with
dependencies: [], the violations included ::Shipping belongs to '.' and
::ApplicationRecord belongs to '.'. A package is a directory, and app/domains/shipping.rb is not
inside app/domains/shipping/, so the namespace module itself lives in the root package. Every
package needs "." in its dependency list before anything useful appears, and the consequence is
that Billing.paid? called from Shipping is never a violation while Billing::Invoice always is.
The public entry point is unenforced by construction. That happens to be the behaviour you want, but
it is a property of where the file sits, not a decision packwerk made.
The cost is small and it is not zero. On a copy of this site, 802 files, packwerk check printed
Finished in 1.55 seconds on an M2 Max, and 2.2 seconds passed on the clock because bundle exec
has to boot first. Do not read that number off the time builtin without looking at which column it
is in: the first run I measured reported 3.62s user at 215 percent CPU, which is packwerk using
several cores for 2.9 seconds of wall time, not a three-and-a-half second step. Adoption on an existing codebase runs
through packwerk update-todo, which writes the current violations into a package_todo.yml per
package so the build goes green while new violations still fail. The nine engine references above
went into a 27-line engines/seo_monitor/package_todo.yml and packwerk check exited 0 on the next
run. What that file is worth
depends entirely on whether anyone ever shortens it.
What packwerk does not see
packwerk resolves constants statically, which means two ordinary ways of reaching across a domain
are invisible to it. Both of these were in the shipping domain, with no dependency on billing
declared, and packwerk check reported No offenses detected and exited 0:
def self.paid?(order_id)
"Billing::Invoice".constantize.exists?(order_id: order_id)
end
def self.unpaid_order_ids
Shipment.connection.select_values(
"SELECT s.order_id FROM shipping_shipments s " \
"LEFT JOIN billing_invoices i ON i.order_id = s.order_id WHERE i.id IS NULL"
)
end
The second one is the one that will actually happen to you, because a report that needs both tables is a legitimate thing to want and a join is the obvious way to write it. The table prefix convention from earlier is what makes it catchable: a domain's tables all start with the domain's name, so a string literal in one domain naming another domain's prefix is a boundary crossing in SQL. That is a test, not a gem:
def test_no_domain_names_another_domains_table_in_raw_sql
offences = []
DOMAINS.each do |domain|
foreign = DOMAINS - [ domain ]
Dir["#{Rails.root}/app/domains/#{domain}/**/*.rb"].each do |file|
source = File.read(file)
foreign.each do |other|
prefix = "#{other}_"
next unless source.match?(/["'][^"']*\b#{prefix}\w+/)
offences << "#{file.sub(Rails.root.to_s + "/", "")} names a #{prefix}* table"
end
end
end
assert_empty offences, offences.join("\n")
end
With the join above restored to app/domains/shipping/unpaid_report.rb, packwerk stayed green and
that test did not:
1) Failure:
BoundariesTest#test_no_domain_names_another_domains_table_in_raw_sql [test/boundaries_test.rb:71]:
app/domains/shipping/unpaid_report.rb names a billing_* table.
Expected ["app/domains/shipping/unpaid_report.rb names a billing_* table"] to be empty.
1 runs, 2 assertions, 1 failures, 0 errors, 0 skips
It is a regex over source files and it will miss a table name built by interpolation. It costs
nothing, it runs in the same suite, and it covers the one crossing that static constant analysis
structurally cannot. The constantize case has no cheap equivalent and is left uncovered here.
The full file, six tests over the transaction behaviour, the autoload roots, the table prefixes, the packwerk exit and the SQL guard, ran clean:
6 runs, 17 assertions, 0 failures, 0 errors, 0 skips
The position, and what would change it
Keep one process. Put the domains in app/domains, give each one a table_name_prefix and a
namespace module that is the only thing other domains call, and add packwerk once a second person is
committing to the codebase. The transaction is worth more than the isolation you would buy by
splitting, and the isolation is available inside one process for the price of a CI step.
What would change that: a domain that needs to scale on a different axis from the rest, which usually means it is CPU-bound or it holds a queue that the web dynos should not be sharing, and a team that is already paying the reconciliation cost somewhere else and knows what it costs. Not request latency. The 98 us measured above is not what is slow about anybody's application.
What this page does not cover
This page does not cover extracting a domain once you have decided to, which is a different job with
strangler-fig routing and dual writes in it. It does not cover multiple databases, which is the
supported Rails way to give a domain its own storage inside one process and is the obvious next step
after the table prefix. It does not benchmark packwerk against rubocop-packs, packs-rails or the
sorbet-based checks, none of which were installed here. And the latency figures are loopback on one
laptop with no TLS, no authentication and no serialisation beyond to_json on a two-key hash, which
is the most favourable case a network hop will ever get.
Comments
No comments yet. Be the first.