Skip to content

Monorepo

Directories alone do not put a boundary around each of an app’s domains. Here each domain is a Gem Carton: a path gem with declared runtime gems, an Interior, and a Public Surface. Each runs in its own Carton Box, with Bundler selecting its gem versions (#32). Source on GitHub.

Gemfile # carton, the three Cartons, bigdecimal 4.1.1
main.rb # Carton.setup_bundle, then import each Carton
cartons/
pricing/
pricing.gemspec # depends on bigdecimal
lib/pricing.rb # Entrypoint: export Pricing
billing/
billing.gemspec # depends on pricing
lib/billing.rb # Pricing = import 'pricing'
shipping/
shipping.gemspec # only depends on carton
lib/shipping.rb
  • One Gemfile and Gemfile.lock cover the whole monorepo and pin every third-party version.
  • import 'billing' loads a Carton by gem name in its own Carton Box. Only its Export crosses back.
  • bigdecimal loads inside pricing’s Box at the locked version, not in the Main Box.

A Carton can leave the monorepo as a published gem without changes, since it already is one.

main.rb
require 'carton'
# One Gemfile for the whole monorepo. Bundler puts each Carton's lib/ on the
# load path, so Cartons are imported by gem name.
Carton.setup_bundle
# Carton boundary: each import loads one Carton in its own Box.
Billing = import 'billing'
Shipping = import 'shipping'
Pricing = import 'pricing'
puts Billing.invoice(42, %w[19.99 5.01])
puts "Shipping for 3 kg: #{Shipping.quote(3)}"
puts "Pricing uses bigdecimal #{Pricing.bigdecimal_version}"
puts
# Billing imported pricing first; the app gets the same Carton.
puts "Billing and the app share one pricing Carton: #{Billing::Pricing.equal?(Pricing)}"
begin
Pricing::Rounding
rescue NameError => e
puts "Pricing::Rounding is private: #{e.message.lines.first.strip}"
end
puts "bigdecimal loaded in the Main Box: #{!defined?(BigDecimal).nil?}"

Each Entrypoint requires Carton to define import and export. pricing hides Pricing::Rounding with private_constant:

cartons/pricing/lib/pricing.rb
require 'carton'
# Resolved by the app's lockfile, so every Carton sees the same version.
require 'bigdecimal'
module Pricing
# Hidden from Importers by Ruby's own visibility, which holds across Boxes.
module Rounding
def self.cents(amount) = amount.round(2)
end
private_constant :Rounding
def self.total(prices)
Rounding.cents(prices.sum { |price| BigDecimal(price) })
end
def self.bigdecimal_version = BigDecimal::VERSION
end
# Export: the Carton's root module.
export Pricing

billing declares pricing in its gemspec and imports it. Each Carton loads once per process, so billing and the app share one pricing Carton:

cartons/billing/billing.gemspec
Gem::Specification.new do |spec|
spec.name = 'billing'
spec.version = '0.1.0'
spec.summary = 'Builds invoices from priced line items'
spec.authors = ['Carton']
spec.files = ['lib/billing.rb']
# The runtime gems this Carton uses.
spec.add_dependency 'carton'
spec.add_dependency 'pricing'
end
cartons/billing/lib/billing.rb
# Define import/export for ordinary gem consumers too.
require 'carton'
module Billing
# Carton boundary: pricing is an Imported Carton, declared in billing's
# gemspec. Every Importer receives the same Export.
Pricing = import 'pricing'
def self.invoice(number, prices)
"Invoice #{number}: #{Pricing.total(prices).to_s('F')}"
end
end
# Export: the Carton's root module.
export Billing
cartons/shipping/lib/shipping.rb
require 'carton'
# A Carton whose only runtime gem is carton.
module Shipping
RATE_PER_KG = 4
def self.quote(kilograms) = kilograms * RATE_PER_KG
end
# Export: the Carton's root module.
export Shipping

The leaf Cartons pricing and shipping also work with ordinary require without Boxes enabled. billing imports pricing, so it needs RUBY_BOX=1 even when loaded with require.

A Gem Carton imported by a bundled Importer sees the Importer’s whole lockfile, as Bundler.setup gives the Main Box. The example declares every runtime gem and passes carton check. To see an Undeclared Gem reported, temporarily add Shipping = import 'shipping' inside the Billing module in cartons/billing/lib/billing.rb, then run:

cd examples/monorepo
bundle exec ruby ../../carton-tools/exe/carton check main.rb
cartons/billing/lib/billing.rb:7:14: error: import 'shipping' loads the shipping gem, which billing's gemspec does not declare: add spec.add_dependency 'shipping' [undeclared-gem]
14 files, 3 Cartons, 1 error, 0 warnings

That import would work at runtime even though billing.gemspec does not declare it. Enforcing declarations at runtime too is proposed in #38 as strict Gem Cartons.

test/ shows both ways to test a Carton, under the monorepo’s bundle:

  • test/pricing_test.rb is a unit test. It loads pricing with plain require, so there is no Box or boundary, and tests the private Pricing::Rounding through Pricing.const_get(:Rounding). It runs with or without RUBY_BOX=1.
  • test/app_test.rb is an integration test. It imports billing and pricing as main.rb does, tests their Public Surfaces, and checks that Pricing::Rounding and bigdecimal stay hidden.

Each file runs in its own process, since the unit test’s require would load a Second Copy of pricing beside the imported one.

ruby examples/monorepo/test/pricing_test.rb
RUBY_BOX=1 ruby examples/monorepo/test/app_test.rb
bundle exec rake example:monorepo
RUBY_BOX=1 ruby examples/monorepo/main.rb

See Gems and Bundler and Testing.