Introduction
Building a FastAPI application that scales? It's not just about async endpoints and Pydantic models. In this post, I'll share how to structure FastAPI applications for long-term maintainability using layered architecture and dependency injection. You'll learn how to keep your code organized, write testable code, and build a flexible system that can evolve with your needs.
Understanding Layered Architecture
Layered architecture (also known as n-tier architecture) is a pattern that organizes code into distinct layers, each with a specific responsibility. This separation of concerns makes the codebase more maintainable, testable, and flexible. Think of it like a well-organized kitchen in a restaurant: each station has its specific role, and the flow of work moves in one direction, from preparation to plating.
Layer 1: Presentation Layer
The presentation layer is your application's front door. It's where FastAPI lives, along with other entry points like Celery tasks and Typer commands. This layer is responsible for handling all external communication, whether it's HTTP requests, background jobs, or CLI commands.
Here's a typical example of how this layer looks in practice:
@router.post("/foos")
async def create_foo(
foo: FooCreateDTO,
foo_service: FooService = Depends(get_foo_service)
) -> FooResponseDTO:
return await foo_service.create_foo(foo)
Key characteristics:
Minimal code focusing on input/output mapping: The layer acts as a translator between external formats (HTTP, Celery, CLI) and your service layer's standardized format, handling both request transformation and response formatting.
Handles service injection through FastAPI's dependency injection system: The layer leverages FastAPI's built-in dependency injection to provide services with their required dependencies, making the code more testable and maintainable.
Consistent pattern across all entry points: Whether you're handling an HTTP request, a background job, or a CLI command, the same pattern is followed, making the code predictable and easier to maintain.
Layer 2: Service Layer (Business)
This is where the magic happens. The service layer is the heart of your application, containing all business logic and transaction management. It's like the kitchen in our restaurant analogy - where the actual cooking (business logic) takes place.
Let's look at how we structure this layer:
class FooServiceInterface(ABC):
@abstractmethod
async def create_foo(self, foo: FooCreateDTO) -> FooResponseDTO:
...
class StandardFooService(FooServiceInterface):
def __init__(self, foo_dao: FooDAOInterface, uow: UnitOfWorkInterface):
self.foo_dao = foo_dao
self.uow = uow
async def create_foo(self, foo: FooCreateDTO) -> FooResponseDTO:
async with self.uow:
return await self.foo_dao.create(foo)
Key features:
Interface-based design that allows for multiple implementations: By defining clear interfaces, we can easily swap implementations without affecting the rest of the system.
Transaction management through the Unit of Work pattern: The Unit of Work pattern ensures that all database operations within a transaction either succeed together or fail together.
Centralized business logic that's easy to find and modify: All business rules and operations are contained within the service layer, making them easy to locate and update.
Self-documenting code through clear method names and responsibilities: The service layer's methods and classes are named to clearly indicate their purpose and responsibilities.
The service layer is where you'll spend most of your time implementing business rules. It's also where you'll handle complex operations that might involve multiple data access operations, ensuring they all succeed or fail together.
Layer 3: Persistence Layer (DAOs)
The persistence layer is your application's memory. It handles all database interactions through Data Access Objects (DAOs), which are like specialized waiters in our restaurant analogy - they know exactly how to interact with the storage system.
Here's how we implement this layer:
class FooDAOInterface(ABC):
@abstractmethod
async def create(self, foo: FooDTO) -> FooDTO:
...
class SQLAlchemyFooDAO(FooDAOInterface):
def __init__(self, session: AsyncSession):
self.session = session
async def create(self, foo: FooDTO) -> FooDTO:
db_foo = Foo(**foo.model_dump())
self.session.add(db_foo)
await self.session.flush()
return FooDTO.model_validate(db_foo)
Key aspects:
Interface-based DAO design: Each DAO implements a clear interface that defines the contract for data access operations.
Transaction management at service level: The DAO layer never handles commits, leaving transaction management to the service layer for better control.
Flexible implementation swapping: The interface-based design allows easy switching between different ORMs or data storage solutions without affecting the service layer.
DTOs: The Glue Between Layers
DTOs (Data Transfer Objects) are the messengers between your layers. They're like the standardized order forms in our restaurant - they ensure everyone speaks the same language.
Here's how we define our DTOs:
from pydantic import BaseModel
from decimal import Decimal
from typing import List
class FooItemDTO(BaseModel):
name: str
quantity: int
class FooDTO(BaseModel):
id: str
name: str
items: List[FooItemDTO]
total: Decimal
Characteristics:
Immutable data transfer: DTOs are implemented as frozen Pydantic models, ensuring data consistency as they flow between layers.
Validation-free by design: DTOs typically don't include validation logic, except when used as FastAPI input models where we can add API-specific validation methods.
Type-safe layer communication: DTOs ensure type safety as data moves between layers, making the code more maintainable and catching errors early.
JSON serialization ready: DTOs are designed to be easily serializable to and from JSON, making them perfect for API responses and external communication.
Dependency Injection with Dependency Service
Dependency injection is like having a well-organized kitchen where each chef knows exactly what tools they need. Our DependencyService is the kitchen manager that ensures everyone has what they need.
Here's how we implement it:
class DependencyService:
@staticmethod
def get_foo_service(
db: AsyncSession = Depends(get_db),
) -> FooServiceInterface:
uow = SQLAUnitOfWork(db)
return FooService(
foo_dao=FooDAO(uow.db),
foo_client=FooClient(),
uow=uow,
)
Benefits:
Transparent service assembly: The dependency service clearly shows what each service needs, making it easy to understand and modify service configurations.
Explicit dependency management: All dependencies are clearly declared and injected, eliminating hidden requirements and making the code more predictable.
Extensible architecture: The centralized dependency service makes it easy to add cross-cutting concerns like caching, feature flags, or logging without modifying business logic.
Centralized configuration: All dependency management happens in one place, making it easier to maintain and modify the application's structure.
Testing Strategy
Testing is crucial in any architecture, and our layered approach makes it easier to test each component in isolation. We follow the classic testing pyramid:
System Tests
System tests verify that your entire application works together. They're like having a food critic visit your restaurant:
class TestFooAPI:
@pytest.mark.anyio
async def test_get_foo(
self,
async_client: AsyncClient,
) -> None:
# given
payload = { bar: "bar" }
response = await async_client.post("v1/foo/", json=payload)
foo_id = response.json()["id"]
# when
response = await async_client.get(f"v1/foo/{foo_id}")
# then
assert response.status_code == 200
assert response.json() == {
"id": str(job.id),
"bar": "bar",
}
Integration Tests
Integration tests verify that your layers work together correctly. They're like testing each station in the kitchen:
class TestSQLAFooDAO:
@pytest.mark.anyio
async def test_create(
self,
test_session: AsyncSession,
) -> None:
# Given
dao = SQLAlchemyFooDAO(test_session)
foo_dto = FooDTO(bar="bar")
# When
result = await dao.create(foo_dto)
# Then
assert result.bar == "bar"
assert result.created_at is not null
Unit Tests
Unit tests verify individual components in isolation. They're like testing each chef's skills:
@pytest.mark.anyio
class TestFooService:
async def test_foo_happy(self) -> None:
# given
foo_dao = AsyncMock(spec=FooDAOInterface)
foo_client = AsyncMock(spec=FooClientInterface)
foo_service = FooService(
foo_dao=foo_dao,
foo_client=foo_client
)
foo_id = "foo_id"
foo_dao.get_one.return_value = FooDTO(bar="bar", id="foo_id")
foo_client.get.return_value = FooDTO(bar="bar", id="foo_id")
# when
response = await foo_service.get_one(foo_id=foo_id)
# then
assert response == FooDTO(bar="bar", id="foo_id")
foo_dao.get_one.assert_called_once_with(foo_id)
foo_client.get.assert_called_once_with(foo_id)
Trade-offs and Challenges
While layered architecture offers many benefits, it's important to understand the trade-offs and challenges you might face when implementing this pattern. These challenges shouldn't deter you, but rather help you make an informed decision about whether this architecture is right for your project.
Challenges
Initial development overhead: The layered architecture requires more boilerplate code upfront, but this investment pays off in maintainability. However, with proper Cursor or GitHub Copilot rules, you can significantly reduce this overhead as these AI tools can generate code that follows your established patterns.
Learning curve for new developers: Team members need time to understand the architecture patterns and dependency injection concepts. However, if your company uses this pattern consistently across projects, developers can quickly become productive as they move between projects, as the patterns remain familiar.
Potential over-engineering: For very small applications, this architecture might introduce unnecessary complexity and overhead. For example, if you're just exposing an ML model through a simple API endpoint, the full layered architecture would be overkill as there's minimal business logic to manage.
Benefits
Despite these challenges, the benefits of layered architecture often outweigh the initial investment. Let's explore the key advantages that make this architecture pattern valuable for many projects.
Enhanced maintainability: The clear separation of concerns and consistent patterns make the codebase easier to understand and modify.
Comprehensive testability: Each layer can be tested in isolation, making it easier to write and maintain tests.
Implementation flexibility: The interface-based design allows easy swapping of implementations without affecting other parts of the system. This is particularly useful for early validation, as you can implement null DAOs to have a fully functional end-to-end application without persistence, allowing you to validate business logic before committing to a specific database solution.
Consistent development patterns: The architecture enforces consistent patterns across the codebase, making it easier for teams to collaborate.
When to Use This Architecture
Choosing the right architecture for your project is crucial. While layered architecture is powerful, it's not a one-size-fits-all solution. Here are the scenarios where this architecture pattern truly shines and can provide the most value to your project.
Domain-focused applications: When your application has a clear, well-defined domain with complex business rules that need to be managed effectively. For multi-domain applications or large monoliths, other architectures that provide better inter-domain interfaces (like hexagonal architecture) should be preferred.
Growing projects: When you anticipate the application will grow over time and need a structure that can scale with it.
Complex business logic: When your application requires clear separation of concerns to manage intricate business rules and processes.
Multiple entry points: When your application needs to handle different types of requests (API, CLI, background jobs) while maintaining consistent business logic.
Long-term maintenance: When you need a codebase that will be maintained and extended over a long period.
Quality-focused development: When you need a structure that supports comprehensive testing and quality assurance.
Conclusion
While this architecture might seem like overkill for small projects, it pays dividends as your application grows. The initial investment in structure and patterns leads to a more maintainable, testable, and flexible codebase. The consistent patterns make it easier for new team members to onboard, and the clear separation of concerns makes it easier to modify and extend the system.
You can find all the code examples in our GitHub repository.