Skip to content

Rails

Customers and Billing are two Rails Engines, and each is imported as its own Carton. Customers registers customers. Billing imports Customers, validates invoices, calculates tax, and stores them. Rails loads once, in a framework Carton. A shop Carton mounts both Engines and exports the Rack app to Main. Source on GitHub.

The example uses only public Rails, Zeitwerk, and Carton APIs. It adds no runtime patches.

  1. POST /customers with customer: { name: "Ada" } registers a customer and returns its ID and trimmed name.
  2. POST /invoices.json with invoice: { customer_id: 1, subtotal_cents: 4200 } issues an invoice for that customer. Billing imports Customers and uses its Directory to resolve the customer before persisting the invoice.
  3. GET /invoices/1.json returns id, customer_id, subtotal_cents, tax_cents, and total_cents; GET /invoices/1 renders HTML with the escaped customer name and total.

Both POSTs return 201. Tax is ten percent, rounded half up in integer cents, so 4200 gives 420 tax and 4620 total. The stored tax is kept when the invoice is read later. Blank customer names or invalid subtotals return 422 with domain errors. An unknown customer returns 404.

Subtotals must be non-negative Integers after the controller coerces form strings. Fractional and malformed values are rejected.

main.rb # repeatable Rack demo, temporary SQLite
cartons/
framework/lib/framework.rb # shared Rails implementation and base classes
customers/
lib/customers.rb # Entrypoint, private framework Import, local loader
lib/customers/{customer,directory,invalid_customer,engine}.rb
app/models/customers/customer_record.rb
app/repositories/customers/customer_repository.rb
app/controllers/customers/
config/routes.rb
db/migrate/
billing/
lib/billing.rb # Entrypoint, imports Framework and Customers
lib/billing/{invoice,issue_invoice,invalid_invoice,tax_policy,engine}.rb
app/models/billing/invoice_record.rb
app/repositories/billing/invoice_repository.rb
app/controllers/billing/
app/views/billing/invoices/show.html.erb
config/routes.rb
db/migrate/
shop/
bin/rails # explicit CLI bridge into the framework Carton
config/{boot,application,environment,routes}.rb
config/database.yml
config.ru
Rakefile
lib/shop.rb # imports/registers domains and exports application
lib/shop/application.rb
test/integration/engines_test.rb

Repositories pass IDs and plain Hashes between Cartons. Each Engine’s storage records use qualified names, such as Customers::CustomerRecord and Billing::InvoiceRecord. Associations still resolve across Carton boundaries. Controllers create their repositories per request, so no request state lives in the shared exported modules.

Each Engine loads its own Ruby code with its own Zeitwerk loader. Its models, controllers, and repositories stay in its Carton. The loader also leaves the Engine’s app/ directories to Carton, so Rails’ main autoloader does not claim them.

isolate_namespace keeps each Engine’s names and routes separate. It does not choose a Box.

Domain methods execute in the domain’s Carton Box beside its storage models. Inherited Rails implementations execute in the framework Box. Framework classes and registries remain shared mutable Ruby objects; reopening shared classes affects their holders.

Rails finds each Engine’s routes, migrations, and views as usual. The framework Carton registers the exported namespaces so Rails can look them up. It rejects name collisions and never copies their classes into Object.

The shop’s config/application.rb imports shop rather than requiring it again, so there is one copy. The application class lives in the shop Carton. Main never loads Rails.

cartons/framework/lib/framework.rb
require 'carton'
require 'rails'
require 'active_record/railtie'
require 'action_controller/railtie'
require_relative '../app/models/application_record'
require_relative '../app/controllers/application_controller'
# Rails runs once here. Domains import these ordinary shared class objects.
module Framework
Rails = ::Rails
ActiveRecord = ::ActiveRecord
ApplicationRecord = ::ApplicationRecord
ApplicationController = ::ApplicationController
BrowserController = ::ActionController::Base
# Rails resolves qualified controller and association names here.
# Bind namespaces explicitly; never copy their classes into Object.
def self.register(*namespaces)
namespaces.each do |namespace|
name = namespace.name
if Object.const_defined?(name, false)
existing = Object.const_get(name, false)
unless existing.equal?(namespace)
raise ArgumentError, "namespace already registered: #{name}"
end
else
Object.const_set(name, namespace)
end
end
end
# The example's CLI enters this Box before loading Rails commands and Rake.
# APP_PATH is Rails' standard bootstrap constant, local to this Box.
def self.cli(argv, application:)
Object.const_set(:APP_PATH, application)
ARGV.replace(argv)
require 'rails/commands'
end
end
# Export: shared Rails classes and the registration and CLI helpers.
export Framework
cartons/customers/lib/customers.rb
require 'carton'
require 'zeitwerk'
module Customers
# Carton boundary: Rails comes from the framework Carton's Export.
Framework = import 'framework'
private_constant :Framework
# This Box's Zeitwerk owns the Engine's Ruby code, including lazy autoloads.
# Each Engine repeats this setup: a shared Framework helper would run
# Zeitwerk in Framework's Box instead of this one.
LOADER = Zeitwerk::Loader.new
%w[models controllers repositories].each do |directory|
LOADER.push_dir(File.expand_path("../app/#{directory}", __dir__))
end
LOADER.push_dir(File.expand_path('customers', __dir__), namespace: Customers)
LOADER.ignore(File.expand_path('customers/engine.rb', __dir__))
LOADER.setup
private_constant :LOADER
end
require_relative 'customers/engine'
# Export: the Engine's namespace.
export Customers
cartons/billing/lib/billing.rb
require 'carton'
require 'zeitwerk'
module Billing
# Carton boundary: Rails comes from the framework Carton's Export.
Framework = import 'framework'
private_constant :Framework
Customers = import 'customers'
private_constant :Customers
# This Box's Zeitwerk owns the Engine's Ruby code, including lazy autoloads.
# Each Engine repeats this setup: a shared Framework helper would run
# Zeitwerk in Framework's Box instead of this one.
LOADER = Zeitwerk::Loader.new
%w[models controllers repositories].each do |directory|
LOADER.push_dir(File.expand_path("../app/#{directory}", __dir__))
end
LOADER.push_dir(File.expand_path('billing', __dir__), namespace: Billing)
LOADER.ignore(File.expand_path('billing/engine.rb', __dir__))
LOADER.setup
private_constant :LOADER, :TaxPolicy
end
require_relative 'billing/engine'
# Export: the Engine's namespace.
export Billing
cartons/shop/lib/shop.rb
require 'carton'
require 'logger'
# Carton boundary: import inside the app namespace, since Rake also defines a
# top-level `import`.
module Shop
Framework = import 'framework'
Customers = import 'customers'
Billing = import 'billing'
Framework.register(Customers, Billing, self)
end
require_relative 'shop/application'
Shop::Application.initialize!
# Export: the Main Box receives the Rack application, not a Box.
export Shop::Application

From the repository root:

bundle exec rake example:rails

The task installs the bundle, runs the temporary-database Rack demo, and runs the command and HTTP tests in fresh processes. The tests cover requests, invalid inputs, unknown customers, stored pricing, escaped HTML, associations, and Box ownership under lazy and eager loading. For a persistent local app, install the bundle, then run:

cd examples/rails/cartons/shop
RUBY_BOX=1 bin/rails db:prepare
RUBY_BOX=1 bin/rails routes
RUBY_BOX=1 bin/rails runner 'puts Customers::CustomerRecord.count'
RUBY_BOX=1 bin/rails zeitwerk:check
RUBY_BOX=1 bin/rails test
RUBY_BOX=1 bin/rails server -b 127.0.0.1

Each Engine exposes its migrations to Rails, so db:prepare creates the tables; importing the app creates none. Development and test use separate databases under shop/storage/. Set SHOP_DATABASE to choose another path. Generated databases, schemas, and logs are git-ignored.

The workflow tests live in cartons/shop/test/ and run through bin/rails test, Rails’ own runner; test/commands_test.rb starts it. The Hanami example’s tests import Shop directly instead.

Use the shop’s bin/rails. It enters the framework Carton, sets Rails’ standard APP_PATH there, and makes the exported namespaces available to runner. Rails source loads only in the framework Carton. Verified commands are db:prepare, db:migrate, routes, runner, zeitwerk:check, test, and server with Puma. Stock bundle exec rails, direct rake, the console, and generators are not covered. Boot Imports live inside modules because Rake also defines a top-level import.

Restart the server after edits. Rails reloading is disabled and Imports stay cached. The separate reloading spike copies this app and tests two serial Customers and Billing replacements with private hooks. This example never loads those hooks.

The app selects its Railties explicitly. Full rails/all initialization on Ruby 4.0.7 with Boxes enabled can reach upstream issues through Action Cable and mail dependencies. Active Support extensions stay Box-local; domain code that needs them must declare and require them explicitly. Arbitrary third-party Engines, jobs, mailers, external queues, and other Rails subsystems are outside this example.