DEV Community

Patoliya Infotech
Patoliya Infotech

Posted on

Advanced Dependency Injection Patterns Beyond Service Containers

Dependency Injection (DI) has long been praised as a fundamental component of code that is clear, testable, and manageable. Service containers (such as Spring in Java, the built-in container in ASP.NET Core, or well-known PHP containers like Symfony/PSR-11) are how most developers initially come into contact with it.

The problem is that service containers are only the first step. You're losing out on effective DI strategies that may advance the scalability, flexibility, and performance of your design if you settle for "bind this, resolve that."

Let's move beyond service containers in this post and examine sophisticated DI techniques that are used by seasoned developers.

Constructor Injection vs. Method Injection vs. Property Injection

The basic forms of DI are well-known, but choosing the right one can massively impact testability and coupling.

Constructor Injection (preferred default):

public class OrderService
{
    private readonly IPaymentGateway _gateway;

    public OrderService(IPaymentGateway gateway)  
    {
        _gateway = gateway;
    }

    public void PlaceOrder(Order order) => _gateway.Charge(order.Amount);
}
Enter fullscreen mode Exit fullscreen mode
  • ✅ Immutable dependencies
  • ✅ Easy to test
  • ❌ Can lead to “constructor bloat” if you inject too many

Method Injection (better for context-specific needs):

public void ProcessRefund(Order order, IPaymentGateway gateway)
{
    gateway.Refund(order.Amount);
}
Enter fullscreen mode Exit fullscreen mode
  • ✅ Great for one-off dependencies
  • ❌ Harder to test at scale

Property Injection (use sparingly):

public ILogger Logger { get; set; }

Enter fullscreen mode Exit fullscreen mode
  • ✅ Good for optional dependencies
  • ❌ Risky if properties aren’t initialized

Pro tip: Use constructor injection by default. Fall back to method/property injection only when you want flexibility or optional behaviors.

PHP is still highly relevant for building scalable, dynamic apps—see why PHP is a top choice for e-commerce development

Composition Root Pattern – The Real Powerhouse

One of the biggest mistakes with DI is scattering container.Resolve<T>() calls throughout the codebase. That’s just a service locator anti-pattern in disguise.
Instead, define a composition root – a single place where all object graphs are composed.

Example in ASP.NET Core:

public void ConfigureServices(IServiceCollection services)
{
    services.AddScoped<IOrderService, OrderService>();
    services.AddScoped<IPaymentGateway, StripePaymentGateway>();
}
Enter fullscreen mode Exit fullscreen mode

Your application will now execute without ever contacting the container again. This maintains the purity and framework independence of your essential domain logic.

Typed Factories (Dynamic Injection at Runtime)

Sometimes you need to decide dependencies at runtime – but without hardcoding new calls. Enter typed factories.

Example: Strategy-based payment gateways

public interface IPaymentGatewayFactory
{
    IPaymentGateway Create(string provider);
}
Enter fullscreen mode Exit fullscreen mode

Implementation using DI container (example in Autofac):

builder.RegisterType<StripePaymentGateway>().Keyed<IPaymentGateway>("stripe");
builder.RegisterType<PayPalPaymentGateway>().Keyed<IPaymentGateway>("paypal");

builder.Register<Func<string, IPaymentGateway>>(c =>
{
    var ctx = c.Resolve<IComponentContext>();
    return key => ctx.ResolveKeyed<IPaymentGateway>(key);
});
Enter fullscreen mode Exit fullscreen mode

Usage:

var gateway = factory("paypal");
gateway.Charge(500);
Enter fullscreen mode Exit fullscreen mode

Now you can resolve implementations dynamically without scattering if/else or switch statements everywhere.
Scoped & Contextual Injection
Not all dependencies should live forever. For example, a DbContext or request-specific cache needs a scoped lifetime.

Learn more about PHP & It's Trending Frameworks

Scoped injection in ASP.NET Core:

services.AddScoped<IUnitOfWork, EfUnitOfWork>();

Enter fullscreen mode Exit fullscreen mode

Contextual injection goes even deeper – injecting different implementations based on runtime context. For example, logging differently in Development vs Production.

services.AddScoped<ILogger>(sp =>
{
    var env = sp.GetRequiredService<IHostEnvironment>();
    return env.IsDevelopment() ? new ConsoleLogger() : new CloudLogger();
});
Enter fullscreen mode Exit fullscreen mode

Interception & Decorators (Cross-Cutting Concerns)

Instead of bloating your services with logging, caching, or retry logic, you can use decorators.

Example: Adding retry logic around an external API call.

public class RetryPaymentGateway : IPaymentGateway
{
    private readonly IPaymentGateway _inner;

    public RetryPaymentGateway(IPaymentGateway inner)
    {
        _inner = inner;
    }

    public void Charge(decimal amount)
    {
        int retries = 3;
        while (true)
        {
            try
            {
                _inner.Charge(amount);
                break;
            }
            catch (Exception ex) when (retries-- > 0)
            {
                // Retry
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

To wrap current services, this may be registered in DI Containers such as Autofac or SimpleInjector
Cross-cutting issues are now openly introduced while your business rationale remains clear.

DI Without a Container (Pure DI)

Understanding when to avoid using a container altogether is the true art of expertise.
A straightforward manual composition root might be more streamlined for tiny programs or libraries:

var gateway = new StripePaymentGateway();
var service = new OrderService(gateway);
Enter fullscreen mode Exit fullscreen mode

This avoids unnecessary abstraction overhead. Remember: containers are a tool, not a rule.

Final Thoughts

Dependency Injection is not just about wiring dependencies through a service container. At its core, it’s a design principle that enables:

  • Cleaner architecture – decoupling business logic from infrastructure concerns.
  • Better testability – injecting fakes, mocks, and stubs without hacking core code.
  • Flexibility at scale – swapping implementations at runtime or per environment.
  • Maintainability – centralizing object creation in a composition root instead of spreading new calls everywhere. The real maturity comes when you:
  1. Use constructor injection as your default, but lean on method/property injection where flexibility is needed.
  2. Centralize dependency wiring in a composition root.
  3. Apply factories, scoped lifetimes, and decorators for runtime dynamism and cross-cutting concerns.
  4. Recognize when a container is helpful vs. when pure DI is simpler.

In short: a service container is a tool; Dependency Injection is a mindset. The more you treat DI as an architectural principle instead of a framework trick, the more resilient, scalable, and test-friendly your systems will become.

Top comments (0)