Usage
Complete usage guide for Boxwerk — runtime package isolation for Ruby.
Requirements
-
Ruby 4.0.7+ with the
RUBY_BOX=1environment variable set before process boot -
RubyGems 4.0.23+ and Bundler 4.0.23+ within Bundler 4.x
-
package.ymlfiles (Packwerk format)
Installation
Update RubyGems and its bundled Bundler with gem update --system 4.0.23.
With Bundler
Add boxwerk to your project’s gems.rb/Gemfile:
# Gemfile source 'https://rubygems.org' gem 'boxwerk'
Install and set up:
bundle install # Install gems (including boxwerk) bundle binstubs boxwerk # Create bin/boxwerk binstub bin/boxwerk install # Install per-package gems (works without pre-installed project gems)
Run your application:
RUBY_BOX=1 bin/boxwerk run main.rb
Without Bundler
Boxwerk works without any Gemfile. Install it as a system gem:
gem install boxwerk
Run directly:
RUBY_BOX=1 boxwerk run main.rb
In this mode, no gems are loaded into the global context (except Boxwerk itself). Per-package gems are still supported if Bundler is available on the system.
Package Configuration (package.yml)
Standard Packwerk format:
# packs/finance/package.yml enforce_dependencies: true dependencies: - packs/util - packs/billing enforce_privacy: true public_path: public/ # default; directory of public constants private_constants: - "::InternalHelper" # explicitly blocked even if in public_path
| Field | Type | Default | Description |
|---|---|---|---|
enforce_dependencies |
bool | false |
Block access to undeclared dependencies |
dependencies |
list | [] |
Direct dependency package paths |
enforce_privacy |
bool | false |
Restrict external access to public constants |
public_path |
string | public/ |
Directory containing the package’s public API |
private_constants |
list | [] |
Constants blocked even if in the public path |
pack_public: true Sigil
Files outside the public_path can be individually marked public by adding a comment sigil in the first 5 lines:
# pack_public: true class SpecialService # accessible to dependents even though it lives in lib/, not public/ end
The sigil is matched with #.*pack_public:\s*true.
CLI Reference
boxwerk <command> [options] [args...]
Commands
boxwerk run <script.rb> [args...]
Run a Ruby script in a package box.
boxwerk run main.rb # Run in root package box boxwerk run --package packs/finance main.rb # Run in a specific package box boxwerk run --global main.rb # Run in global context (no package)
boxwerk exec <command> [args...]
Execute a command in the boxed environment. Boxwerk looks for the command in this order:
-
Project binstub —
./bin/<command>in the project root -
Gem binstub — resolved via
Gem.bin_path -
Shell command — falls back to running the command as a shell process in the package directory
Project binstubs take precedence, allowing custom command entry points (e.g. a bin/rails that sets APP_PATH and requires rails/commands).
boxwerk exec rake test # Root package boxwerk exec --package packs/util rake test # Specific package boxwerk exec --all rake test # All packages sequentially
With --all, each package runs in a separate subprocess for clean isolation (avoids at_exit conflicts from test frameworks like Minitest).
boxwerk console [irb-args...]
Start an IRB console with package constants accessible. IRB runs in Ruby::Box.master with a composite resolver that provides the target package’s constants.
boxwerk console # Root package context boxwerk console --package packs/finance # Specific package context boxwerk console --global # Global context
IRB autocomplete is disabled (box-scoped constants are not visible to the completer).
boxwerk info
Boot the application and show runtime autoload structure: config, dependency tree, global section, per-package enforcements/dependencies/autoload dirs/gems/constants. Also reports gem version conflicts.
RUBY_BOX=1 boxwerk info
Output sections: - Config — boxwerk.yml settings with defaults filled in - Dependency Graph — tree view; circular dependencies are marked (circular) - Global — boot script, autoload or eager_load dirs (label is eager_load when eager loading enabled), gems - Packages — each package with enforcements, dependencies, autoload/eager_load dirs, collapse dirs, ignore dirs, pack_public constants, explicit private constants, and direct gems
Requires RUBY_BOX=1 (boots the application to gather runtime autoload information).
When eager loading is enabled (eager_load_global / eager_load_packages), the autoload section label becomes eager_load.
boxwerk install
Run bundle install for every package that has a gems.rb/Gemfile. Installs global (root) gems first, then packages.
bin/boxwerk install
Does not require RUBY_BOX=1. Works without pre-installing global project gems first — the binstub skips bundler/setup for this command so you can run it as the first step after cloning a project.
boxwerk help / boxwerk version
Show usage or version.
Options
| Flag | Short | Applies to | Description |
|---|---|---|---|
--package <name> |
-p |
exec, run, console |
Run in a specific package box (default: .) |
--all |
-a |
exec |
Run for all packages sequentially (subprocesses) |
--global |
-g |
exec, run, console |
Run in the global context (no package) |
--package-paths <paths> |
exec, run, console |
Comma-separated package path globs (override boxwerk.yml) |
|
--eager-load-global |
exec, run, console |
Enable global eager loading | |
--no-eager-load-global |
exec, run, console |
Disable global eager loading | |
--eager-load-packages |
exec, run, console |
Enable package eager loading | |
--no-eager-load-packages |
exec, run, console |
Disable package eager loading |
CLI config options override boxwerk.yml values. This enables quick configuration without creating a config file.
Package names passed to --package are normalized: leading ./ and trailing / are stripped, so ./packs/loyalty, packs/loyalty/, and packs/loyalty all refer to the same package.
Per-Package Gems
Each package can have its own gems.rb/Gemfile and corresponding lockfile. Different packages can use different versions of the same gem.
packs/billing/
├── package.yml
├── Gemfile # gem 'stripe', '~> 5.0'
├── Gemfile.lock
└── lib/
└── payment.rb # Stripe auto-required, ready to use
Gem Isolation Model
-
Global gems (root
Gemfile) load in master before package creation. Package boxes start with those definitions and separate copies of the load-path and loaded-feature arrays -
Per-package gems are resolved from lockfiles and added to each box’s
$LOAD_PATHindependently -
Auto-required: Gems declared in a package’s
gems.rb/Gemfileare automatically required in the package box (like Bundler’s default behaviour). No manualrequireneeded -
require: false— Gems declared withrequire: falseare added to$LOAD_PATHbut not auto-required -
Custom require:
gem 'foo', require: 'foo/bar'auto-requiresfoo/barinstead offoo -
Gems do NOT leak across package boundaries — package A cannot see package B’s gems, even if A depends on B
-
Cross-package version differences are safe — each box has its own isolated
$LOAD_PATH -
Global override warning: If a package defines a gem that’s also in the root
Gemfileat a different version, both versions load into memory (functionally correct but wastes memory).Boxwerkwarns at boot time -
Shared global gems: Add a gem to the root
Gemfilewithoutrequire: falseto share a single copy across all packages
Run boxwerk install to install gems for all packages.
Constant Resolution
Intra-Package: autoload
Each package’s constants are registered as autoload entries in its box. When code references Calculator, Ruby’s built-in autoload loads the file — standard Ruby behaviour.
Cross-Package: const_missing
When a constant is not found in the current box, Object.const_missing fires. Boxwerk’s per-box handler:
-
Iterates through declared direct dependencies
-
Checks each dependency for the constant (via file index or
const_get) -
Enforces privacy rules
-
Returns the constant value from the dependency’s box
-
Raises
NameErrorif no dependency has it
# Simplified flow: class Object def self.const_missing(const_name) deps.each do |dep| next unless dep.has_constant?(const_name) check_privacy!(const_name, dep) return dep.box.const_get(const_name) end raise NameError, "uninitialized constant #{const_name}" end end
Constants are not wrapped in namespaces. Invoice is accessed as Invoice, not Finance::Invoice.
Namespace Module Resolution
Parent modules are resolved by loading child files. If a file index has Menu::Item but no direct Menu entry, referencing Menu triggers autoload of a child file, which defines the Menu module as a side effect.
Privacy Enforcement
Privacy is checked at const_missing time when resolving cross-package constants:
-
public_path— Only files in this directory (default:public/) define the package’s public API. Constants outside it are blocked. -
private_constants— Explicitly private constants, blocked even if in the public path. -
pack_public: truesigil — Files outside the public path can opt in to public visibility.
Violations raise NameError with a descriptive message:
Privacy violation: 'InternalHelper' is private to 'packs/finance'. Only constants in the public path are accessible.
Root Package vs Global Context
These are different concepts:
-
Root package (
.) — Your top-levelpackage.yml. Gets its ownRuby::Boxlike any other package. Has dependencies and constants. This is where your application code runs by default. -
Global context — Shared application configuration, gems, and constants hosted in
Ruby::Box.master.Boxwerk.globalexposes its autoloader configuration.
Each package, including ., gets an optional user box initialized from master. The root package is distinct from both Ruby::Box.root, Ruby’s runtime box, and Ruby::Box.main, where the command-line program and -r libraries run. See Architecture for the verified Ruby 4.0.7 model.
This has important implications:
-
Global gems loaded via
Bundler.requireare available in package boxes created afterward -
Shared top-level constants must be loaded before package boxes are created
-
Code in
global/boot.rbruns before any package boxes exist -
Later changes to master do not reliably update existing boxes. Constant values can still refer to shared mutable objects; box creation is not a deep copy
Use --global / -g to run commands in the global context directly. A composite resolver is installed on Ruby::Box.master so that all package constants are accessible — useful for scripts, debugging, or tools that need cross-package access without picking a single package.
Global Gems
Gems in the root gems.rb/Gemfile are loaded in Ruby::Box.master during boot:
-
Bundler.setup+Bundler.requirerun in the global context -
Package boxes start with master definitions and separate copies of
$LOAD_PATHand$LOADED_FEATURES. Loaded-feature entries prevent duplicate requires; they do not create the definitions -
Use
require: falseto keep a gem on$LOAD_PATHwithout loading it at boot
# Gemfile gem 'activesupport' # loaded globally, available everywhere gem 'pry', require: false # on $LOAD_PATH but not loaded
Note: The root
gems.rb/Gemfileis always for global gems shared across all packages. If your top-level package needs “package private” gems, use an implicit root (nopackage.ymlat root) and create apacks/mainpackage as your entry point with its ownGemfile.
Gems with Internal Autoloading
Some gems (like Rails) use Zeitwerk internally to autoload their own constants. When loaded via Bundler.require in the global context, these autoloads are registered as pending entries in Ruby::Box.master. Package boxes capture top-level constant entries and loading state at creation, and pending autoloads may not resolve consistently across those boxes.
Boxwerk runs Zeitwerk::Loader.eager_load_all after global boot to resolve all pending autoloads. If your gem needs additional setup before eager loading (e.g. requiring sub-components), do this in global/boot.rb:
# global/boot.rb require "active_record/railtie" # register Zeitwerk autoloads require "action_controller/railtie" # Boxwerk runs Zeitwerk::Loader.eager_load_all after this script
Testing
Run tests through Boxwerk to enforce package isolation:
boxwerk exec rake test # Root package tests boxwerk exec --package packs/finance rake test # Specific package tests boxwerk exec --all rake test # All packages sequentially
Each --all run spawns a separate subprocess per package for clean isolation — test frameworks like Minitest register tests globally via at_exit, which would conflict across packages in a single process.
Configuration (boxwerk.yml)
An optional boxwerk.yml file at the project root configures Boxwerk behaviour.
# boxwerk.yml package_paths: - "packs/*" # default: ["**/"] eager_load_global: true # default: true eager_load_packages: false # default: false
| Field | Type | Default | Description |
|---|---|---|---|
package_paths |
list | ["**/"] |
Glob patterns for where to search for package.yml files |
eager_load_global |
bool | true |
Eager-load global/ files and Zeitwerk constants at boot |
eager_load_packages |
bool | false |
Eager-load all constants in each package box after boot |
By default, Boxwerk searches everywhere (**/) for package.yml files. Set package_paths to restrict the search to specific directories.
Eager Loading
-
eager_load_global— Whentrue(default), requires all files inglobal/and any dirs registered viaBoxwerk.global.autoloader.push_dir, then runsZeitwerk::Loader.eager_load_all. This ensures constants are defined before child boxes are created. Whenfalse, global constants are registered as lazy autoloads (accessible on demand, e.g. inglobal/boot.rb) but not eagerly required. -
eager_load_packages— Whentrue, eager-loads all constants in each package box immediately after it boots. Whenfalse(default), constants are lazy-loaded via autoload on first access.
Implicit Root Package
If no package.yml exists at the project root, Boxwerk creates an implicit root package with:
-
enforce_dependencies: false -
enforce_privacy: false -
Automatic dependencies on all discovered packages
This is useful for gradually adopting Boxwerk — you can start with just sub-packages and no root package.yml. The implicit root can access constants from all packages without declaring explicit dependencies.
Global Boot
global/ Directory
An optional global/ directory provides shared constants and initialization. Boxwerk sets up namespaces and file autoloads in master, excluding global/boot.rb. Namespace setup can load explicit namespace files. With the default eager_load_global: true, it requires those files after the global boot script and before creating package boxes.
Files follow Zeitwerk conventions:
global/ ├── boot.rb # Global boot script (optional) ├── config.rb # Config └── middleware.rb # Middleware
# global/config.rb module Config SHOP_NAME = ENV.fetch('SHOP_NAME', 'My App') CURRENCY = '$' end
global/boot.rb
An optional global/boot.rb script runs in master after global gems load and global autoloads are registered, before package boxes are created. Referencing a global constant can trigger its autoload during the script. Eager loading follows the script when eager_load_global: true.
# global/boot.rb require 'dotenv/load' puts "Booting #{Config::SHOP_NAME}..."
Boxwerk.global.autoloader
Use Boxwerk.global.autoloader in global/boot.rb to register additional shared autoload directories before package creation. It registers entries in master. Calling it later does not reliably make new top-level constants available in existing package boxes.
# global/boot.rb # Load shared utilities from a custom lib/ dir Boxwerk.global.autoloader.push_dir(File.expand_path('../lib', __dir__))
Methods:
Boxwerk.global.autoloader.push_dir("lib") # Register lazy autoloads Boxwerk.global.autoloader.collapse("lib/utils") # Collapse namespace Boxwerk.global.autoloader.ignore("lib/utils") # Ignore from autoloading Boxwerk.global.autoloader.setup # Register any pending dirs
push_dir and collapse call setup immediately, registering file autoloads and setting up namespaces. Explicit namespace files can load during setup; other files load on access. With eager_load_global: true, Boxwerk eagerly requires the registered files after global/boot.rb, before package creation.
Root-Level boot.rb
A boot.rb at the project root is a root package boot script — it runs in the root package’s box (like any other package’s boot.rb). This is different from global/boot.rb which runs in the master box.
Use Cases
-
global/boot.rb— Load environment variables, define global config, manually eager load constants (e.g. Rails internals) -
boot.rb— Root package initialization, e.g. booting Rails (see Rails example). Runs when the root package boots, after its dependencies
Per-Package Boot Scripts
Each package can have an optional boot.rb that runs after the package’s own constants are scanned and per-package gems are loaded, but before cross-package constants are wired. It can be used to configure additional autoload dirs and collapse:
# packs/models/boot.rb pkg = Boxwerk.package pkg.name # => "packs/models" pkg.root? # => false pkg.config # => frozen hash of package.yml values pkg.root_path # => absolute path to the package directory pkg.autoloader # => autoload configuration object pkg.autoloader.push_dir("models") pkg.autoloader.collapse("lib/concerns") # Promotes lib/concerns/* to parent namespace pkg.autoloader.ignore("lib/legacy") # Excludes lib/legacy/* from autoloading
collapse removes the intermediate namespace directory from the constant hierarchy. For example, collapsing lib/analytics/formatters means files in that directory are accessible as Analytics::CsvFormatter rather than Analytics::Formatters::CsvFormatter. The Formatters intermediate constant is removed from the box.
ignore prevents files in the directory from being autoloaded. Accessing any constant from that directory raises NameError.
autoloader.setup
push_dir and collapse automatically call setup, registering constants immediately so they are available during boot.rb execution. You can also call autoloader.setup explicitly to ensure dirs are registered at a specific point:
# packs/svc/boot.rb pkg = Boxwerk.package pkg.autoloader.push_dir("extras") # Helper is available immediately (push_dir auto-called setup) Helper.configure(ENV["API_KEY"])
setup can be called multiple times — each call registers only the dirs added since the last call.
Boxwerk.package returns a PackageContext accessible from anywhere inside a package box. The BOXWERK_PACKAGE constant is also set in each package box for direct access.
Autoloader Public API
Both Boxwerk.global.autoloader and Boxwerk.package.autoloader expose the same four methods:
| Method | Description |
|---|---|
push_dir(dir) |
Add a dir to autoload roots; registers lazy autoloads immediately |
collapse(dir) |
Collapse a subdir into its parent namespace; registers immediately |
ignore(dir) |
Exclude a dir from autoloading entirely |
setup |
Register any pending dirs (auto-called by push_dir and collapse) |
All methods return self, allowing chaining.
Monkey Patch Isolation
Because each package runs in its own Ruby::Box, patches to built-in classes such as String are isolated to that box:
# packs/kitchen/boot.rb class String def to_order_ticket "🎫 #{upcase}" end end
Code inside the kitchen package can call "Latte".to_order_ticket, but other packages and the root context will not see the method.
Other classes are shared. A class from the global context (gems and global/) or from a dependency is one definition, so reopening it changes it for every package:
-
Reopening a global class, e.g.
class ActiveRecord::Base, affects all packages. -
Invoice.class_eval,include, orprependon a dependency’s class affects its package and every other dependent. -
class Invoice ... endin a dependent does not reopen the dependency’sInvoice. It defines a separate class in the dependent that shadows it.
To extend a shared class, subclass it or use a refinement.
Circular Dependencies
Boxwerk allows circular dependencies. Both packages in a cycle are booted; the first visited in DFS order goes first. Dependencies in cycles are still wired normally.
Relaxed Dependency Enforcement
When enforce_dependencies: false, const_missing searches ALL packages — not just declared dependencies. Explicit dependencies are searched first (in declared order), then remaining packages. Privacy rules still apply per-package.
Examples
-
examples/minimal/— Simplest setup: three packages, dependency enforcement, no gems -
examples/complex/— Full-featured: namespaced constants, privacy enforcement, per-package gems, global gems, and tests -
examples/rails/— Rails with ActiveRecord, foundation package, privacy