Skip to content

Carton

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 carton

main.rb

require 'carton'

Billing = import_relative 'billing'
Billing.invoice(100) # => 110.0
Tax                  # NameError

billing Carton

billing.rb

Tax = import_relative 'tax'

module Billing
  def self.invoice(total) = total + Tax.on(total)
end

export Billing

tax Carton

tax.rb

module Tax
  def self.on(total) = total * 0.1
end

export Tax

Ruby 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.

What the boundary holds

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

Monkey patches stay put

A 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?  # NoMethodError

Gem versions per Carton

Each 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)

Shaped like a gem

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.

  1. billing.rbEntrypoint: importable, exports Billing
  2. billing/Interior: loads inside the Carton
  3. invoice.rb
  4. tax.rb
  5. models/charge.rb
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.

  • The domains of an app or a monorepo, as tens of Cartons, beside Rails or without it.
  • Plugin hosts whose plugins need conflicting gem versions, which plain Ruby cannot load together.
  • Not yet file by file. Each Carton costs about 60 ms and 2.4 MB, so aim for tens of Cartons, not hundreds.

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.

Draw your first boundary

Install the gem, export a module from one file, and import it from another. It takes about five minutes.

Read Getting started
gem install carton