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.
Business workflow
Section titled “Business workflow”POST /customerswithcustomer: { name: "Ada" }registers a customer and returns its ID and trimmed name.POST /invoices.jsonwithinvoice: { 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.GET /invoices/1.jsonreturnsid,customer_id,subtotal_cents,tax_cents, andtotal_cents;GET /invoices/1renders 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.
Layout
Section titled “Layout”main.rb # repeatable Rack demo, temporary SQLitecartons/ 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.rbRepositories 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.
How it fits together
Section titled “How it fits together”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.
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' endend
# Export: shared Rails classes and the registration and CLI helpers.export Frameworkrequire '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 :LOADERend
require_relative 'customers/engine'
# Export: the Engine's namespace.export Customersrequire '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, :TaxPolicyend
require_relative 'billing/engine'
# Export: the Engine's namespace.export Billingrequire '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::ApplicationFrom the repository root:
bundle exec rake example:railsThe 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/shopRUBY_BOX=1 bin/rails db:prepareRUBY_BOX=1 bin/rails routesRUBY_BOX=1 bin/rails runner 'puts Customers::CustomerRecord.count'RUBY_BOX=1 bin/rails zeitwerk:checkRUBY_BOX=1 bin/rails testRUBY_BOX=1 bin/rails server -b 127.0.0.1Each 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.
CLI and limits
Section titled “CLI and limits”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.
