Building Robust Python Projects: Mastering Dependency Injection, Protocols, and Abstract Base Classes
Explore the use of dependency injection, protocols, and abstract base classes in Python to enhance code maintainability, testability, and overall quality.
4 août 2024
Published
Hugo Mufraggi
Author

Mastering Dependency Injection, Protocols, And Abstract Base Classes
In the last article, we set up the repository and the CI/CD pipeline to maintain good code quality. We will continue advancing in this quest and creating an SQL repository for our API.
We will use:
- SQLModel: A library created by the primary maintainer of FastAPI.
- SQLite: For its simplicity and ease of use in this tutorial.
Before we start, I’ll introduce three code patterns and practices you can use in almost all languages and environments (frontend/backend). These patterns will help you improve the maintainability of your code, facilitate testing, and reduce the risk of creating a spaghetti code base. After that, we’ll see how to implement them in practice.
Patterns and Practices
Be confident with the definitions. We’ll go through them step by step in practice.
Dependency Injection
In software engineering, a dependency is a code linked to a feature. To illustrate this concept, let’s consider a project where we create an API with a database for managing tasks.
In our project, all functions related to the database and tasks are grouped into components. We represent this component using a Python class. This organization leads us to the Inversion of Control (IoC) concept.
Inversion of Control is a principle where the control of certain parts of code is inverted. Rather than creating dependencies, a component receives them from an external source. This is where dependency injection comes into play.
Dependency injection is a technique for implementing IoC. With this approach, dependencies are “injected” into a component from the outside rather than created within the component itself.
By adopting these practices, you achieve several benefits:
- Code segmentation: Your code is divided into precise, distinct components.
- Improved maintainability: Changes to one component are less likely to affect others.
- Reduced cognitive load: The codebase becomes easier to understand and reason about.
- Enhanced testability: Components can be tested in isolation by injecting mock dependencies.
- Increased flexibility: Dependencies can be easily swapped or modified without changing the component’s code.
Protocol
Introduced in Python 3.8, the Protocol class is a feature of the typing module that enables structural subtyping. A Protocol defines a set of methods or attributes a class should implement, similar to an interface in other languages. However, unlike traditional inheritance, a class doesn’t need to explicitly inherit from a Protocol to be considered compatible with it. If a class implements all the methods defined in a Protocol, it’s considered a match. Protocols are beneficial for static type checking. While they don’t enforce behavior at runtime, they allow tools like MyPy to verify type consistency before the code runs. MyPy is a popular static type checker for Python that can use Protocols to ensure that objects conform to expected interfaces.
Abstract Base Classes (ABC)
Introduced in Python 2.6, the Abstract Base Classes (ABC) module is a feature of the ABC module that provides a way to define abstract classes. An Abstract Base Class can define a set of methods that must be created within any subclass derived from the abstract class. This is similar to the concept of interfaces in other languages.
An Abstract Base Class serves as a blueprint for other classes. It cannot be instantiated on its own. Instead, other classes inherit from it and implement its abstract methods. If a subclass fails to implement these methods, an error is raised, ensuring that the subclass conforms to the expected interface.
ABCs help define and enforce consistent interfaces within an object-oriented design. They allow for creating a clear contract for subclasses, ensuring that specific methods are always present. This is beneficial for both static type checking and runtime enforcement.
When to Use Them
Dependency Injection: This pattern is universally applicable. It’s a robust pattern that forces you to split your code, making testing more accessible.
Protocol and ABC: The use of these depends on your QA technical standards. At a minimum, define some abstract base classes. In an ideal scenario, the combination of ABC and Protocol definitions is potent and enhances the quality assurance of your code. I’ll show you in the following sections how you can use both to drastically improve consistency in your project.
Practice, Practice, Practice
Goal
Create a repository with SQLModel to interact with a SQLite database.
The Game Plan
- Create a class representing the Task
- Create a generic ABC for implementing CRUD operations once and reusing them in other repositories.
- Create the Task repository
- Create the Protocol
Class Task
First, I’ll define a branded type for my Task. My previous article provides more information about branded types and their benefits.
TaskId = NewType('TaskId', uuid.UUID)
class Task(SQLModel, table=True):
id: TaskId = Field(sa_column=Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4))
title: str
description: Optional[str] = None
is_completed: bool = False
Python makes the difference between TaskId and uuid.UUID
def test(t: TaskId):
print(t)
test(uuid.uuid4())
#Python trigger the error of type
And if you use mypy, it will catch the typing error to.
src/schemas/task.py:21: error: Argument 1 to "test" has incompatible type "UUID"; expected "TaskId" [arg-type]
Found 1 error in 1 file (checked 6 source files)
Abstract Base Class
Now, let’s create a generic abstract base class to define CRUD operations once for each repository.
from sqlalchemy import Row, RowMapping
from sqlmodel import SQLModel, Session, select
from typing import TypeVar, Generic, Type, Any, Sequence
from abc import ABC, abstractmethod
from uuid import UUID
T = TypeVar('T', bound=SQLModel)
ID = TypeVar('ID')
class AbstractCRUD(ABC, Generic[T, ID]):
@abstractmethod
def model(self) -> Type[T]:
pass
@abstractmethod
def get_session(self) -> Session:
pass
def create(self, obj: T) -> T:
session = self.getSession()
session.add(obj)
session.commit()
session.refresh(obj)
return obj
def get_by_id(self, id: ID) -> T | None:
session = self.getSession()
return session.get(self.model(), id)
def update(self, obj: T) -> T:
session = self.getSession()
session.add(obj)
session.commit()
session.refresh(obj)
return obj
def delete(self, id: ID) -> None:
session = self.getSession()
obj = session.get(self.model(), id)
if obj:
session.delete(obj)
session.commit()
def list(self, skip: int = 0, limit: int = 100) -> list[T]:
session = self.getSession()
statement = select(self.model()).offset(skip).limit(limit)
results = session.exec(statement)
return list(results)
I define two generic types:
Tfor SQLModel types like TaskIDfor branded types like TaskId
After declaring two abstract methods, any class implementing AbstractCRUD must provide concrete implementations for the model and get_session methods.
Following these abstract method declarations, I define the logic for each CRUD function.
Task Repository
To implement the AbstractCRUD in the TaskRepository, we use the following class definition:
class TaskRepository(AbstractCRUD[Task, TaskId]):
Here, TaskRepository inherits from AbstractCRUD and we specify the concrete types for the generic parameters:
TbecomesTaskIDbecomesTaskId
This setup allows the TaskRepository to inherit all the CRUD operations defined in AbstractCRUD, while ensuring type safety for the specific Task and TaskId types.
class TaskRepository(AbstractCRUD[Task, TaskId]):
def __init__(self, session: Session):
self.__session = session
def get_session(self) -> Session:
return self.__session
def model(self) -> Type[Task]:
return Task
def list_tasks_id(self) -> list[TaskId]:
session = self.get_session()
statement = select(self.model().id)
results = session.exec(statement)
return list(results)
I added a dummy function, list_tasks_id, to show you that you are not limited by the abstract base class (ABC) definition and can still utilize the Protocol.
After initializing your task_repository, you can access all the functions of the ABC and your class.
Protocol
Why define a protocol? A protocol is the gateway to the dependency inversion concept, which forces you and your team to create a contract for your class. This helps in testing systems, ensuring your class’s behavior and functions adhere to expected standards.
The definition is very straightforward and similar to an abstract method definition.
class ITaskRepository(Protocol):
def get_session(self) -> Session:
pass
def model(self) -> Type[Task]:
pass
def list_tasks_id(self) -> list[TaskId]:
pass
def create(self, obj: Task) -> Task:
pass
def get_by_id(self, id: TaskId) -> Task | None:
pass
def update(self, obj: Task) -> Task:
pass
def delete(self, id: TaskId) -> None:
pass
def list(self, skip: int = 0, limit: int = 100) -> list[Task]:
pass
Additionally, I recommend using this pattern by wrapping the class definition inside a function instead of declaring it directly.
def create_task_repository(session: Session) -> ITaskRepository:
return TaskRepository(session)
# Old way
repo = TaskRepository(session)
# New way
repo = create_task_repository(session)
With this code pattern, you ensure that your TaskRepository implements the ITaskRepository interface.
Conclusion
That’s all for now. In this article, we explored the differences between abstract base classes (ABCs) and protocols, how to implement them, and how to use them.
In the following article, we will use the repository with dependency injection and show how to test your code quickly.
Thank you for reading! I hope this helps you in your work. To see my future posts, you can follow me on Medium.