Chapter 25: Dependency Injection Container#
1. The Problem With Hard Dependencies#
Your route handler creates a Tina4::Database instance directly. Your mailer instantiates its SMTP connection inline. Your PDF generator calls Tina4::Api.new(...) inside the method body.
Hard dependencies make code hard to test (you cannot swap the real database for a fake one), hard to reconfigure (the connection string is buried in 12 files), and hard to share (every caller creates its own instance instead of sharing one).
A DI container is a registry. You register services by name once. Everything that needs a service asks the container for it by name. Tests register fake implementations. Production registers real ones.
2. Registering a Service#
Tina4::Container.register(:database) do Tina4::Database.new(ENV["TINA4_DATABASE_URL"])endThe block is a factory. It runs the first time the service is resolved (lazy initialization). The result is cached and reused for every subsequent resolve.
Registering Multiple Services#
Tina4::Container.register(:database) do Tina4::Database.new(ENV["TINA4_DATABASE_URL"])endโTina4::Container.register(:cache) do Tina4::QueryCache.new(default_ttl: 60, max_size: 1000)endโTina4::Container.register(:stripe) do Tina4::Api.new("https://api.stripe.com", { "Authorization" => "Bearer #{ENV['STRIPE_SECRET_KEY']}", "Content-Type" => "application/json", "Stripe-Version" => "2024-11-20" })endโTina4::Container.register(:mailer) do Tina4::Messenger.new( host: ENV["TINA4_MAIL_HOST"], port: ENV["TINA4_MAIL_PORT"].to_i, username: ENV["SMTP_USER"], password: ENV["SMTP_PASS"] )end3. Resolving a Service#
db = Tina4::Container.resolve(:database)users = db.query("SELECT * FROM users")The first resolve call runs the factory block and caches the result. Every subsequent call returns the same instance.
4. Checking Registration#
if Tina4::Container.has?(:cache) cache = Tina4::Container.resolve(:cache) cached = cache.get("products:list")end5. Using Services in Route Handlers#
# @noauthTina4::Router.get("/api/users") do |request, response| db = Tina4::Container.resolve(:database)โ users = db.query("SELECT id, name, email FROM users")โ response.json({ users: users, count: users.length })endโTina4::Router.post("/api/orders") do |request, response| body = request.body db = Tina4::Container.resolve(:database) mailer = Tina4::Container.resolve(:mailer) stripe = Tina4::Container.resolve(:stripe)โ result = stripe.post("/v1/payment_intents", body: { amount: (body["total"].to_f * 100).to_i, currency: "usd" })โ unless result.success? next response.json({ error: "Payment failed" }, 402) endโ order_id = db.execute( "INSERT INTO orders (email, total, payment_intent) VALUES (?, ?, ?)", body["email"], body["total"], result.body["id"] )โ mailer.send( to: body["email"], subject: "Order Confirmation", body: "Your order ##{order_id} has been placed." )โ response.json({ order_id: order_id }, 201)end6. reset: Reset the Container#
Remove all registered services and their cached instances.
Tina4::Container.resetUse reset in tests to start fresh between test cases and register test doubles.
7. Testing With the Container#
Swap real services for fakes in tests without changing production code.
# test/test_orders.rbrequire "minitest/autorun"โclass FakeMailer attr_reader :sentโ def initialize @sent = [] endโ def send(opts) @sent << opts endendโclass FakeStripe def post(path, body:) OpenStruct.new( success?: true, body: { "id" => "pi_test_123" } ) endendโclass FakeDatabase def execute(sql, *args) 42 # Simulated insert returning an ID endendโclass OrderTest < Minitest::Test def setup Tina4::Container.reset @mailer = FakeMailer.newโ Tina4::Container.register(:database) { FakeDatabase.new } Tina4::Container.register(:mailer) { @mailer } Tina4::Container.register(:stripe) { FakeStripe.new } endโ def teardown Tina4::Container.reset endโ def test_order_sends_confirmation_email # Simulate a POST /api/orders request # ...route invocation logic...โ assert_equal 1, @mailer.sent.length assert_equal "Order Confirmation", @mailer.sent.first[:subject] endendNo real database, no real Stripe, no real email -- the test runs in milliseconds and never touches external services.
8. Service Dependencies#
A factory block can resolve other services from the container.
Tina4::Container.register(:order_service) do db = Tina4::Container.resolve(:database) mailer = Tina4::Container.resolve(:mailer) stripe = Tina4::Container.resolve(:stripe)โ OrderService.new(db: db, mailer: mailer, stripe: stripe)endclass OrderService def initialize(db:, mailer:, stripe:) @db = db @mailer = mailer @stripe = stripe endโ def place(email:, total:) result = @stripe.post("/v1/payment_intents", body: { amount: (total * 100).to_i, currency: "usd" })โ raise "Payment failed" unless result.success?โ order_id = @db.execute( "INSERT INTO orders (email, total) VALUES (?, ?)", email, total )โ @mailer.send(to: email, subject: "Order ##{order_id} confirmed")โ order_id endendTina4::Router.post("/api/orders") do |request, response| body = request.body service = Tina4::Container.resolve(:order_service)โ order_id = service.place(email: body["email"], total: body["total"].to_f) response.json({ order_id: order_id }, 201)rescue => e response.json({ error: e.message }, 500)endThe route handler has one job: parse the request, delegate to the service, return the response.
9. Gotchas#
1. reset drops cached instances#
reset removes the factory registration and the cached instance. Any subsequent resolve after reset raises unless you re-register.
2. Singletons by default#
The container caches the first resolved instance. If your service is not thread-safe, wrapping it in a mutex or using a new instance per request is safer.
3. Circular dependencies cause infinite loops#
If service A resolves B, and B resolves A during factory execution, the container loops forever. Restructure to break the cycle.
4. Registration order matters#
A factory block that resolves :database runs when it is first resolved, not when it is registered. Registration order does not matter. Resolution order does.