Dependency Injection: Beyond the Service Container

Philip Rehberger Aug 25, 2026 6 min read

Move past framework magic and understand DI as a design tool. Constructor vs setter vs interface injection compared.

Dependency injection is one of those concepts that gets reduced to "constructor parameters and a service container" in most framework tutorials. That reduction is correct in the way "a car is a thing with wheels" is correct — accurate as far as it goes, missing most of what matters.

DI is, more fundamentally, a design technique that pushes the decision of "which collaborator do I use?" out of the class that needs the collaborator. The service container is a mechanism for implementing it. They are not the same thing, and understanding the difference matters when DI starts producing strange code.

What Injection Actually Buys

A class that constructs its own collaborators is hard to test, hard to reuse, and hard to change. The dependency on the collaborator is implicit, hidden inside the constructor or a method.

final class SendInvoiceEmail
{
    public function send(Invoice $invoice): void
    {
        $client = new MailgunClient(config('services.mailgun.key'));
        $renderer = new InvoiceTemplate();
        $body = $renderer->render($invoice);
        $client->send($invoice->customer_email, 'Your invoice', $body);
    }
}

This class has hard-coded the mail provider, the template, and the credential source. Testing it requires either hitting Mailgun or some clever trick.

Inject the collaborators and the dependencies become explicit:

final class SendInvoiceEmail
{
    public function __construct(
        private MailClient $mail,
        private InvoiceTemplate $template,
    ) {}

    public function send(Invoice $invoice): void
    {
        $body = $this->template->render($invoice);
        $this->mail->send($invoice->customer_email, 'Your invoice', $body);
    }
}

MailClient can be an interface; the test uses a fake, production uses Mailgun. The class no longer knows how its dependencies are constructed.

The Three Injection Styles

Constructor injection. Dependencies are required to construct the object. Once constructed, the object is fully wired. This is the default for almost every modern framework, and the right choice unless you have a specific reason to deviate.

Setter injection. Dependencies are set via methods after construction. Useful when a dependency is genuinely optional, or when you have a circular dependency that constructor injection cannot resolve.

final class Logger
{
    private ?MetricsRecorder $metrics = null;

    public function setMetrics(MetricsRecorder $metrics): void
    {
        $this->metrics = $metrics;
    }
}

Setter injection means the object can exist in a partially-wired state. The class has to handle the null case, which is more code than constructor injection. Use sparingly.

Method injection. A dependency is passed into the specific method that needs it. Common for dependencies that change per call.

final class Report
{
    public function generate(ReportFormat $format): string
    {
        return $format->render($this->data);
    }
}

Useful when the dependency is request-specific (the format chosen by the user), not application-wide.

When the Container Helps

A service container resolves the dependency graph automatically. You ask for SendInvoiceEmail; the container looks at the constructor, sees it needs MailClient and InvoiceTemplate, resolves those (which may themselves have dependencies), and hands you a fully-wired object.

This scales well: an object with eight dependencies, each with their own dependencies, is awkward to wire by hand and trivial for the container. The container is doing the boring work.

// Laravel binding
$this->app->bind(MailClient::class, MailgunClient::class);

// Anywhere downstream
$mailer = app(SendInvoiceEmail::class);
// MailClient is wired to MailgunClient, transitively

When the Container Hurts

The container becomes a problem when it is used to hide complexity. Some symptoms:

Service location masquerading as injection. Calling app(SomeService::class) from inside a class is service location, not DI. The dependency is hidden again, just behind a global function. If you grep for app( and find it inside your domain classes, you have lost most of DI's benefit.

Too-clever bindings. When the binding logic for an interface includes conditionals on the request, the user, or the environment, the wiring stops being predictable. Two callers asking for the same interface might get different implementations for non-obvious reasons.

Container-only configuration. When the only way to understand what a class needs is to read both the class and the container bindings, the dependency graph becomes implicit again. The class declares MailClient; the container decides what that means.

Interface Injection vs Concrete Injection

A common debate: should you inject interfaces or concrete classes?

Inject interfaces when you have a real reason to swap implementations — testing with a fake, supporting multiple providers, future-proofing against vendor changes. Inject concretes when the implementation is the only one that will ever exist and the interface would be ceremonial.

// Interface — multiple implementations make sense
public function __construct(private MailClient $mail) {}

// Concrete — no other implementation exists or is planned
public function __construct(private InvoiceCalculator $calculator) {}

The "always inject interfaces" reflex produces interfaces that exist only to be implemented once. Each one is a small cost: another file to read, another binding to maintain. Resist unless the benefit is real.

Constructor Injection Has Limits

When a class needs eight dependencies, the problem is not the constructor — it is the class. Eight dependencies usually means the class is doing eight things, and one of them should probably move somewhere else.

// Almost certainly too many dependencies
public function __construct(
    private UserRepository $users,
    private OrderRepository $orders,
    private MailClient $mail,
    private SmsClient $sms,
    private SlackClient $slack,
    private InvoiceRenderer $renderer,
    private PaymentGateway $payments,
    private AuditLogger $audit,
) {}

The fix is not to inject a "facade" that bundles them. The fix is to split the class into pieces that have fewer collaborators each. DI's friction with too many dependencies is a feature: it makes design problems visible.

Practical Defaults

For most application code:

  • Use constructor injection unless you have a specific reason not to
  • Inject concrete classes by default; introduce interfaces when you actually need them
  • Let the container resolve dependencies; do not call the container from inside domain code
  • If a class has more than four or five dependencies, split it before reaching for more advanced injection patterns

The point of DI is not the technique itself. It is the design discipline of making dependencies explicit and decoupling construction from use. When that discipline pays off, the code is easier to test, easier to change, and easier to read. When it doesn't, you are usually moving complexity rather than removing it.


Working through a codebase where the service container has become a maze of bindings? We help teams refactor for clearer dependency graphs and fewer hidden collaborators. scopeforged.com

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.