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.1main.rb # Carton.setup_bundle, then import each Cartoncartons/ 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
GemfileandGemfile.lockcover 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.bigdecimalloads insidepricing’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.
The app
Section titled “The app”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::Roundingrescue NameError => e puts "Pricing::Rounding is private: #{e.message.lines.first.strip}"end
puts "bigdecimal loaded in the Main Box: #{!defined?(BigDecimal).nil?}"The Cartons
Section titled “The Cartons”Each Entrypoint requires Carton to define import and export. pricing hides Pricing::Rounding with private_constant:
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::VERSIONend
# Export: the Carton's root module.export Pricingbilling declares pricing in its gemspec and imports it. Each Carton loads once per process, so billing and the app share one pricing Carton:
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# 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')}" endend
# Export: the Carton's root module.export Billingrequire 'carton'
# A Carton whose only runtime gem is carton.module Shipping RATE_PER_KG = 4
def self.quote(kilograms) = kilograms * RATE_PER_KGend
# Export: the Carton's root module.export ShippingThe 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.
Undeclared Gems
Section titled “Undeclared Gems”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/monorepobundle exec ruby ../../carton-tools/exe/carton check main.rbcartons/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 warningsThat 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.rbis a unit test. It loadspricingwith plainrequire, so there is no Box or boundary, and tests the privatePricing::RoundingthroughPricing.const_get(:Rounding). It runs with or withoutRUBY_BOX=1.test/app_test.rbis an integration test. It importsbillingandpricingasmain.rbdoes, tests their Public Surfaces, and checks thatPricing::Roundingandbigdecimalstay 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.rbRUBY_BOX=1 ruby examples/monorepo/test/app_test.rbbundle exec rake example:monorepoRUBY_BOX=1 ruby examples/monorepo/main.rbSee Gems and Bundler and Testing.
