Why I Started Using Dependency Injection in Python
Learn how Dependency Injection (DI) in Python helps improve code structure, testing, and flexibility, using real-world examples and the dependency-injector library.
Join the DZone community and get the full member experience.
Join For FreeWhen I first started building real-world projects in Python, I was excited just to get things working. I had classes calling other classes, services spun up inside constructors, and everything somehow held together.
But deep down, I knew something was off.
Why?
My code felt clunky. Testing was a nightmare. Changing one part broke three others. Adding a small feature would make me change 10 different files in the package. I couldn't quite explain why - until a seasoned software developer reviewed my code and asked, "Have you heard of dependency injection?"
I hadn't.
So I went down the rabbit hole. And what I found changed the way I think about structuring code. In this post, I'll walk you through what dependency injection is, how it works in Python, and why you might want to start using a library like dependency-injector. We'll use real-world examples, simple language, and practical code to understand the concept.
If you're early in your dev journey or just looking to clean up messy code, this one's for you.
So, What the Heck Is Dependency Injection?
Let's break it down.
Dependency injection (DI) is just a fancy term for "give me what I need, don't make me create it myself."
Instead of a class or function creating its own dependencies (like other classes, database, clients, etc), you inject those dependencies from the outside. This makes it easier to swap them out, especially in tests, and promotes loose coupling.
A Quick Real-World Analogy
Imagine you're running a coffee shop. Every time someone orders coffee, you don't go build a coffee machine from scratch. You already have one, and you pass it to the barista.
That's DI. The barista (your class) doesn't create the coffee machine (the dependency to serve coffee); it receives it from outside.
Without DI: A Painful Example
class DataProcessor:
def __init__(self):
# DataProcessor creates its own database connection
self.db = MySQLDatabase(host="localhost", user="root", password="secret")
def process_data(self):
data = self.db.query("SELECT * from data")
# Process the data...
return processed_data
See the problem?
This works, but here's the problem. Our DataProcessor
is tightly coupled to a specific MySQL database implementation.
- Want to switch databases? You'll have to modify this class.
- Want to test it with a mock database? Good luck with that.
With DI: Clean and Flexible
Now, here's the same code with dependency injection.
class DataProcessor:
def __init__(self, database):
# Database is injected from outside
self.db = database
def process_data(self):
data = self.db.query("SELECT * from data")
# Process the data ...
return processed_data
# Creating and injecting dependencies
db = MySQLDatabase(host="localhost", user="root", password="secret")
processor = DataProcessor(db)
That's it! The processor now takes its database dependency as a parameter instead of creating it internally. This simple change makes a world of difference.
Using a DI Library in Python
The simplest form of DI in Python is just passing dependencies through constructors (as we saw above). This is called constructor injection and works great for simple cases. But in larger applications, manually writing dependencies gets messy.
That's where the dependency-injector library shines. It gives you containers, providers, and a nice way to manage your dependencies.
First, let's install the package:
pip install dependency-injector
Now, let's see it in action in the real world. Imagine we're building a data engineering pipeline that:
- Extracts data from different sources.
- Transforms it.
- Loads it into a data warehouse.
Here's how we'd structure it with DI:
from dependency-injector import containers, providers
from database import PostgresDatabase, MongoDatabase
from services import DataExtractor, DataTransformer, DataLoader
class Container(containers.DeclarativeContainer):
config = providers.Configuration()
# Database dependencies
postgres_db = providers.Singleton(
PostgresDatabase,
host=config.postgres.host,
username=config.postgres.username,
password=config.postgres.password
)
mongo_db = providers.Singleton(
MongoDatabase,
connection_string=config.mongo.connection_string
)
# Service dependencies
extractor = providers.Factory(
DataExtractor,
source_db=mongo_db
)
transformer = providers.Factory(
DataTransformer
)
loader = providers.Factory(
DataLoader,
target_db=postgres_db
)
# Main application
etl_pipeline = providers.Factory(
ETLpipeline,
extractor=extractor,
transfomer=transformer,
loader=loader
)
Let's break down what's happening here:
Containers
A container is like a registry of your application's dependencies. It knows how to create each component and what dependencies each component needs.
Providers
Providers tell the container how to create objects. There are different types:
- Factory: Creates a new instance each time (good for most services)
- Singleton: Creates only one instance (good for connections, for example)
- Configuration: Provides configuration values
- And many more... (check out the complete list following the link above)
Using the Container
# Load configuration from a file
container = Container()
container.config.from_yaml('config.yaml')
# Create the pipeline with all dependencies resolved automatically
pipeline = container.etl_pipeline()
pipeline.run()
The magic here is that our ETLPipeline
class doesn't need to know how to create extractors, transformers, or loaders. It just uses what it's given:
class ETLPipeline:
def __init__(self, extractor, transformer, loader):
self.extractor = extractor
self.transformer = transformer
self.loader = loader
def run(self):
data = self.extractor.extract()
transformed_data = self.transformer.transform(data)
self.loader.load(transformed_data)
Wrapping Up
Dependency injection might sound like something only big enterprise apps need. But if you've ever tried to write tests or refactor your code and wanted to scream, DI can save your sanity.
The dependency-injector library in Python gives you an elegant way to structure your code, keep things loosely coupled, and actually enjoy testing.
Final Thoughts
Next time you're writing a class that needs "stuff" to work, pause and ask: Should I be injecting this instead?
It's a simple shift in mindset, but it leads to cleaner code, better tests, and happier developers (you included).
If you found this helpful, give it a clap or share it with a teammate who's knee-deep in spaghetti code. And if you want more articles like this — real talk, real examples — follow me here on DZone.
Let's write better Python, one clean service at a time.
Opinions expressed by DZone contributors are their own.
Comments