Usage

Complete usage guide for Boxwerk — runtime package isolation for Ruby.

Requirements

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:

  1. Project binstub — ./bin/<command> in the project root

  2. Gem binstub — resolved via Gem.bin_path

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

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:

  1. Iterates through declared direct dependencies

  2. Checks each dependency for the constant (via file index or const_get)

  3. Enforces privacy rules

  4. Returns the constant value from the dependency’s box

  5. Raises NameError if 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:

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:

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:

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:

  1. Bundler.setup + Bundler.require run in the global context

  2. Package boxes start with master definitions and separate copies of $LOAD_PATH and $LOADED_FEATURES. Loaded-feature entries prevent duplicate requires; they do not create the definitions

  3. Use require: false to keep a gem on $LOAD_PATH without 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/Gemfile is always for global gems shared across all packages. If your top-level package needs “package private” gems, use an implicit root (no package.yml at root) and create a packs/main package as your entry point with its own Gemfile.

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

Implicit Root Package

If no package.yml exists at the project root, Boxwerk creates an implicit root package with:

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

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:

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