How I Use Dependency Injection in Python Without a Framework

Here’s how I implement dependency injection in real-world Python projects — without adding unnecessary complexity or third-party tools.

How I Use Dependency Injection in Python Without a Framework
Photo by Sara Bakhshi on Unsplash

Most developers think dependency injection needs a fancy framework. The truth? You can do it cleanly with plain Python.

How I Use Dependency Injection in Python Without a Framework

And why you probably should too…..

Dependency Injection (DI) is often seen as a heavyweight concept tied to massive frameworks and enterprise-level applications.

When you hear “DI,” your mind might jump to Spring (Java), .NET’s built-in container, or FastAPI’s dependency system.

But here’s a little secret:

You don’t need a framework to use dependency injection in Python.

In fact, once I embraced a framework-free approach to DI, my code became cleaner, easier to test, and more modular — without sacrificing simplicity.

Let me walk you through how I use dependency injection in plain Python, why it works beautifully, and how you can adopt it today.


What is Dependency Injection (In Plain English)?

Dependency Injection is a technique where objects receive their dependencies from the outside rather than creating them internally.

Instead of this:

class Service: 
    def __init__(self): 
        self.db = Database()  # hardcoded dependency

You do this:

class Service: 
    def __init__(self, db: Database): 
        self.db = db  # injected dependency

The goal is decoupling. Your classes shouldn’t care how their dependencies are constructed — they should just use them.

Why Bother With Dependency Injection?

If you’ve never used DI before, it might feel like extra work. But here’s why I use it:

Testability: I can pass mock dependencies in unit tests.
Flexibility: I can swap implementations (e.g., SQLite vs PostgreSQL) with zero changes to business logic.
Clarity: My class responsibilities become clearer. No more hidden instantiations.

How I Do Dependency Injection Without a Framework

Let’s break this down into a few practical techniques I use in everyday Python projects.


1. Constructor Injection (My Go-To Method)

The simplest and most effective form of DI.

class EmailService: 
    def send(self, to: str, subject: str, body: str): 
        print(f"Sending email to {to} with subject '{subject}'") 
 
class UserManager: 
    def __init__(self, email_service: EmailService): 
        self.email_service = email_service 
 
    def register_user(self, email: str): 
        # Logic to save user... 
        self.email_service.send(email, "Welcome!", "Thanks for registering.")

Now I can inject a fake EmailService during testing:

class FakeEmailService: 
    def send(self, to: str, subject: str, body: str): 
        print(f"[TEST] Pretending to send email to {to}") 
 
user_manager = UserManager(email_service=FakeEmailService())

No framework. Just clean, testable Python.

2. Function-Based Injection (When Things Get Functional)

Python’s first-class functions make this super natural:

def notify_user(email: str, send_email): 
    send_email(email, "Notification", "You’ve got updates!") 
 
def real_email_sender(to, subject, body): 
    print(f"Sending real email to {to}") 
 
notify_user("hello@example.com", send_email=real_email_sender)

Again, easy to mock, swap, and test.

3. Manual Wiring with a Composition Root

A composition root is the place where you wire up your dependencies.

I usually keep it in main.py:

def main(): 
    email_service = EmailService() 
    user_manager = UserManager(email_service=email_service) 
     
    user_manager.register_user("test@example.com") 
 
if __name__ == "__main__": 
    main()

All the wiring happens in one place. The rest of the app remains dependency-agnostic.

4. Optional: Use Python’s Protocol for Better Flexibility

If you’re on Python 3.8+ with typing.Protocol, you can define interfaces without abstract base classes:

from typing import Protocol 
 
class EmailSender(Protocol): 
    def send(self, to: str, subject: str, body: str): ... 
 
class RealEmailSender: 
    def send(self, to, subject, body): 
        print("Real email sent") 
 
def notify_user(email: str, sender: EmailSender): 
    sender.send(email, "Hi", "Thanks for signing up.")

This gives you the benefits of interface-driven programming — without the rigidity of a full-blown framework.

Testing Is a Breeze

Here’s how dependency injection has made my tests cleaner:

def test_user_registration_sends_email(): 
    sent = {} 
 
    class TestEmailService: 
        def send(self, to, subject, body): 
            sent['to'] = to 
            sent['subject'] = subject 
 
    user_manager = UserManager(email_service=TestEmailService()) 
    user_manager.register_user("test@example.com") 
 
    assert sent['to'] == "test@example.com" 
    assert "Welcome" in sent['subject']

No need to patch anything. No side effects. Just pure Python and simple injection.


Final Thoughts

You don’t need FastAPI, Flask, or any fancy library to do Dependency Injection in Python. You just need:

A little discipline
Clear constructor/function parameters
A central place to wire your dependencies

When used well, this lightweight form of DI gives you:

Modular architecture
Clean separation of concerns
Stress-free testing

So if you’re writing Python and avoiding frameworks, you can still embrace powerful software architecture patterns — and keep your codebase maintainable and professional.


Want to Go Deeper?

  • Try integrating this pattern in a CLI tool or background job.
  • Use Protocol and mypy for interface validation.
  • Organize your wiring in a container.py file if your app grows.

Thanks for reading!
If you found this helpful, give it a clap and share it with your fellow Pythonistas.

Follow me for more articles on writing clean, modern Python — without the overhead.

Photo by Mohammad Rahmani on Unsplash