Dependency Injection and the Repository-Service Pattern
What is Dependency Injection?
Dependency injection is a programming technique used to decouple a class or a function from their dependencies by passing them as arguments.
Consider the following example:
1
2
3
4
5
6
7
8
9
10
11
class UserManager:
def __init__(self):
self.data = {}
def create_user(self, username):
user_data = {
"username": username,
"password": random_password,
"created_at": datetime.now()
}
self.data[username] = user_data
In this example, the UserManager class creates a data dictionary and stores it in the data
attribute. The create_user
method then uses the data
attribute to create a user. This locks the UserManager class into a stateful approach and makes it difficult to change the implementation of the UserManager class in the future, especially while creating unit tests.
So, how do we improve on this using dependency injection?
The Repository-Service Pattern
The repository-service pattern is a design pattern that allows for the separation of the data access layer from the business logic layer.
The repository-service pattern commonly consists of the following main components:
- A data model
- A repository interface
- A repository implementation
- A service to interact with the repository
Let’s take a look at how we would prepare this code for the repository-service pattern.
Data Model
The data model consists of a class that represents the data the repository will be storing in the format that the project requires. The data model is a very simple concept that should keep logic to a minimum. Possible logic could be data validation, data sanitization, etc.
Note: You can use
TypedDict
,dataclasses
orpydantic
to aid in the creation of these classes.pydantic
will provide the validation and sanitization logic for you 😀.
1
2
3
4
5
6
7
8
class User:
def __init__(self, username, password, created_at):
assert isinstance(username, str)
assert isinstance(password, str)
self.username = username
self.password = password
self.created_at = created_at
Repository Interface
The repository interface is a contract that the repository implementation must adhere to. It defines the methods that the repository implementation must implement. The addition of this layer of abstraction makes it easy to (inter)change the implementation of the repository.
The repository interface should only provide basic CRUD operations. We will get to additional functionality later.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from abc import ABC, abstractmethod
class BaseUserRepository(ABC):
@abstractmethod
def get_one(self, username):
...
@abstractmethod
def get_many(self, usernames):
...
@abstractmethod
def create(self, username, password):
...
@abstractmethod
def update(self, username, password):
...
@abstractmethod
def delete(self, username):
...
Repository Implementation
The repository implementation inherits from the interface and implements the methods defined in the interface. Its main responsibility is to abstract the database layer away from the rest of the application. The repository will return and receive plain data.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class UserRepository(BaseUserRepository):
def __init__(self):
self.data: dict[str, dict] = {}
def get_one(self, username):
return self.data.get(username)
def get_many(self, usernames):
return [self.data[username] for username in usernames if username in self.data]
def create(self, username, password):
self.data[username] = {
"username": username,
"password": password,
"created_at": datetime.now()
}
def update(self, username, password):
self.data[username]["password"] = password
def delete(self, username):
del self.data[username]
Service
The service is the layer that interacts with the repository and provides models to the application. Any logic that is not part of the repository should be handled by the service layer.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class UserService:
def __init__(self, repository: BaseUserRepository):
self.repository = repository
def get_one(self, username):
data = self.repository.get_one(username)
return User(**data)
def get_many(self, usernames):
data = self.repository.get_many(usernames)
return [User(**user) for user in data]
def create(self, username, password):
self.repository.create(username, password)
def update(self, username, password):
self.repository.update(username, password)
def delete(self, username):
self.repository.delete(username)
def exists(self, username):
return get_one(username) is not None
This layer is where dependency injection comes into play.
In this example, the user repository uses a stateful approach by storing the data in a dictionary. In a real application, the user repository would be interacting with a database of sorts.
This makes it difficult to unit test the repository as we normally would need to mock the database. However, because of dependency injection, we can easily change the implementation of the user repository inside the tests and test the service layer without acquiring the database.
Rules
Now, you might be excited to use this pattern in everything you write. However, there are some rules to follow to make sure shit doesn’t hit the fan. In principle, this means that you may only interact one layer deep.
- The application may only interact with the service layer.
- The service layer may only interact with the repository layer.
- The service layer may interact with multiple repositories.
- A service is allowed to call its own methods, but not other services.
- The repository layer may only interact with the data layer.
---
title: Basically, only the following is allowed
---
flowchart
0 <--> 1
1 <--> 2
2 <--> 3
subgraph 0["Data"]
end
subgraph 1["Repository"]
end
subgraph 2["Service"]
end
subgraph 3["Application"]
end
Using our example
In our example, we are injecting the repository into the service via its constructor.
1
2
3
4
5
repository = UserRepository()
service = UserService(repository)
# Create a user
service.create("username", "password")
Conclusion
Dependency injection is a very powerful technique that modern programmers should be familiar with. The repository-service pattern is a great use case for it, and I hope that this post has made you want to try this out for yourself, unless you are already using it.
I have also implemented dependency injection in my osu! API library, which implements a base repository for storing OAuth tokens. If you wish to see that as well, you may check it out here.