LaunchKit
← All posts
· 12 min read · by The LaunchKit team · 0 views

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.

#rails #architecture

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.