23c36a2b39210a45dd7a65052574d2aedcb68827
FastAPI/FastAPI - SOLID Principles and Design Patterns.md
| ... | ... | @@ -0,0 +1,300 @@ |
| 1 | +https://medium.com/@lautisuarez081/fastapi-best-practices-and-design-patterns-building-quality-python-apis-31774ff3c28a |
|
| 2 | + |
|
| 3 | +# FastAPI: SOLID Principles and Design Patterns | Medium |
|
| 4 | +In recent years, FastAPI has emerged as one of the most popular frameworks for building Python APIs thanks to its simplicity, speed and support for static typing. However, to get the most out of this powerful tool, it is essential to follow some good practices and design patterns that allow us to write clean, maintainable and scalable code. |
|
| 5 | + |
|
| 6 | +In this article, I will show you how to apply some of the SOLID principles and design patterns such as DAO (Data Access Object), Service Layer, and Dependency Injection to build robust and efficient APIs with FastAPI. |
|
| 7 | + |
|
| 8 | +SOLID Principles Applied to FastAPI |
|
| 9 | +----------------------------------- |
|
| 10 | + |
|
| 11 | +**1\. Single Responsibility Principle (SRP)** |
|
| 12 | +--------------------------------------------- |
|
| 13 | + |
|
| 14 | +> Each module or class should have responsibility for only one part of the functionality provided by the software, and this responsibility should be encapsulated in its entirety by the class. |
|
| 15 | + |
|
| 16 | +What does this mean? For example, in a FastAPI application, a routing function (endpoint) should focus on receiving a request, delegating business logic to a specific service, and returning a response. Let’s see this in code: |
|
| 17 | + |
|
| 18 | +**Code without SRP:** |
|
| 19 | + |
|
| 20 | +```python |
|
| 21 | +from fastapi import APIRouter |
|
| 22 | +from app.models.user import UserCreate, UserRead |
|
| 23 | +from app.db import database |
|
| 24 | + |
|
| 25 | +router = APIRouter() |
|
| 26 | + |
|
| 27 | +@router.post("/users", response_model=UserRead) |
|
| 28 | +async def create_user(user: UserCreate): |
|
| 29 | + # Data validation |
|
| 30 | + if not user.email or not user.password: |
|
| 31 | + raise ValueError("Email and password are required.") |
|
| 32 | + |
|
| 33 | + # Check if the user already exists |
|
| 34 | + existing_user = database.fetch_one("SELECT * FROM users WHERE email = :email", {"email": user.email}) |
|
| 35 | + if existing_user: |
|
| 36 | + raise ValueError("User already exists.") |
|
| 37 | + |
|
| 38 | + # Create a new user in the database |
|
| 39 | + new_user_id = database.execute("INSERT INTO users (email, password) VALUES (:email, :password)", { |
|
| 40 | + "email": user.email, |
|
| 41 | + "password": user.password |
|
| 42 | + }) |
|
| 43 | + |
|
| 44 | + # Get new user details |
|
| 45 | + new_user = database.fetch_one("SELECT * FROM users WHERE id = :id", {"id": new_user_id}) |
|
| 46 | + |
|
| 47 | + return new_user |
|
| 48 | +``` |
|
| 49 | + |
|
| 50 | + |
|
| 51 | +In this example, the create user endpoint **does multiple tasks**: |
|
| 52 | + |
|
| 53 | +\- Input data validation (validate email and password). |
|
| 54 | +\- Verify if the user already exists in the database. |
|
| 55 | +\- Create a new user in the database. |
|
| 56 | +\- Retrieve and return the details of the new user. |
|
| 57 | + |
|
| 58 | +This mix of responsibilities makes the code more difficult to maintain and scalable. Any change in business logic or how data is accessed will require modifications to this same block of code, increasing the chances of errors. |
|
| 59 | + |
|
| 60 | +**Code with SRP:** |
|
| 61 | + |
|
| 62 | +```python |
|
| 63 | +# ------- REPOSITORY FILE ------- |
|
| 64 | +from app.models.user import UserCreate, UserDB |
|
| 65 | +from app.db import database |
|
| 66 | + |
|
| 67 | +class UserRepository: |
|
| 68 | + def __init__(self, db_session): |
|
| 69 | + self.db_session = db_session |
|
| 70 | + |
|
| 71 | + async def get_user_by_email(self, email: str) -> UserDB: |
|
| 72 | + query = "SELECT * FROM users WHERE email = :email" |
|
| 73 | + return await self.db_session.fetch_one(query, {"email": email}) |
|
| 74 | + |
|
| 75 | + async def add_user(self, user_data: UserCreate) -> int: |
|
| 76 | + query = "INSERT INTO users (email, password) VALUES (:email, :password) RETURNING id" |
|
| 77 | + values = {"email": user_data.email, "password": user_data.password} |
|
| 78 | + new_user_id = await self.db_session.execute(query, values) |
|
| 79 | + return new_user_id |
|
| 80 | + |
|
| 81 | + async def get_user_by_id(self, user_id: int) -> UserDB: |
|
| 82 | + query = "SELECT * FROM users WHERE id = :id" |
|
| 83 | + return await self.db_session.fetch_one(query, {"id": user_id}) |
|
| 84 | + |
|
| 85 | + |
|
| 86 | +# ------- SERVICE FILE ------- |
|
| 87 | +from app.models.user import UserCreate, UserRead |
|
| 88 | +from app.repositories.user_repository import UserRepository |
|
| 89 | + |
|
| 90 | +class UserService: |
|
| 91 | + def __init__(self, user_repository: UserRepository): |
|
| 92 | + self.user_repository = user_repository |
|
| 93 | + |
|
| 94 | + async def validate_user_data(self, user_data: UserCreate) -> None: |
|
| 95 | + if not user_data.email or not user_data.password: |
|
| 96 | + raise ValueError("Email and password are required.") |
|
| 97 | + |
|
| 98 | + async def check_user_exists(self, email: str) -> None: |
|
| 99 | + existing_user = await self.user_repository.get_user_by_email(email) |
|
| 100 | + if existing_user: |
|
| 101 | + raise ValueError("User already exists.") |
|
| 102 | + |
|
| 103 | + async def create_user(self, user_data: UserCreate) -> UserRead: |
|
| 104 | + # Business Logic Validation |
|
| 105 | + await self.validate_user_data(user_data) |
|
| 106 | + await self.check_user_exists(user_data.email) |
|
| 107 | + new_user_id = await self.user_repository.add_user(user_data) |
|
| 108 | + return await self.user_repository.get_user_by_id(new_user_id) |
|
| 109 | + |
|
| 110 | + |
|
| 111 | +# ------- USER ROUTER FILE ------- |
|
| 112 | +from fastapi import APIRouter, Depends |
|
| 113 | +from app.models.user import UserCreate, UserRead |
|
| 114 | +from app.services.user_service import UserService |
|
| 115 | +from app.routers.dependencies import get_user_service |
|
| 116 | + |
|
| 117 | +router = APIRouter() |
|
| 118 | + |
|
| 119 | +@router.post("/users", response_model=UserRead) |
|
| 120 | +async def create_user(user: UserCreate, user_service: UserService = Depends(get_user_service)): |
|
| 121 | + return await user_service.create_user(user) |
|
| 122 | +``` |
|
| 123 | + |
|
| 124 | + |
|
| 125 | +Now, in this example, you can clearly see the unique responsibility of each module. |
|
| 126 | + |
|
| 127 | +* **User Repository:** handles all database related operations, such as getting or inserting users. Any change in the database structure or the way data is handled will be done here. |
|
| 128 | +* **User Service:** Contains the business logic related to the users, such as validations, business rules, etc. It is the intermediary between the repository and the FastAPI routes. |
|
| 129 | +* **User Router:** The function of the endpoint is very simple: receive an HTTP request, delegate the logic to the corresponding service, and return the response. It does not care about the internal details of how the data or business logic is handled. |
|
| 130 | + |
|
| 131 | +By applying SRP in your FastAPI application, you not only make your code cleaner and easier to maintain, but also to establish a **solid foundation for the future expansion** of its application. |
|
| 132 | + |
|
| 133 | +2\. **Dependency Inversion Principle (DIP)** |
|
| 134 | +-------------------------------------------- |
|
| 135 | + |
|
| 136 | +This principle sets out 2 rules: |
|
| 137 | + |
|
| 138 | +> 1\. High-level modules should not depend on low-level modules. Both must depend on abstractions. |
|
| 139 | +> |
|
| 140 | +> 2\. Abstractions should not depend on details. Details must depend on abstractions. |
|
| 141 | + |
|
| 142 | +**Returning to the previous example:** The UserService class depends directly on the concrete implementation of UserRepository. This is a DIP violation because UserService (high-level module) depends on UserRepository (low-level module). If tomorrow you decide to change the way UserRepository handles data persistence (for example, migrating from SQL to NoSQL), you would also have to modify UserService. |
|
| 143 | + |
|
| 144 | +To apply the DIP correctly, we must introduce an abstraction (an interface or base class) that defines the contract for the operations that UserService needs to interact with the user repository. In this way, UserService will depend on the abstraction and not on the concrete implementation. |
|
| 145 | + |
|
| 146 | +We will call this class IUserRepository, referring to the fact that it is an interface: |
|
| 147 | + |
|
| 148 | +``` |
|
| 149 | +from abc import ABC, abstractmethod |
|
| 150 | +from app.models.user import UserCreate, UserRead |
|
| 151 | +class IUserRepository(ABC): |
|
| 152 | + @abstractmethod |
|
| 153 | + async def get_user_by_email(self, email: str) -> UserRead: |
|
| 154 | + pass |
|
| 155 | + @abstractmethod |
|
| 156 | + async def add_user(self, user_data: UserCreate) -> int: |
|
| 157 | + pass |
|
| 158 | + @abstractmethod |
|
| 159 | + async def get_user_by_id(self, user_id: int) -> UserRead: |
|
| 160 | + pass |
|
| 161 | +``` |
|
| 162 | + |
|
| 163 | + |
|
| 164 | +This interface defines the methods that any concrete implementation of a user repository must have. This ensures that UserService can work with any repository that implements this interface, independent of how it handles data. |
|
| 165 | + |
|
| 166 | +Now we must make UserRepository implement this interface: |
|
| 167 | + |
|
| 168 | +``` |
|
| 169 | +from app.models.user import UserCreate, UserRead |
|
| 170 | +from app.db import database |
|
| 171 | +from app.repositories.user_repository_interface import IUserRepository |
|
| 172 | +class UserRepository(IUserRepository): |
|
| 173 | + def __init__(self, db_session): |
|
| 174 | + self.db_session = db_session |
|
| 175 | + async def get_user_by_email(self, email: str) -> UserRead: |
|
| 176 | + query = "SELECT * FROM users WHERE email = :email" |
|
| 177 | + return await self.db_session.fetch_one(query, {"email": email}) |
|
| 178 | + async def add_user(self, user_data: UserCreate) -> int: |
|
| 179 | + query = "INSERT INTO users (email, password) VALUES (:email, :password) RETURNING id" |
|
| 180 | + values = {"email": user_data.email, "password": user_data.password} |
|
| 181 | + new_user_id = await self.db_session.execute(query, values) |
|
| 182 | + return new_user_id |
|
| 183 | + async def get_user_by_id(self, user_id: int) -> UserRead: |
|
| 184 | + query = "SELECT * FROM users WHERE id = :id" |
|
| 185 | + return await self.db_session.fetch_one(query, {"id": user_id}) |
|
| 186 | +``` |
|
| 187 | + |
|
| 188 | + |
|
| 189 | +Implementing the interface ensures that it provides all the necessary functionalities that UserService might need. |
|
| 190 | + |
|
| 191 | +Finally, we have to make our UserService class depend on the interface and not on the concrete class: |
|
| 192 | + |
|
| 193 | +``` |
|
| 194 | +from app.models.user import UserCreate, UserRead |
|
| 195 | +from app.repositories.user_repository_interface import IUserRepository |
|
| 196 | +class UserService: |
|
| 197 | + def __init__(self, user_repository: IUserRepository): |
|
| 198 | + self.user_repository = user_repository |
|
| 199 | + async def validate_user_data(self, user_data: UserCreate) -> None: |
|
| 200 | + if not user_data.email or not user_data.password: |
|
| 201 | + raise ValueError("Email and password are required.") |
|
| 202 | + async def check_user_exists(self, email: str) -> None: |
|
| 203 | + existing_user = await self.user_repository.get_user_by_email(email) |
|
| 204 | + if existing_user: |
|
| 205 | + raise ValueError("User already exists.") |
|
| 206 | + async def create_user(self, user_data: UserCreate) -> UserRead: |
|
| 207 | + await self.validate_user_data(user_data) |
|
| 208 | + await self.check_user_exists(user_data.email) |
|
| 209 | + new_user_id = await self.user_repository.add_user(user_data) |
|
| 210 | + new_user = await self.user_repository.get_user_by_id(new_user_id) |
|
| 211 | + return new_user |
|
| 212 | +``` |
|
| 213 | + |
|
| 214 | + |
|
| 215 | +Applying the Dependency Inversion Principle (DIP) improves your application architecture by decoupling business logic from implementation details. In this example, UserService becomes **more flexible, maintainable and easier to test** by relying on the IUserRepository interface instead of a concrete implementation suc**Story**h as UserRepository. |
|
| 216 | + |
|
| 217 | +Applied design patterns |
|
| 218 | +----------------------- |
|
| 219 | + |
|
| 220 | +In the examples above, in addition to using SOLID principles, we are using some design patterns. Did you manage to identify which ones? |
|
| 221 | + |
|
| 222 | +**The DAO pattern and the Service Layer**, now I will explain them in detail: |
|
| 223 | + |
|
| 224 | +**1\. Data Access Object (DAO)** |
|
| 225 | +-------------------------------- |
|
| 226 | + |
|
| 227 | +The DAO Pattern is a design pattern used to separate the data access logic from the business logic of the application. Its purpose is to provide an abstraction for CRUD (Create, Read, Update, Delete) operations that are performed on a database or other data source. |
|
| 228 | + |
|
| 229 | +In the example we are working on we can **identify the UserRepository class**. This class will be responsible for all interactions with the database related to the User entity. |
|
| 230 | + |
|
| 231 | +``` |
|
| 232 | +from app.models.user import UserCreate, UserRead |
|
| 233 | +from app.db import database |
|
| 234 | +from app.repositories.user_repository_interface import IUserRepository |
|
| 235 | +class UserRepository(IUserRepository): |
|
| 236 | + def __init__(self, db_session): |
|
| 237 | + self.db_session = db_session |
|
| 238 | + async def get_user_by_email(self, email: str) -> UserRead: |
|
| 239 | + query = "SELECT * FROM users WHERE email = :email" |
|
| 240 | + return await self.db_session.fetch_one(query, {"email": email}) |
|
| 241 | + async def add_user(self, user_data: UserCreate) -> int: |
|
| 242 | + query = "INSERT INTO users (email, password) VALUES (:email, :password) RETURNING id" |
|
| 243 | + values = {"email": user_data.email, "password": user_data.password} |
|
| 244 | + new_user_id = await self.db_session.execute(query, values) |
|
| 245 | + return new_user_id |
|
| 246 | + async def get_user_by_id(self, user_id: int) -> UserRead: |
|
| 247 | + query = "SELECT * FROM users WHERE id = :id" |
|
| 248 | + return await self.db_session.fetch_one(query, {"id": user_id}) |
|
| 249 | +``` |
|
| 250 | + |
|
| 251 | + |
|
| 252 | +Why should we use this pattern? |
|
| 253 | + |
|
| 254 | +* **Data Access Encapsulation:** The DAO provides a dedicated layer to manage all data access operations. This means that any change in the persistence logic (e.g., switching from SQL to NoSQL) is performed only in the DAO layer, without affecting the rest of the application. |
|
| 255 | +* **Reusability:** The DAO implementation can be reused by different services or components that need to interact with data from the same entity, eliminating code duplication. |
|
| 256 | +* **Easy Testing:** By separating data access in its own layer, it is easy to create mocks or stubs for unit testing, allowing the business logic to be tested in isolation without depending on the actual database. |
|
| 257 | +* **Maintainability:** Data access operations are centralized in a DAO class, making it easy to locate and correct persistence-related errors. |
|
| 258 | + |
|
| 259 | +2\. **Service Layer** |
|
| 260 | +--------------------- |
|
| 261 | + |
|
| 262 | +It is responsible for organizing the business logic of the application. Its purpose is to provide an interface to the presentation layer or controllers (e.g. FastAPI controllers) that encapsulates all relevant business logic. |
|
| 263 | + |
|
| 264 | +By separating the business logic into a service layer, a more modular, more maintainable and testable code is achieved. In addition, it facilitates the reuse of business logic in different application contexts. |
|
| 265 | + |
|
| 266 | +In the example, we **use this pattern in the UserService class**. This class is responsible for the business logic related to users. This business logic may include validations, business rules, data transformation, among others. It will use the DAO to perform data access operations, but it will not directly manage the database. |
|
| 267 | + |
|
| 268 | +``` |
|
| 269 | +from app.models.user import UserCreate, UserRead |
|
| 270 | +from app.repositories.user_repository_interface import IUserRepository |
|
| 271 | +class UserService: |
|
| 272 | + def __init__(self, user_repository: IUserRepository): |
|
| 273 | + self.user_repository = user_repository |
|
| 274 | + async def validate_user_data(self, user_data: UserCreate) -> None: |
|
| 275 | + if not user_data.email or not user_data.password: |
|
| 276 | + raise ValueError("Email and password are required.") |
|
| 277 | + async def check_user_exists(self, email: str) -> None: |
|
| 278 | + existing_user = await self.user_repository.get_user_by_email(email) |
|
| 279 | + if existing_user: |
|
| 280 | + raise ValueError("User already exists.") |
|
| 281 | + async def create_user(self, user_data: UserCreate) -> UserRead: |
|
| 282 | + await self.validate_user_data(user_data) |
|
| 283 | + await self.check_user_exists(user_data.email) |
|
| 284 | + new_user_id = await self.user_repository.add_user(user_data) |
|
| 285 | + new_user = await self.user_repository.get_user_by_id(new_user_id) |
|
| 286 | + return new_user |
|
| 287 | +``` |
|
| 288 | + |
|
| 289 | + |
|
| 290 | +Why should we use this pattern? |
|
| 291 | + |
|
| 292 | +* **Separation of Business Logic:** Provides a centralized place for all business logic, eliminating duplicate logic in different parts of the application and keeping it separate from the data access logic. |
|
| 293 | +* **Easy Unit Test:** By encapsulating the business logic in a separate service layer, it is easier to perform unit tests on that business logic without having to worry about the details of the database. This allows creating different Mockups to test different scenarios. |
|
| 294 | +* **Code Decoupling:** Presentation layers (such as controllers in FastAPI) do not depend directly on the business logic or data access layer. This allows changes to the business logic without affecting the user interface or vice versa. |
|
| 295 | +* **Flexibility and Extensibility:** Acts as a central point where additional changes, rules or validations can be applied to the business logic without affecting the rest of the application. |
|
| 296 | + |
|
| 297 | +Conclusion |
|
| 298 | +---------- |
|
| 299 | + |
|
| 300 | +By following these design principles and patterns, you will be able to build more robust, flexible and maintainable APIs using FastAPI. By applying principles such as SOLID and using patterns such as DAO or Service Layer you will not only improve the quality of your code, but also its ability to adapt to changes and grow over time. |
|
| ... | ... | \ No newline at end of file |