Skip to the content.

Author: Amin Boulouma, Software Engineer Github source code: https://github.com/aminblm/ai_systems_design_from_scratch Engineering Blog: https://aminblm.github.io/ai_systems_design_from_scratch/blog/

The Ultimate Guide to Mastering Python Dependency Injection (Without Boilerplate)

Every Senior Engineer has faced the “Hard-Coded Hell” scenario. You’re building a notification service, and it’s tightly coupled to your email provider. When you need to add SMS support or mock the email provider for integration tests, you find yourself refactoring half the codebase. Why does this happen? Because your services are responsible for both doing the work and creating their dependencies.


The Core Concept

Dependency Injection (DI) is an architectural pattern where an object receives its dependencies from an external source rather than creating them itself. By “injecting” dependencies—usually through constructor arguments—you invert the control, making your components decoupled and highly testable.

Glossary for Beginners


Why We Choose DI Over Hard-Coded Instances

We choose DI to enforce Modularity. In enterprise systems, requirements change. Today you use a local filesystem for storage; tomorrow you need AWS S3. If your service is hard-coded to LocalFileStorage, you are trapped. With DI, you simply inject a different implementation that satisfies the same interface.

Why X over Y? We choose constructor-based DI over “Service Locators” (a common anti-pattern in Python) because constructor injection makes dependencies explicit. If you look at the __init__ method, you immediately know what a class requires to function. No hidden globals, no magic lookups.


Implementation: The Dependency Injection Pattern

Simple Example: Constructor Injection

class EmailService:
    def send(self, message):
        print(f"Sending email: {message}")

class NotificationManager:
    # Injecting the dependency
    def __init__(self, service: EmailService):
        self.service = service

    def notify(self, msg):
        self.service.send(msg)

# Usage
service = EmailService()
manager = NotificationManager(service)
manager.notify("Hello World")

Complex Example: Production-Grade Provider Pattern

In production, we often use an abstract base class (interface) and a provider to handle object lifecycle, ensuring that we can swap implementations without touching the business logic.

from abc import ABC, abstractmethod

class NotificationProvider(ABC):
    @abstractmethod
    def send(self, message: str):
        pass

class EmailProvider(NotificationProvider):
    def send(self, message: str):
        print(f"Emailing: {message}")

class SMSProvider(NotificationProvider):
    def send(self, message: str):
        print(f"Texting: {message}")

class NotificationService:
    def __init__(self, provider: NotificationProvider):
        self.provider = provider

    def run(self, msg: str):
        self.provider.send(msg)

# Production initialization
provider = SMSProvider()
service = NotificationService(provider)
service.run("Alert!")

Quick Reference: DI Strategies

Strategy When to use Pros
Constructor Injection Mandatory dependencies Explicit, immutable, testable
Setter Injection Optional/Changing dependencies Flexible
Interface Injection Massive enterprise systems Highly decoupled

Developer Checklist

TL;DR Summary

Stop hard-coding your services. Dependency Injection is the most effective way to decouple your architecture and ensure your system remains resilient to change. By injecting dependencies via constructors, you gain the ability to test in isolation and swap providers effortlessly. Don’t let your services own their dependencies—let them simply use them.