Only exports cross
Other Cartons reach what a Carton exports and nothing else. Ruby's own private_constant hides the rest, across Boxes.
Billing::Invoice # public
Billing::Tax # NameError
Real boundaries for Ruby
Split an app into Cartons that import and export like modules. Each Carton runs in its own Ruby::Box, so Ruby enforces the boundary while your code runs.
gem install cartonmain.rb
require 'carton'
Billing = import_relative 'billing'
Billing.invoice(100) # => 110.0
Tax # NameErrorbilling Carton
billing.rb
Tax = import_relative 'tax'
module Billing
def self.invoice(total) = total + Tax.on(total)
end
export Billingtax Carton
tax.rb
module Tax
def self.on(total) = total * 0.1
end
export TaxRuby has gems to share code and Bundler to pick their versions, but nothing inside an app draws a boundary. Every file loads into one namespace: any code can reach any constant, a monkey patch applies everywhere, and one Gemfile fixes one version of each gem for the whole process. Carton turns that convention into a boundary Ruby keeps.
Other Cartons reach what a Carton exports and nothing else. Ruby's own private_constant hides the rest, across Boxes.
Billing::Invoice # public
Billing::Tax # NameErrorA patch to a builtin class, such as String#blank?, applies only inside the Carton that makes it.
# in the cms Carton
''.blank? # => true
# everywhere else
''.blank? # NoMethodErrorEach Carton can set up its own bundle, so two Cartons can load different versions of the same gem in one process.
# reports/Gemfile.lock
bigdecimal (3.3.1)
# pricing/Gemfile.lock
bigdecimal (4.1.1)A Carton is an Entrypoint, billing.rb, plus its Interior, the billing/ directory beside it. That's the layout of a gem's lib/ and a Zeitwerk namespace, so most code is already in shape.
There is no manifest. A gem, a path gem, a directory, or a single file can be a Carton, and a Carton with a gemspec can leave the app as a published gem unchanged.
| Bundler | Packwerk | Carton | |
|---|---|---|---|
| Boundaries inside an app | No | Checked statically, in CI | Enforced at runtime, and checked statically by carton check |
| Private code | No | Privacy checks removed in 3.0 | Only exports cross; private_constant hides the rest |
| Gem versions | One per app | One per app | One per Carton |
| Monkey patches to builtins | Global | Global | Stay in their Carton |
| Unit of distribution | A gem | A pack, which stays in the app | A gem-shaped Carton, which can become a gem |
Static checks see only what they can parse. A Carton’s boundary is the Box it runs in, so Ruby holds it even for code no tool can follow, and carton check catches what the runtime cannot, such as Second Copies and Undeclared Gems, before the code runs. Why Carton goes further.
Carton is experimental, and so is Ruby::Box. It needs Ruby 4.0.7 or later, run with RUBY_BOX=1. Status tracks what is done and what is missing.
Install the gem, export a module from one file, and import it from another. It takes about five minutes.
gem install carton