Skip to content

Dependency injection

A domain Carton cannot see the application’s database models, payment client, or logger by constant name. Pass the objects it needs through ordinary constructor arguments. This checkout Carton calculates an order’s total, charges it, saves the payment reference, and logs success. Its host supplies storage, payments, and logging. Source on GitHub.

main.rb # Main: sets up the bundle, imports checkout, wires adapters
adapters.rb # Main: JSON order storage and simulated payments
checkout.rb # Carton Entrypoint: export Checkout
checkout/service.rb # Inside the Carton: Checkout::Service
test/checkout_test.rb # Imports checkout and supplies fakes
test/host_test.rb # Imports checkout and exercises the file-backed adapters

The Carton never requires the host or sets up a bundle. Its Public Surface is a constructor and one method:

checkout.rb
# These files are inside one Carton; requiring them crosses no boundary.
require_relative 'checkout/service'
# Export: the host receives this module, not the Carton's Box.
export Checkout
checkout/service.rb
module Checkout
class Service
def initialize(orders:, payments:, logger:)
@orders = orders
@payments = payments
@logger = logger
end
def call(order_id:)
order = @orders.fetch(order_id)
total_cents =
order
.fetch(:items)
.sum { |item| item.fetch(:unit_price_cents) * item.fetch(:quantity) }
# Injected methods run where they were defined, in the host's Main Box.
payment_id = @payments.charge(order_id:, amount_cents: total_cents)
@orders.save(order.merge(payment_id:))
@logger.info("Paid order #{order_id}: #{total_cents} cents")
# Plain values cross back; no repository or payment-provider types leak.
{ order_id:, total_cents:, payment_id: }
end
end
end

The injected objects need only these methods:

Object Method Result
orders fetch(id) An order Hash with :id and :items; each item has integer :unit_price_cents and :quantity
orders save(order) Stores the order with its :payment_id; return value is ignored
payments charge(order_id:, amount_cents:) A payment ID String, or raises on failure
logger info(message) Logs the message; return value is ignored

main.rb loads the adapters with require_relative, then imports checkout and wires them in:

main.rb
require 'carton'
# Bundle boundary: only Main sets up this bundle. Checkout uses plain Ruby.
Carton.setup_bundle
require 'logger'
require 'tmpdir'
require_relative 'adapters'
# Carton boundary: only Checkout's export crosses into Main.
Checkout = import_relative 'checkout'
Dir.mktmpdir('carton-checkout') do |dir|
orders_path = File.join(dir, 'orders.json')
payments_path = File.join(dir, 'payments.jsonl')
File.write(
orders_path,
JSON.generate(
[{ id: 42, items: [{ unit_price_cents: 1_500, quantity: 2 }] }],
),
)
orders = Adapters::FileOrders.new(orders_path)
logger =
Logger.new($stdout, formatter: ->(_, _, _, message) { "#{message}\n" })
# Ordinary constructor injection. The objects are shared, not copied into
# the Carton; their methods retain Main's constants and loaded gems.
checkout =
Checkout::Service.new(
orders:,
payments: Adapters::DemoPayments.new(payments_path),
logger:,
)
result = checkout.call(order_id: 42)
puts "Checkout result: #{result.inspect}"
puts "Stored payment: #{orders.fetch(42).fetch(:payment_id)}"
puts "Recorded charge: #{File.read(payments_path).strip}"
end

These are shared Ruby objects, not copies or remote proxies. Their methods run in the Box where they were defined, so the host’s adapters still resolve JSON and Logger in Main. The Carton calls the objects it receives without seeing the host’s constants. Orders and results are plain Hashes, Arrays, Strings, and Integers, so neither side needs the other’s types. The service saves a new Hash instead of mutating the order it fetched.

The demo host stores orders in JSON and records simulated charges in a temporary directory. No real payments, network, or credentials are involved. A real app would supply its database repository and payment client through the same methods. The example omits payment retries, idempotency, and recovery if saving fails after charging.

If a dependency raises, the exception reaches the Importer unchanged. A declined payment does not save the order or log success.

Both tests import the real checkout Carton. checkout_test.rb supplies fakes and checks totals, adapter calls, and failures without files or a payment provider. It defines the payment error in Main and rescues it there after it passes through the Carton. host_test.rb supplies the demo adapters and checks the stored order, charge, and log output. Neither loads the Carton’s Interior directly.

Every Shared Import returns the same Export module, but each Checkout::Service.new receives its own dependencies, so there is no module-level state to reset between tests.

bundle exec rake example:dependency_injection

Once the bundle is installed, run the host or tests directly:

RUBY_BOX=1 ruby examples/dependency_injection/main.rb
RUBY_BOX=1 ruby examples/dependency_injection/test/checkout_test.rb
RUBY_BOX=1 ruby examples/dependency_injection/test/host_test.rb
Paid order 42: 3000 cents
Checkout result: {order_id: 42, total_cents: 3000, payment_id: "demo-42"}
Stored payment: demo-42
Recorded charge: {"id":"demo-42","order_id":42,"amount_cents":3000}

See Sharing classes and services for the pattern.