DEV Community

Koichi Sasada
Koichi Sasada

Posted on

Ruby::Box Digest Introduction (Ruby 4.0.0 New Feature)

(This article is AI translation version of https://product.st.inc/entry/2025/12/25/134453 from Japanese to English. The author is not Koichi, but Endoh-san)

Hello, this is Yusuke Endoh (@mametter).

Ruby::Box was introduced in Ruby 4.0.0 ([Feature #21311]).

Ruby::Box was called Namespace when it was proposed. Matz even declared at RubyKaigi and elsewhere that if Namespace or ZJIT landed, the next release would be Ruby 4.0.0, so it is a key feature.

So some people may only know the vibe of "some amazing feature," but I think most people don’t actually know what it can do or what its current state is.

So, I’ll try to explain in some detail what Endoh knows about Ruby::Box. I hope this helps those who want to play with Ruby::Box.

Note that the designer/implementer of Ruby::Box is tagomoris, and Endoh mostly just chimed in here and there.
This article was reviewed by tagomoris before publication, but the responsibility for the text is Endoh's.

A quick tour of Ruby::Box

Roughly speaking, Ruby::Box is a feature for isolating class and method definitions.

First, read the following code carefully.

# test.rb
# Run this on ruby 4.0.0 with `RUBY_BOX=1 ruby test.rb`

# Create a Ruby::Box instance b
b = Ruby::Box.new

# Define class Foo inside b
b.eval <<'RUBY'
  class Foo
    def foo = 42
  end
RUBY

# Class Foo is, in principle, accessible only inside b
b.eval <<'RUBY'
  p Foo.new.foo #=> 42
RUBY

# Outside b, Foo is not defined
Foo.new #=> uninitialized constant Foo (NameError)
Enter fullscreen mode Exit fullscreen mode

You can see that the definition of the class Foo is enclosed within Ruby::Box b.

To add a little more: to access Foo inside b from outside, write b::Foo.

# Access Foo inside b from outside with b::Foo
p b::Foo.new.foo #=> 42
Enter fullscreen mode Exit fullscreen mode

You can also use Ruby::Box#require instead of eval.
It loads a file inside that Ruby::Box.

# Load ./foo.rb inside b
b.require "./foo"
Enter fullscreen mode Exit fullscreen mode

That’s the basic idea of Ruby::Box.

Also, when playing with Ruby::Box, set the environment variable RUBY_BOX=1. Otherwise Ruby::Box is disabled.

$ ruby -e 'p Ruby::Box.new'
-e:1:in 'Ruby::Box#initialize': Ruby Box is disabled. Set RUBY_BOX=1 environment variable to use Ruby::Box. (RuntimeError)
        from -e:1:in '<main>'

$ RUBY_BOX=1 ruby -e 'p Ruby::Box.new'
ruby: warning: Ruby::Box is experimental, and the behavior may change in the future!
See https://docs.ruby-lang.org/en/v4.0.0/Ruby/Box.html for known issues, etc.
#<Ruby::Box:3,user,optional>
Enter fullscreen mode Exit fullscreen mode

Ruby::Box use cases

A typical use case for Ruby::Box is loading the same library multiple times. Specifically, cases like:

  • Use different versions of a gem at the same time
  • Use a gem with global configuration under multiple configurations

Let’s go through them.

Use case 1: Use different versions of a gem at the same time

Consider the following greeting gem.

# greeting-1.0.0.rb
class Greeting
  VERSION = "1.0.0"

  def say_hello = puts("Hello, user!")
end
Enter fullscreen mode Exit fullscreen mode
# greeting-2.0.0.rb
class Greeting
  VERSION = "2.0.0"

  def say_hello(name) = puts("Hello, #{name}!")
end
Enter fullscreen mode Exit fullscreen mode

the greeting gem version moves from 1.0.0 to 2.0.0, and the arguments to Greeting#say_hello change incompatibly.
So to upgrade the greeting gem from 1.0.0 to 2.0.0, you need to fix all existing say_hello calls to say_hello("user"), etc.
But doing that all at once in a monolith developed by multiple people is hard.

With Ruby::Box, you can load both 1.0.0 and 2.0.0 and use them separately.

# Load greeting 1.0.0 into Ruby::Box b1
b1 = Ruby::Box.new
b1.require "./greeting-1.0.0"

# Load greeting 2.0.0 into Ruby::Box b2
b2 = Ruby::Box.new
b2.require "./greeting-2.0.0"

# Each Ruby::Box has its own library loaded
p b1::Greeting::VERSION #=> "1.0.0"
p b2::Greeting::VERSION #=> "2.0.0"

# Call each say_hello separately
p b1::Greeting.new.say_hello         #=> "Hello"
p b2::Greeting.new.say_hello("mame") #=> "Hello, mame!"
Enter fullscreen mode Exit fullscreen mode

You can use greeting 2.0.0 in some parts of a large codebase and 1.0.0 elsewhere.
In other words, this gives you the option to update gem versions gradually.

Caution

In reality, switching versions at the granularity of each say_hello call can become unmanageable, so you should avoid it (and for reasons explained later, it’s also difficult in practice).

A better practice might be to split large codebases roughly by feature units (for example, pack units in packwerk?), map a Ruby::Box to each, and update gem versions per feature unit.
But no one has done this in practice yet, so it’s unclear if it really works. I hope for future knowledge sharing.

There is also an opinion that “multi-version support like this is not good in the first place.” Still, I think it is the best topic to understand what Ruby::Box is and consider its applications, so I introduced it.

Also, seeing such examples may make you want a separate Gemfile per Ruby::Box.
However, there is no good integration between Ruby::Box and RubyGems/Bundler yet. Hopefully in the future.

Use case 2: Use a globally configured gem under multiple configurations

Here’s another example. Consider a library whose settings are stored globally.

In the Greeting library above, suppose setting Greeting.polite = true makes the message more polite.

# greeting.rb
class Greeting
  @@polite = false

  def self.polite=(polite)
    @@polite = polite
  end

  def say_hello
    if @@polite
      puts "Hello. How are you today?")
    else
      puts "Yo!"
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Since @@polite is a global setting, you can only fix it to one or the other.
You can switch it midway, but in a multi-threaded program it is not thread-safe.

require "./greeting"

Greeting.new.say_hello #=> "Yo!"

# You can switch midway, but...
Greeting.polite = true
Greeting.new.say_hello #=> "Hello. How are you today?"

# Risky when used in threads
Thread.new { Greeting.new.say_hello } #=> You never know what will come out
Greeting.polite = false
Enter fullscreen mode Exit fullscreen mode

By loading the library multiple times with Ruby::Box, you can hold multiple configurations in a thread-safe way.

b1 = Ruby::Box.new
b1.require "./greeting"

b2 = Ruby::Box.new
b2.require "./greeting"

b2::Greeting.polite = true

# Safely use different settings even in threads
Thread.new { b1::Greeting.new.say_hello } #=> "Yo!"
Thread.new { b2::Greeting.new.say_hello } #=> "Hello. How are you today?"
Enter fullscreen mode Exit fullscreen mode

Personally I want to say “libraries with global configuration are lame,” and think they should use thread-local storage if anything, but in reality such libraries probably do exist.

Ruby::Box is not object-space isolation

We’ve looked at Ruby::Box, and someone may think “Ruby::Box isolates object space.”
That is a misconception, so be careful.

Example of passing objects across Ruby::Box (not recommended)

Ruby::Box only separates class (constant) namespaces.
Objects themselves do not have a concept like “belonging to a Ruby::Box,” and passing objects across Ruby::Box boundaries is allowed.

See the following code.

# Define Foo class inside b1
b1 = Ruby::Box.new
b1.eval <<'RUBY'
  class Foo
    def say_hello = puts("Hello")
  end
RUBY

# Define Test class inside b2
b2 = Ruby::Box.new
b2.eval <<'RUBY'
  class Test
    # The accept method can take an instance of b1::Foo
    def self.accept(b1_foo)
      b1_foo.say_hello
    end
  end
RUBY

# Create an instance of b1::Foo
b1_foo = b1::Foo.new

# It can be passed to a method inside b2
b2::Test.accept(b1_foo) #=> "Hello"
Enter fullscreen mode Exit fullscreen mode

b1_foo has no concept of “belonging to b1.”
A method inside b2 can receive the b1_foo object and call b1::Foo#say_hello.
Passing instances of classes defined in one Ruby::Box to methods in another Ruby::Box is not restricted.
So this is not object-space separation.

(Also, besides passing via methods, there are tricks like forcibly passing via constant definition, e.g., b2::X = b1::Foo.new.)

Behavior when defining same-named classes across Ruby::Box

As shown above, passing objects across Ruby::Box is allowed, but (in Endoh’s opinion) it is by no means recommended.
Because it can easily lead to very confusing bugs.

Consider this example using the Data class.

# Define User class inside b1
b1 = Ruby::Box.new
b1.eval <<'RUBY'
  class User < Data.define(:name)
    def greeting = "Hello #{self.name}"
  end
RUBY

# Also define User class inside b2
# Note: b1::User and b2::User are completely different classes!
b2 = Ruby::Box.new
b2.eval <<'RUBY'
  class User < Data.define(:name)
    def greeting = "こんにちは #{self.name}"
  end

  class Test
    def self.test(a) = a.greeting
  end
RUBY

# Instances of b1::User and b2::User look identical but are actually different
p b1::User.new("mame") #=> #<data User name="mame">
p b2::User.new("mame") #=> #<data User name="mame">

# Comparison treats them as completely different objects
p b1::User.new("mame") == b2::User.new("mame") #=> false

# .greeting respects each class definition
p b1::User.new("mame").greeting       #=> "Hello mame"
p b2::User.new("mame").greeting       #=> "こんにちは mame"

# Even when calling .greeting from inside b2, each definition is respected
p b2::Test.test(b1::User.new("mame")) #=> "Hello mame"
p b2::Test.test(b2::User.new("mame")) #=> "こんにちは mame"
Enter fullscreen mode Exit fullscreen mode

Instances of Data are supposed to be equal if their member values are equal, but here b1::User and b2::User themselves are different, so their instances are treated as different objects (even though the contents are the same).

Mixing instances of classes defined in different Ruby::Boxes is a recipe for confusion, so it is wise to avoid it.
And for this reason, switching gem versions at fine granularity like per method call is risky.
It’s better to update gem versions at a unit where you can understand object exchanges, such as feature units.

By the way, the behavior above is so confusing that there is (or isn’t) discussion about showing the Ruby::Box in the output, like #<data b1::User name="mame">.

Mindset

As a rule of thumb, it’s good to remember: “Do not pass objects across Ruby::Box boundaries.”

Strictly speaking, even reading a class object like b1::User from b1 is “passing an object across Ruby::Box boundaries,” so how far you should avoid that is something we’ll need to explore.

Built-in classes like Integer and String are shared across Ruby::Boxes, so passing those instances is relatively safe. I’ll explain this next.

Monkey-patch isolation

Ruby::Box has another very delightful feature.
It isolates monkey patches on built-in classes.

What is monkey-patching?

In Ruby, you can add methods to built-in classes or redefine existing methods. This is commonly called monkey-patching.

In Ruby, integer division truncates. Some people who like math want it to return rationals. In such cases, you can monkey-patch like this.

p 5 / 3 #=> 1

class Integer
  def /(other) = self.quo(other)
end

p 5 / 3 #=> (5/3)
Enter fullscreen mode Exit fullscreen mode

But if you actually do this, most code and libraries that expect truncation will break.

In fact, ancient Ruby had a standard library called "mathn" that applied such monkey patches, but it gradually fell out of use and disappeared because it didn’t play well with other code.

Monkey-patch isolation via Ruby::Box

With Ruby::Box, you can isolate and localize the effect of monkey patches like this.

b = Ruby::Box.new

# Monkey-patch Integer#/ inside b
b.eval <<'RUBY'
  class Integer
    # Return Rational
    def /(other) = self.quo(other)
  end
RUBY

# Inside b, division returns rationals
b.eval <<'RUBY'
  p 5 / 3 #=> (5/3)
RUBY

# Outside b, division truncates as before
p 5 / 3 #=> 1
Enter fullscreen mode Exit fullscreen mode

Refinement already exists as a monkey-patch isolation feature, but you have to write using SomeRefinement in each context you want to use it. Ruby::Box doesn’t require such explicitness.

Use cases

This feature was introduced to isolate ActiveSupport inside a Ruby::Box (though it seems it doesn’t work for that purpose yet).

ActiveSupport and mathn are of course use cases for monkey-patch isolation, but personally I think this feature has many other uses.

For example, it seems useful to isolate the environment where user code is executed in irb.
In today’s irb, if you carelessly redefine String#+, irb crashes like this.
If you separate the Ruby::Box that evaluates user code, it might handle such mischievous code nicely.

$ irb
irb(main):001> class String; def +(_) = raise; end
An error occurred when inspecting the object: RuntimeError
Result of Kernel#inspect: #<Symbol:0x0000000000002b0c>
=> :+
(irb):1:in 'String#+': unhandled exception
        from /home/mame/.rbenv/versions/ruby-dev/lib/ruby/gems/4.0.0+0/gems/irb-1.15.3/lib/irb.rb:658:in 'block in IRB::Irb#format_prompt'
        from /home/mame/.rbenv/versions/ruby-dev/lib/ruby/gems/4.0.0+0/gems/irb-1.15.3/lib/irb.rb:626:in 'String#gsub'
        from /home/mame/.rbenv/versions/ruby-dev/lib/ruby/gems/4.0.0+0/gems/irb-1.15.3/lib/irb.rb:626:in 'IRB::Irb#format_prompt'
...
        from /home/mame/.rbenv/versions/ruby-dev/bin/irb:25:in '<main>'

$
Enter fullscreen mode Exit fullscreen mode

Also recently, there’s a movement to rewrite Ruby’s built-in methods from C to Ruby (because JIT can make Ruby code faster in some cases). This can cause monkey patches to affect unexpected methods.
For example, currently the Ruby implementation of Integer#times uses #succ, so monkey-patching Integer#succ breaks Integer#times.

class Integer
  def succ = raise
end

# The Ruby implementation of Integer#times calls #succ, so this raises
1.times { p "ok" }
#=> RUBY_BOX=0: in 'Integer#succ': unhandled exception
#   RUBY_BOX=1: "ok"
Enter fullscreen mode Exit fullscreen mode

Ruby::Box already solves this problem. If you run the above code with RUBY_BOX=1, Integer#times ignores the redefinition of Integer#succ and still works.
This is because when RUBY_BOX=1 is set, the Ruby::Box that defines built-in methods (Ruby::Box.root) and the Ruby::Box for user code (Ruby::Box.main) are separated.
In other words, monkey patches to Integer#succ done inside user code are isolated from the built-in Ruby::Box space.

Caution

The monkey-patch isolation above is a special feature only for built-in classes.
Built-in classes are classes available immediately after starting the Ruby interpreter, such as Array, Integer, String, etc.
Be aware that these are treated fundamentally differently from user-defined classes.

I said that classes with the same name defined in different Ruby::Boxes are completely different (the b1::User and b2::User example).
However, built-in classes like Integer are specially shared across Ruby::Boxes.
Instead, the method table for the same Integer class is separated per Ruby::Box.

If you study the following example carefully, you might start to see it.

# b1 monkey-patches Integer#/
b1 = Ruby::Box.new
b1.eval <<'RUBY'
  class Integer
    def /(other) = self.quo(other.n)
  end
  ONE = 1
  TWO = 2
RUBY

# b2 calls / on its arguments
b2 = Ruby::Box.new
b2.eval <<'RUBY'
  class Test
    def self.test(a, b) = a / b
  end
RUBY

# Even if you divide b1::ONE and b1::TWO inside b2, it truncates
# (i.e., the Integer monkey patch inside b1 is not effective inside b2)
p b2::Test.test(b1::ONE, b1::TWO) #=> 0
Enter fullscreen mode Exit fullscreen mode

In short, monkey-patch definitions do not carry across Ruby::Box boundaries.

Caution (2)

Date is subtle.
Time is a built-in class, so mixing across Ruby::Boxes works without surprises, but Date is not a built-in class (you need require "date"), so you get non-intuitive behavior like b1::User / b2::User.

# Require the date library in b1 and b2
# (two separate Date classes are defined)
b1 = Ruby::Box.new
b1.require "date"

b2 = Ruby::Box.new
b2.require "date"

# b1::Time and b2::Time are actually the same class
 t1 = b1::Time.new(2025, 12, 25)
 t2 = b2::Time.new(2025, 12, 25)

p t1 #=> 2025-12-25 00:00:00 +0900
p t2 #=> 2025-12-25 00:00:00 +0900

# Time instances compare normally
p t1 == t2 #=> true

# b1::Date and b2::Date are different
 d1 = b1::Date.new(2025, 12, 25)
 d2 = b2::Date.new(2025, 12, 25)

p d1 #=> #<Date: 2025-12-25 ((2461035j,0s,0n),+0s,2299161j)>
p d2 #=> #<Date: 2025-12-25 ((2461035j,0s,0n),+0s,2299161j)>

# These Date instances are considered different when compared
p d1 == d2 #=> false
Enter fullscreen mode Exit fullscreen mode

Personally, I recommend not using Date, but in the short term that’s not feasible, so I’m stuck at “what do we do?”

Other notes

Ruby::Box has various caveats and anecdotes.
I’ll explain them quickly, avoiding too many corner cases.

Top-level constant references are contained within Ruby::Box

With ::A you can reference a top-level constant, but inside Ruby::Box it looks for the top-level constant within that Ruby::Box.

Foo = "toplevel"

b = Ruby::Box.new
b.eval <<'RUBY'
  Foo = 42
  p ::Foo #=> 42 (not "toplevel")
RUBY
Enter fullscreen mode Exit fullscreen mode

This means that existing libraries that reference ::Foo should still work when loaded via Ruby::Box#require. Probably.

Ruby::Box also isolates global variables

So far we only talked about constants, but global variables are isolated the same way.

$foo = 42

b = Ruby::Box.new
b.eval <<'RUBY'
  p $foo #=> nil
RUBY
Enter fullscreen mode Exit fullscreen mode

Global variables are no longer global (there are discussions about sharing some like $VERBOSE).

Each Ruby::Box has its own $LOAD_PATH/$LOADED_FEATURES

$LOAD_PATH and $LOADED_FEATURES are global variables, so each Ruby::Box has its own definitions.
Ruby::Box#require follows each definition, so be careful.

Ruby::Box.new creates a clean environment

Even if you define classes or methods before Ruby::Box.new, those definitions are not carried into the new Ruby::Box.

class Foo
end

b = Ruby::Box.new
b.eval <<'RUBY'
  Foo #=> uninitialized constant Foo (NameError)
RUBY
Enter fullscreen mode Exit fullscreen mode

Personally I think there are many cases where you want to inherit definitions, but the spec and implementation would get quite complex, so it’s like this for now.

Ruby::Box is not a sandbox

It is not. Ruby::Box is not built for security.

For example, you can pull out outside objects via ObjectSpace, and there are likely many other tricks.

Ruby::Box API

At the moment, it seems to have the following methods.

  • Ruby::Box.enabled?: whether Ruby::Box functionality is enabled
  • Ruby::Box#eval: execute code inside a Ruby::Box
  • Ruby::Box#require: load a source file inside a Ruby::Box
  • Ruby::Box#require_relative: Kernel#require_relative version of the above
  • Ruby::Box#load: Kernel#load version of the above
  • Ruby::Box#load_path: $LOAD_PATH inside the Ruby::Box? (not sure)
  • Ruby::Box.current: return the current Ruby::Box
  • Ruby::Box.main: return the top-level Ruby::Box
  • Ruby::Box#main?: return whether it is the above
  • Ruby::Box.root: return the Ruby::Box where built-in classes are defined (best not to use lightly)
  • Ruby::Box#root?: return whether it is the above

Relation to Kernel.load’s second argument

Actually, you could already pass a module as the second argument to load to specify where constants are defined.

m = Module.new

# Load a.rb in the context of module m
load("a.rb", m)

p m::A #=> #<Module:0x000078d927d4a520>::A
Enter fullscreen mode Exit fullscreen mode

This is basically the prototype of Ruby::Box, but it wasn’t very polished, so I think it was rarely used in practice.
For example, top-level constants ::A still referenced the global top-level constants.
You could say Ruby::Box is this feature refined to an extreme.

What happened to the name "Namespace"?

This feature was originally discussed under the name "Namespace", but according to Matz, it was avoided due to naming conflicts.
For example, when you write a my_app gem, you usually wrap the code like:

module MyApp
  ...
end
Enter fullscreen mode Exit fullscreen mode

The usage of the word “Namespace” or “Namespace module” for this is already established, so they avoided confusion by choosing a different name.

Also, in some Rails apps (specifically GitLab), a top-level constant Namespace is already defined, and changing it would require renaming DB tables. That likely had some influence too.

So, can we use it yet?

No, not in production.

As the warning says when you start ruby 4.0.0 with RUBY_BOX=1warning: Ruby::Box is experimental, and the behavior may change in the future! — it’s not yet at production quality. In fact, with RUBY_BOX=1 the Ruby test suite probably doesn’t pass yet (probably).

However, it seems to be good enough for playing around, so please try it.

High-level API?

According to Matz’s vision, this Ruby::Box is a low-level API for a packaging system that may be introduced to Ruby in the future.
So in the future, a high-level packaging-system API based on Ruby::Box might be born.

It could be a built-in Ruby feature, or it could be a de facto library (not standard Ruby) like zeitwerk for autoload.
Either way, someone will need to try building such a thing with the current Ruby::Box and identify missing features and necessary spec adjustments.

Summary

So, this was a digest introduction to the current Ruby::Box in Ruby 4.0.0, from Endoh’s perspective.

This is only a digest. There are still many corner cases I omitted, and surely there are tons of corner cases and issues that Endoh doesn’t know about (and maybe even the author tagomoris doesn’t recognize).

This is a huge contribution opportunity like few in recent years, so please go play with it a lot.

Top comments (0)