Tina4

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#

ruby
Tina4::Container.register(:database) do  Tina4::Database.new(ENV["TINA4_DATABASE_URL"])end

The 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#

ruby
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"]  )end

3. Resolving a Service#

ruby
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#

ruby
if Tina4::Container.has?(:cache)  cache = Tina4::Container.resolve(:cache)  cached = cache.get("products:list")end

5. Using Services in Route Handlers#

ruby
# @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)end

6. reset: Reset the Container#

Remove all registered services and their cached instances.

ruby
Tina4::Container.reset

Use 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.

ruby
# 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]  endend

No 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.

ruby
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)end
ruby
class 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  endend
ruby
Tina4::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)end

The 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.