Multi-Gem Monorepos
Zeitwerk, Versioning, & Namespacing for complex Ruby gems
![](https://terminalwire-assets.fly.storage.tigris.dev/0_0-noj7kcfQWs23OLx2Y10yeL7HF3kh98NTF7O3Rp936kuOIcIj7v7Uwpw0a1DIGlSP0rRLiQVnEQYTIrzuGH2Le3giMNY7JLKhBOIN.png)
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:
- If you can help it, don’t break your gem apart.
- If you feel the need to break apart your gem, do so with namespaces first.
- Try to not need Zeitwerk unless you absolutely need it.
- 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.