Services, Controllers and Repositories. An Introduction
In today’s software development landscape, building scalable and maintainable APIs is crucial, especially as we move towards microservices architecture. Separating the responsibility of functions and establishing clear boundaries of responsibility in our code significantly speeds up development and allows us to maintain our services or extend them, more easily. One of the keys to achieving this is through the use of well-established design patterns, since those patterns provide us with thoroughly tested techniques that promote the creation of scalable, adaptive and easy-to-understand software. In this post, we will talk about services, controllers, and repositories (with a bonus to an introduction to the Unit of Work design pattern as well). We will also explain how those patterns interact inside a Web Service to promote a clear cycle of function calls that are isolated in terms of responsibility, and complement each other in terms of achieving the end goal: delivering value to a given domain.
Controllers: The Outer Layer
Controllers serve as the outer layer of your API architecture. They act as the bridge between HTTP/REST APIs and the domain or business layer. Essentially, controllers handle HTTP requests, managing headers, parameters, and routing. They direct incoming requests to the appropriate operations within the domain layer. Controllers should remain simple and only focus on request handling and routing, although it is common to see, for very simple applications where the business layer is just a collection of CRUD operations, the union of the service layer and the controller layer in one single function (this is NOT a good practice, especially if we know the service will become more complex in the future, or if we want to add unit tests, which is something we ALWAYS want to do in any code base).
Services: The Business Logic
The service layer is where the business logic of the application resides. It processes the core functionality and rules of the business domain. While in simple applications, some might place business logic directly in controllers, this practice is not recommended. Separating business logic into services ensures better organization, reusability, and maintainability of code. In a more detailed description: the service layer functions are called inside the controller layer, and only the response of the service layer is returned back to the controller, which may expose the response (or a modified version of it), to the user. For instance, an HTTP request to POST a new order for buying a product may create a new order request in the system. The creation of this new order is done in the controller function, by calling a function located in the service layer, and it generates an ID (number or a UUID, which generally matches the ID of the order created in the database layer). The controller only calls the service layer function to create the order, and the result returned by the service function can be the UUID of the generated order placement. All the abstraction of how that order is created is inside the service layer. The UUID is then returned as the response in the controller layer, which can return the order number (ID) back to the user as the response of the POST request.
Repositories: Data Persistence Layer
The data / persistence layer acts as an intermediary between your application’s models and the database. This layer is responsible for creating, updating, and deleting data (or doing any other operations that involve talking to the database somehow). The repository pattern is a popular design choice for implementing this layer, as it decouples the persistence logic from the service layer, promoting a cleaner architecture by means of coding to interfaces, instead of specific low-level detail implementations. Repositories also promote code reusability, since a given atomic database operation (like the creation of a record), may be needed in multiple service functions. In this case, using the repository pattern reduces the duplicated code by ensuring that those simple persistence operations are always done the same way, and by means of the repository interface.
Another advantage of using the repository pattern is that, by exposing an interface used by the service layer to perform data persistence, it isolates HOW we persist data and what database solution we use. In other words, changes in the database layer (like changing from a PostgreSQL to Oracle or even doing a SQL to NoSQL transition) do not require changes in the service layer code. We only modify the internals of the methods inside the interface exposed by the repository, and the service layer will keep using the same interface with the same I/O schemas.
Unit of Work: Ensuring consistency and atomicity
To maintain data consistency and ensure ACID (Atomicity, Consistency, Isolation, Durability) transactions, the Unit of Work (UoW) pattern is often employed alongside the repository pattern. The UoW pattern manages a sequence of database operations, ensuring that either all operations are completed successfully or none are. This prevents partial updates, which can lead to data inconsistency. In Python, UoW is generally implemented using context managers (which are implemented using the magic methods __enter__ and __exit__).
Combining all the patterns in a Web Service
Although the sections above explain, on a high level, why we need each of the described layers for a given service, they do not tell us how the layers interact in practice.
- A straightforward conclusion is that the controllers are the “initial point of access” to our exposed service. They receive the requests made by clients and users, to interact with the service.
- The next step is the access to the service layer, which is done inside the controller functions. For instance, to create a new user in a platform, we must submit a POST request to the /users endpoint. Inside that corresponding POST /users controller function, we will call a function named something like create_user (if you are using Python, that’s probably the name of the function, with lower case and underscores)
- After reaching the service layer, we must persist the new user data in the database layer. The service layer then uses the repository to do that. A UserRepository interface would provide methods like add, delete, get, list, and we would be able to access those methods inside the service layer
- After we make sure that the user was created (and any other objects that would be needed along with the new user, like putting a picture of the user in a storage solution like AWS S3, for instance), we would then commit the transaction using the Unit of Work pattern.
Conclusion
In this post, we discussed the concepts of the fours layers that are usually implemented in Web Services to promote scalability, consistency, code cycling and service / business-centric development. In a forthcoming post, we will apply those design patterns in practice to build a nice back-end API in Python. Stay tuned for next posts!
References
Microservice APIs in Python: Using Python, Flask, Fastapi, Openapi and More. José Haro Peralta. 2023.