AW Dev Rethought

βœ‚οΈ Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away - Antoine de Saint-ExupΓ©ry

βš™οΈ Python Backend Patterns – πŸ”Œ Dependency Injection 🐍


Description:

In backend applications, classes often depend on other components to perform their operations. A service might require a repository to access data, while a controller might depend on a service to execute business logic.

A common approach is to create these dependencies directly inside the classes that use them. Although this works, it creates tight coupling, making components harder to replace, reuse, and test independently.

The Dependency Injection Pattern solves this by providing dependencies from outside rather than constructing them internally. Instead of a service deciding which repository to create, the repository is passed into the service.

Conceptually, the application becomes:

    Controller
        ↓
      Service
        ↓
    Repository
        ↓
    Data Source

The difference is how these components are connected: dependencies are created externally and injected into the components that need them.


Understanding Tight Coupling

Consider a service that creates its own repository:

class UserService:
    def __init__(self):
        self.repository = InMemoryUserRepository()

Here, UserService is directly responsible for creating its dependency. If we later want to use a database-backed repository or a fake implementation for testing, the service is tied to the original implementation. Dependency Injection changes this relationship:

class UserService:
    def __init__(self, repository):
        self.repository = repository

Now, the service receives its dependency from outside. It no longer needs to know how the repository was created.


Defining a Repository Contract

To make dependencies interchangeable, we'll define a repository contract using Python's Protocol.

class UserRepository(Protocol):
    def add(self, user: User) -> None:
        ...

    def get_by_id(self, user_id: int) -> Optional[User]:
        ...

This describes the operations that a repository must support. Any compatible implementation can be passed into the service without requiring inheritance from a particular base class. For example, our InMemoryUserRepository stores users in a dictionary, but a database-backed repository could implement the same operations.


Injecting the Repository into the Service

The UserService receives a repository through its constructor.

class UserService:
    def __init__(self, repository: UserRepository):
        self.repository = repository

The service uses the repository to perform operations without depending on its concrete implementation.

For example:

if self.repository.get_by_id(user_id):
    raise ValueError(f"User {user_id} already exists")

Whether the repository uses a dictionary, database, or another storage mechanism, the service continues to work through the same contract. This is known as constructor injection, one of the most common forms of Dependency Injection.


Injecting the Service into the Controller

Dependency Injection can also be applied between controllers and services.

class UserController:
    def __init__(self, service: UserService):
        self.service = service

Instead of creating a UserService internally, the controller receives an existing service instance. We configure these dependencies when initialising the application:

repository = InMemoryUserRepository()
service = UserService(repository)
controller = UserController(service)

This setup is often called the composition root, where application components are created and connected. The controller handles requests, the service manages business logic, and the repository handles data access.


Replacing Dependencies for Testing

One of the biggest advantages of Dependency Injection is the ability to substitute dependencies during testing. For example, we can create a fake repository:

class FakeUserRepository:
    def __init__(self):
        self._users = {}

    def add(self, user):
        self._users[user.id] = user

    def get_by_id(self, user_id):
        return self._users.get(user_id)

We can then inject it into the existing service:

fake_repository = FakeUserRepository()
test_service = UserService(fake_repository)

The service doesn't need to be modified. We can now test its business logic without connecting to an actual database or affecting application data. This is particularly useful for unit testing and isolating application components.


Why Use Dependency Injection?

Dependency Injection helps backend applications become more modular by separating components from the responsibility of creating their dependencies.

It provides several benefits:

  • Loose Coupling: Components depend on contracts rather than concrete implementations.
  • Testability: Dependencies can be replaced with fakes or mocks during testing.
  • Flexibility: Different implementations can be injected without modifying business logic.
  • Maintainability: Application components have clearer responsibilities.
  • Reusability: Services can operate in different environments using compatible dependencies.

Dependency Injection doesn't require a framework or specialized library. For many Python applications, constructor injection is sufficient.


Key Takeaways

  • Dependency Injection provides dependencies from outside a class rather than creating them internally.
  • Constructor injection is a simple and effective way to implement the pattern.
  • Python's Protocol allows components to depend on shared contracts without requiring explicit inheritance.
  • Repositories and services can be replaced independently when compatible implementations are available.
  • Dependency Injection makes backend code easier to test, maintain, and extend.

Code Snippet:

"""
Programmer: python_scripts (Abhijith Warrier)

PYTHON BACKEND PATTERNS β€” DEPENDENCY INJECTION

This script demonstrates Dependency Injection by passing repositories
and services into application components instead of creating them internally.
"""

# Import dataclass for defining the data model
from dataclasses import dataclass

# Import Protocol for defining repository contracts
from typing import Protocol, Optional


# -----------------------------------------
# Step 1: Define the application data model
# -----------------------------------------
@dataclass
class User:
    id: int
    name: str
    email: str


# -----------------------------------------
# Step 2: Define the repository contract
# -----------------------------------------
class UserRepository(Protocol):
    """Defines the operations required by the service."""

    def add(self, user: User) -> None:
        """Store a user."""
        ...

    def get_by_id(self, user_id: int) -> Optional[User]:
        """Retrieve a user by ID."""
        ...


# -----------------------------------------
# Step 3: Create the repository implementation
# -----------------------------------------
class InMemoryUserRepository:
    """Stores users in an in-memory dictionary."""

    def __init__(self):
        # Use a dictionary as the data source
        self._users = {}

    def add(self, user: User) -> None:
        """Store a new user."""
        self._users[user.id] = user

    def get_by_id(self, user_id: int) -> Optional[User]:
        """Retrieve a user by ID."""
        return self._users.get(user_id)


# -----------------------------------------
# Step 4: Create the service layer
# -----------------------------------------
class UserService:
    """Handles user-related business operations."""

    def __init__(self, repository: UserRepository):
        # Receive the repository instead of creating it
        self.repository = repository

    def register_user(
        self,
        user_id: int,
        name: str,
        email: str,
    ) -> User:
        """Register a user after validation."""

        # Prevent duplicate user IDs
        if self.repository.get_by_id(user_id):
            raise ValueError(f"User {user_id} already exists")

        # Validate user information
        if not name.strip() or not email.strip():
            raise ValueError("Name and email cannot be empty")

        user = User(
            id=user_id,
            name=name,
            email=email,
        )

        self.repository.add(user)

        return user

    def find_user(self, user_id: int) -> Optional[User]:
        """Find an existing user."""
        return self.repository.get_by_id(user_id)


# -----------------------------------------
# Step 5: Create the controller
# -----------------------------------------
class UserController:
    """Handles requests by delegating operations to the service."""

    def __init__(self, service: UserService):
        # Receive the service instead of creating it
        self.service = service

    def create_user(
        self,
        user_id: int,
        name: str,
        email: str,
    ) -> dict:
        """Handle a user registration request."""

        try:
            user = self.service.register_user(
                user_id,
                name,
                email,
            )

            return {
                "success": True,
                "message": "User registered successfully",
                "user_id": user.id,
            }

        except ValueError as error:
            return {
                "success": False,
                "message": str(error),
            }

    def get_user(self, user_id: int) -> dict:
        """Handle a user retrieval request."""

        user = self.service.find_user(user_id)

        if not user:
            return {
                "success": False,
                "message": "User not found",
            }

        return {
            "success": True,
            "user": {
                "id": user.id,
                "name": user.name,
                "email": user.email,
            },
        }


# -----------------------------------------
# Step 6: Configure application dependencies
# -----------------------------------------
# Create dependencies outside the application classes
repository = InMemoryUserRepository()

# Inject the repository into the service
service = UserService(repository)

# Inject the service into the controller
controller = UserController(service)


# -----------------------------------------
# Step 7: Execute application operations
# -----------------------------------------
print("Registering Users:")

print(controller.create_user(1, "Alice", "alice@example.com"))
print(controller.create_user(2, "Bob", "bob@example.com"))

# Attempt to register an existing user
print(controller.create_user(1, "Charlie", "charlie@example.com"))

# Retrieve an existing user
print("\nRetrieving User:")
print(controller.get_user(2))


# -----------------------------------------
# Step 8: Create a fake repository for testing
# -----------------------------------------
class FakeUserRepository:
    """Provides a lightweight repository for testing."""

    def __init__(self):
        # Store test users independently
        self._users = {}

    def add(self, user: User) -> None:
        """Store a test user."""
        self._users[user.id] = user

    def get_by_id(self, user_id: int) -> Optional[User]:
        """Retrieve a test user."""
        return self._users.get(user_id)


# -----------------------------------------
# Step 9: Test with injected dependencies
# -----------------------------------------
# Replace the repository without changing UserService
fake_repository = FakeUserRepository()
test_service = UserService(fake_repository)

# Register a user through the test service
test_user = test_service.register_user(
    101,
    "Test User",
    "test@example.com",
)

# Verify that the fake repository received the user
assert fake_repository.get_by_id(101) == test_user

# Verify that duplicate IDs are rejected
try:
    test_service.register_user(
        101,
        "Duplicate User",
        "duplicate@example.com",
    )

    raise AssertionError("Duplicate registration was not rejected")

except ValueError:
    pass

print("\nDependency Injection Test Passed!")

Link copied!

Comments

Add Your Comment

Comment Added!