Multi-Gem Monorepos

Zeitwerk, Versioning, & Namespacing for complex Ruby gems

When I started working on Terminalwire, it was one gem with one namespace. That’s how I recommend you start too, and keep it that way if you can, but not all projects work out that way. In the case of Terminalwire, I didn’t want to ship server code to the client, so I had to break out my hatchet and start chopping up the Terminalwire monolith into multiple gems.

There’s a method to the madness of breaking apart a gem, and I’ll show you how I did it with Zeitwerk and versioning.

Namespacing

Before I started breaking apart gems, I do what any sane Rubyist would do first: namespace the code into what will eventually become its own gem.

That means I moved all of my server code into the Terminalwire::Server namespace, which means I moved the file lib/terminalwire.rb to lib/terminalwire/server.rb and updated the contents to look like this. Code that’s share between namespaces should stay putβ€”that will end up in a “core” gem.

Zeitwerk is your friend here because it will autoload the Terminalwire::Server namespace when you reference it in your code.

# ./lib/terminalwire.rb
require "zeitwerk"
Zeitwerk::Loader.for_gem.tap do |loader|
  loader.setup
end

Nothing too crazy yet, it’s just Zeitwerk’s standard autoloader for a gem.

Take namespacing as far as you can before you start breaking apart your gems. It’s a lot easier to namespace than it is to break apart gems. The same is true for tests or specs, namespace those and move them to their respective locations.

Breaking apart a gem

Once you’ve taken namespaces as far as you can and have validated that your project absolutely cannot be distributed as one gem, it’s time to start busting it apart into smaller gems.

Here’s what the Terminalwire gem monorepo looks like:

tree -R -L 2
β”œβ”€β”€ CHANGELOG.md
β”œβ”€β”€ CODE_OF_CONDUCT.md
β”œβ”€β”€ Gemfile
β”œβ”€β”€ Gemfile.lock
β”œβ”€β”€ LICENSE.txt
β”œβ”€β”€ README.md
β”œβ”€β”€ Rakefile
β”œβ”€β”€ bin
Terminalwire::Client => console
β”‚Β Β  └── setup
β”œβ”€β”€ examples
β”‚Β Β  └── exec
β”œβ”€β”€ gem
β”‚Β Β  β”œβ”€β”€ terminalwire
β”‚Β Β  β”œβ”€β”€ terminalwire-client
β”‚Β Β  β”œβ”€β”€ terminalwire-core
β”‚Β Β  β”œβ”€β”€ terminalwire-rails
β”‚Β Β  └── terminalwire-server
β”œβ”€β”€ spec
β”‚Β Β  β”œβ”€β”€ integration
β”‚Β Β  └── spec_helper.rb
└── support
    └── terminalwire.rb

The mapping between my gem modules and folders looks like this:

Terminalwire => terminalwire-core
Terminalwire::Client => terminalwire-client
Terminalwire::Rails => terminalwire-rails
Terminalwire::Client => terminalwire-client
Terminalwire::Server => terminalwire-server
😎 => terminalwire

Notice something weird? My root namespace is in the terminalwire-core gem and the terminalwire gem is actually a super simple gem with only one dependency on the terminalwire-client gem. In the near future when I start working on auto-updating clients, the terminalwire gem will load whatever version of the client it needs. Again, future post.

This is where things get weird and interesting with Zeitwerk.

Zeitwerk

Zeitwerk is a gem that maps your application’s module & class namespaces to a file. For example, it would map Foo::Bar to the file foo/bar.rb. It’s useful for when a code base gets large and needs to be split up across files.

Seems simple right? It is, but reality is sometimes not so simple. For example, I need to distribute the gem terminalwire-core, but I don’t have a namespace called Terminalwire::Core. Additionally, Ruby gems usually ship with a Terminalwire::VERSION constant that Zeitwerk infers as Terminalwire::Version.

Fortunately, Zeitwerk is highly configurable and can do just about anything you can throw at it. It even ships with a few shortcuts like the Zeitwerk::Loader.for_gem and Zeitwerk::Loader.for_gem_extension methods that set up gems for you.

How do you set all of that up?

The core gem

I’m doing a weird thing in Terminalwire for the core gem. Usually “core” gems are distributed as just the base gem name, like terminalwire. In my case, I want for terminalwire to only distribute the executables with a dependency on terminalwire-client; thus, I had to create the terminalwire-core library.

Here’s what the contents of the terminalwire-core gem looks like.

tree -R lib
lib
β”œβ”€β”€ terminalwire
β”‚Β Β  β”œβ”€β”€ adapter.rb
β”‚Β Β  β”œβ”€β”€ cache.rb
β”‚Β Β  β”œβ”€β”€ logging.rb
β”‚Β Β  β”œβ”€β”€ transport.rb
β”‚Β Β  └── version.rb
β”œβ”€β”€ terminalwire-core.rb
└── terminalwire.rb

There’s an expectation when a gem like terminalwire-core is distributed, people should be able to write require "terminalwire-core" in their application code to get the library. Here’s what that file looks like for Terminalwire.

# ./gems/terminalwire-core/lib/terminalwire-core.rb
require 'terminalwire'

Not much to it, but Zeitwerk would expect for the constant Terminalwire-core to be defined in this file. Let’s look at how that affects the loader.

# ./gems/terminalwire-core/lib/terminalwire-core.rb
require_relative "terminalwire/version"

require "zeitwerk"
Zeitwerk::Loader.for_gem.tap do |loader|
  # If I don't ignore this, Zeitwerk will throw an error.
  loader.ignore File.join(__dir__, "terminalwire-core.rb")
  loader.setup
end

See that loader.ignore entry? If I didn’t add that, Zeitwerk would throw an error in my face that the constant doesn’t exist.

The non-core gems

Let’s have a look at the terminalwire-client gem structure to see what we’re working with.

tree -R lib
lib
β”œβ”€β”€ terminalwire
β”‚Β Β  β”œβ”€β”€ client
β”‚Β Β  β”‚Β Β  β”œβ”€β”€ entitlement
β”‚Β Β  β”‚Β Β  β”‚Β Β  β”œβ”€β”€ environment_variables.rb
β”‚Β Β  β”‚Β Β  β”‚Β Β  β”œβ”€β”€ paths.rb
β”‚Β Β  β”‚Β Β  β”‚Β Β  β”œβ”€β”€ policy.rb
β”‚Β Β  β”‚Β Β  β”‚Β Β  └── schemes.rb
β”‚Β Β  β”‚Β Β  β”œβ”€β”€ entitlement.rb
β”‚Β Β  β”‚Β Β  β”œβ”€β”€ exec.rb
β”‚Β Β  β”‚Β Β  β”œβ”€β”€ handler.rb
β”‚Β Β  β”‚Β Β  β”œβ”€β”€ resource.rb
β”‚Β Β  β”‚Β Β  └── server_license_verification.rb
β”‚Β Β  └── client.rb
└── terminalwire-client.rb

The terminalwire-client file has a similar entry file to terminalwire-core

require 'terminalwire/client'

This is where things are different. I don’t have a base ./gem/terminalwire-client/lib/terminalwire.rb file; instead, the folder where all the fun happens is in ./gem/terminalwire-client/lib/terminalwire/client.rb.

# ./gem/terminalwire-client/lib/terminalwire/client.rb
require "zeitwerk"
Zeitwerk::Loader.for_gem_extension(Terminalwire).tap do |loader|
  loader.setup
end

I have to pass the Terminalwire namespace into the for_gem_extension argument to instruct Zeitwerk that references to Client::Resource are actually Terminalwire::Client::Resource, which maps to the ./gem/terminalwire-client/lib/terminalwire/client/resource.rb file.

Versioning

First, let’s take a look at versioning for multiple gems. Again, avoid it if you can. My approach to versioning monorepos is to peg everything to the same gem version. That means I have a core gem, in the case of Terminalwire, it’s called terminalwire that has a lib/terminalwire/terminalwire/version.rb file.

Core gemspec and version file

Here’s what that looks like.

module Terminalwire
  VERSION = "0.3.0"
end

There are many like it, but this one is mine. I’ll need to access this from all the dependent gems.

Non-core gemspec

When I release multiple gems in a monorepo, I do everything I can to avoid having multiple sources of information in a gemspec. To accomplish that, I load the core gemspec, then copy most of its values to the dependent gemspec.

# Load the core gemspec, which loads `Terminalwire::VERSION`
core = Gem::Specification.load File.expand_path("../terminalwire-core/terminalwire-core.gemspec", __dir__)

Gem::Specification.new do |spec|
  # Gem name is obviously different
  spec.name = "terminalwire-client"

  # But now everything else I copy from the `core` gemspec.
  spec.version = core.version
  spec.authors = core.authors
  spec.email = core.email

  spec.summary = core.summary
  spec.description = core.description
  spec.homepage = core.homepage
  spec.license = core.license
  spec.required_ruby_version = core.required_ruby_version

  spec.metadata = core.metadata
  spec.metadata["source_code_uri"] = "https://github.com/terminalwire/ruby/tree/main/#{spec.name}"

  # I don't touch this because it looks like a lot of blood was spilled figuring this out. Maybe later.
  gemspec = File.basename(__FILE__)
  spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
    ls.readlines("\x0", chomp: true).reject do |f|
      (f == gemspec) ||
        f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
    end
  end
  spec.require_paths = core.require_paths

  # Finally, I add dependencies for this gem here, including the core gem name.
  spec.add_dependency "launchy", "~> 3.0"
  spec.add_dependency core.name, core.version
end

Does this follow SemVer? Nope, but if you haven’t picked up on my theme yet, it’s “don’t do $X until you absolutely must do $X.” In my case, I’m breaking apart code because I don’t want to distribute the client and server to the same machine, but I do want both to run the same version at the same time, so I keep my gem versions the same: when 0.4.2 is released, all gems are bumped to 0.4.2.

Overview

Shipping multiple gems for a project is something you should do your best to avoid, but sometimes you can’t. If you must do it, remember these few things:

  1. If you can help it, don’t break your gem apart.
  2. If you feel the need to break apart your gem, do so with namespaces first.
  3. Try to not need Zeitwerk unless you absolutely need it.
  4. If you do need to break your gems apart, keep them in one repo and try to keep the version the same.

There’s a lot more stuff you have to worry about with multi-gem monorepos, like testing, integration testing, releasing gems, etc. Maybe that too will be covered in a future post. Stay tuned!


Thanks Xavier Noria πŸ¦‹, the author and maintainer of Zeitwerk, for clarifying my thinking and reviewing this post.

Support this blog πŸ€—

If you like what you read and want to see more articles like this, please consider using Terminalwire for your web application’s command-line interface. In under 10 minutes you can build a command-line in your favorite language and web framework, deploy it to your server, then stream it to the Terminalwire thin-client that runs on your users desktops. Terminalwire manages the binaries, installation, and updates, so you can focus on building a great CLI experience.