DEV Community

Cover image for Constructor Hell: Replacing Dependency Injection with Chain of Responsibility in Ruby
andriy-baran
andriy-baran

Posted on

Constructor Hell: Replacing Dependency Injection with Chain of Responsibility in Ruby

Previously in this series:


The Hook: The One-Line Change That Touched Five Files

At what point did I realize my architecture was failing?

It wasn't a production crash. It was a request to add a single permission check to a nested form field.

"Show the wholesale_price field only if the user has enterprise_view permission."

Simple, right? Not in my app. Because to get that permission check into that nested field, I had to:

  1. Update the Handler constructor to take current_user.
  2. Pass it to the Form constructor.
  3. Pass it to the Collection constructor.
  4. Pass it to the Subform constructor.
  5. Pass it to the Element instance.

I was manually dragging context through five layers of objects just so a tiny subform could check a single flag.

If I had to pass tenant_id or current_user into one more constructor, I was ready to quit web dev and become a hardware engineer. At least in hardware, wires don't have to be manually passed through every intermediate component.


The Constructor Hell Reality: "Pure" DI is a Trap

I was committed to "pure" dependency injection. I'd read the books. I knew "globals are evil." I knew "helpers are hidden state."

The logic: If an object needs something, it should be passed in the constructor. No exceptions.

The result:

# The "Pure" way to despair
form = ProductForm.new(
  object: product,
  current_user: current_user,
  account: account,
  feature_flags: feature_flags,
  pricing_tier: pricing_tier,
  policy: policy
)

# Deep in the subform...
subform = VariantForm.new(
  object: variant,
  current_user: current_user,      # PASSING
  account: account,                # PASSING
  feature_flags: feature_flags,    # PASSING
  pricing_tier: pricing_tier       # PASSING
)
Enter fullscreen mode Exit fullscreen mode

Every intermediate layer became a context shuttle. These objects didn't use the context; they just carried it for their children.

It was fragile. It was noisy. And it made refactoring impossible—adding one piece of context meant changing five files.

The paradox: In my attempt to make dependencies "explicit," I made the system so rigid it was unmaintainable.


The False Choice: Sledgehammer vs. Spaghetti

I spent years thinking my only options were:

  1. Constructor Hell: Passing everything down manually (The "Pure" Way).
  2. Dirty Shortcuts: Using Current.user or global helpers (The "Lazy" Way).

I hated both.

  • Option 1 made my code unreadable and rigid.
  • Option 2 made my code untestable and mysterious.

I was looking for a third way: stop treating context like a constructor argument, and start treating your system like a tree.

Build an explicit ownership tree, and make one rule non-negotiable: every object knows its owner. Then children can request context from above instead of you injecting it through every layer.

This isn't just a theory. The libraries in this series implement this pattern directly:

  • SteelWheel builds the tree (handler owns the request and creates the form with owner: self)
  • ActionForm uses owner + owner_* to bubble context up the chain
  • EasyParams uses the same owner_* idea inside parameter objects, so validations can ask the handler (or other owners) for context

The Browser Epiphany: Why Does JS Bubble?

Think about how events work in the browser.

You click a button deep in the DOM. You don't have to pass an onClick handler from the <html> tag down through every single div to reach that button.

Instead, the event bubbles up.

The button "screams" that it was clicked, and it bubbles up the hierarchy until some parent element decides to handle it.

The insight: Why do we love the way events bubble up the DOM, yet I spent a decade forcing my Ruby objects to be strictly, painfully top-down?

Why can't my nested form element "scream" its request for context up the hierarchy?


Stop Passing Context. Start Requesting It

I decided to stop passing context and start letting components request it.

Instead of DI-by-constructor, I built a tree and gave every node an owner. Once every object knows who owns it, context can bubble upward on demand.

I used method_missing to implement an ownership chain. If an element doesn't know the answer, it asks its owner. If the owner doesn't know, it asks its owner.

# Instead of passing everything...
form = ProductForm.new(object: product, owner: handler)

# The element just screams its request
element :wholesale_price do
  def render?
    owner_can_view_wholesale?  # "Hey, someone up there, can I do this?"
  end
end
Enter fullscreen mode Exit fullscreen mode

The mechanics:

  1. Element calls owner_can_view_wholesale?.
  2. method_missing intercepts the call because of the owner_ prefix.
  3. The element strips the prefix and tries can_view_wholesale? on its owner (the subform).
  4. Subform doesn't have it? It repeats the same delegation to its owner (the form).
  5. Form doesn't have it? It repeats again to its owner (the handler).
  6. Handler has can_view_wholesale?. Answer returned.

Context bubbles up, not down.


The Tree Duality: Composition Goes Both Ways

This works because your object graph is already a tree.

A Handler contains a Form. A Form contains Elements. That’s normal composition — objects containing other objects.

But here’s the missing mental model: trees support delegation in both directions:

  1. Downward (Parent → Children): normal orchestration (handler calls form, form iterates elements)
  2. Upward (Children → Parent): context bubbling (elements ask owners for context)

And the deeper your composition is, the more this pays off.

If your object graph is only 1–2 levels deep, constructor DI feels fine. But once you have real-world nesting—forms inside forms, collections, subforms, elements inside elements—every new piece of context becomes a change to every intermediate constructor.

Owner delegation flips that scaling: deep trees are no longer a liability. They’re exactly what makes bubbling powerful.

We’re taught to think composition is one-way. Parent creates children, parent calls children. Done.

Owner delegation adds the other half: children can ask upward without you manually shuttling context through every intermediate constructor.

# Downward: Normal composition (parent → children)
class ProductHandler < ApplicationHandler
  def form
    @form ||= ProductForm.new(owner: self)  # Handler → Form
  end

  def on_validation_success
    form.elements.each(&:finalize)  # Handler → Form → Elements
  end
end

# Upward: Context bubbling (children → parent)
class PriceElement < ActionForm::Element
  def render?
    owner_can_view_pricing?  # Element → Form → Handler
  end

  def currency
    owner_current_currency  # Element → Form → Handler
  end
end
Enter fullscreen mode Exit fullscreen mode

Same tree. Two directions. No constructor hell. No hidden globals.


Transparent Structures: Runtime Specialization Without the Pain

This is the name I use for the broader idea: Transparent Structures.

A structure is “transparent” when you can open any component class at runtime (form, subform, element, params schema) and specialize its behavior in a precise, local way—without rewriting the whole graph or threading new dependencies through every constructor.

Owner delegation is one part of it: it makes context flow up through the structure when a deep leaf needs something.


The owner_ Signal: Magic With Intent

I chose the explicit owner_ prefix for a reason.

I know it’s a "magic" trick. I know some Rubyists hate method_missing. But I needed it to be explicit.

I didn't want mysterious method calls where you don't know where the data comes from. I wanted any developer—including my future self—to see owner_ and immediately understand: "I am delegating this request up the ownership chain."

It’s a signal: "I don't have this context, but my owner does."

It turns a hidden dependency into an explicit request.


The Sharp Edges (and Why It's Still Worth It)

Yes, method_missing is sharp. The trick is to use it with guardrails:

  • Make delegation opt-in: only delegate when the call starts with owner_.
  • Implement respond_to_missing?: so introspection and tooling don't lie.
  • Keep the API narrow: you’re not delegating “everything”, you’re delegating explicit requests for context.
  • Fail normally: if the chain can’t answer, let Ruby raise NoMethodError (or add a custom error message later if you want).

This isn’t “mysterious magic.” It’s a deliberate mechanism for one specific problem: context bubbling.

And it’s not hypothetical — the core idea in ActionForm::Composition is literally:

def method_missing(name, *attrs, **kwargs, &block)
  return super unless name.to_s.start_with?("owner_")

  owner_method = name.to_s.sub("owner_", "").to_sym
  return super unless (handler = owners_chain.detect { |o| o.respond_to?(owner_method) })

  handler.public_send(owner_method, *attrs, **kwargs, &block)
end
Enter fullscreen mode Exit fullscreen mode

The Chain of Responsibility: A Multi-Level Power Grid

Passing context through constructors is like manually wiring every single lightbulb in your house directly back to the city's power plant. If you want to add one lamp in the attic, you have to rip open every floorboard to run the wire.

Owner Delegation is a modern electrical grid.

You just plug the lamp into the nearest wall socket (the owner_ call), and the house takes care of "bubbling" that request up to the main breaker for you.

The hierarchy:

  1. Element: "I need the current user's role."
  2. Subform: "Don't know, asking the form."
  3. Form: "Don't know, asking the handler."
  4. Handler: "Here it is."

Intermediate layers (Subform, Form) don't need to know anything about the current user's role. They just need to know who their owner is.

This is the Chain of Responsibility pattern, applied to composition.


The DX Shift: Vibe Coding Without the Weight

I’m now "vibe coding" without the weight of a thousand manual injections.

When a requirement changes, I don't dread it. I don't have to touch five files to add one permission check. I just call owner_something? and add the method to the handler.

Can I finally admit it? The "Rails Way" stopped being the most productive path for my complex domains a long time ago.

We’ve been taught to fear "magic" and "metaprogramming." But I’ve learned that the magic of structured delegation is far less dangerous than the boilerplate of manual injection.

One keeps your code clean. The other keeps you in the office until 11 PM on a Friday.


Resources


Next time you find yourself passing a variable through three constructors just to reach a child element:

Ask yourself: Are you building a house, or are you just running miles of redundant wire?

Remember: trees aren't just for pushing commands down. They're for pulling context up.

Maybe it's time to install the grid.

Top comments (0)