Introduction
A Python backend often starts with a few routes and a database connection. As the application grows, route handlers accumulate validation, business rules, database operations, and external API calls.
The result is code that becomes difficult to test and risky to change. A clear layered architecture helps separate responsibilities without introducing unnecessary abstraction.
1. Give each layer a clear responsibility
A practical backend can use four main layers:
- API layer: Handles HTTP requests, validates input, and returns responses.
- Service layer: Implements business rules and coordinates operations.
- Data-access layer: Encapsulates database queries and persistence.
- Domain layer: Represents business concepts and rules where the application needs them.
The goal is not to create a class for every function. Each layer should exist because it separates a meaningful responsibility.
A possible project structure is:
app/
api/
routes/
orders.py
schemas/
orders.py
services/
orders.py
repositories/
orders.py
models/
orders.py
main.py
tests/
test_order_service.py
test_order_routes.py
The exact structure can vary by framework and application size.
2. Keep HTTP handling separate from business logic
Consider an endpoint that creates an order. The route should handle HTTP concerns and delegate the actual operation:
from fastapi import APIRouter, Dependsfrom pydantic import BaseModelrouter = APIRouter()class CreateOrderRequest(BaseModel): product_id: int quantity: int@router.post("/orders")def create_order( request: CreateOrderRequest, service=Depends(get_order_service),): return service.create_order( product_id=request.product_id, quantity=request.quantity, )
This example assumes get_order_service is defined elsewhere. The service should validate business rules such as whether the product can be ordered and whether the requested quantity is permitted.
HTTP-specific concerns—status codes, authentication context, and response formatting—should remain at the API boundary where practical.
3. Put business rules in the service layer
A service coordinates the steps required to complete an operation. For example, creating an order may require checking inventory, calculating a total, and saving the order.
class OrderService: def __init__(self, repository): self.repository = repository def create_order(self, product_id: int, quantity: int): if quantity <= 0: raise ValueError("Quantity must be positive") product = self.repository.get_product(product_id) if product is None: raise LookupError("Product not found") if product.stock < quantity: raise ValueError("Insufficient stock") return self.repository.create_order( product=product, quantity=quantity, )
This is a simplified example. In a real system, inventory checks and order creation may need a database transaction or locking strategy to prevent concurrent requests from overselling stock.
4. Avoid abstractions that do not help
A repository layer is useful when it centralizes complicated queries, isolates persistence details, or makes tests easier to write. It is less useful when it simply duplicates every ORM method without adding meaningful separation.
Likewise, dependency injection can simplify testing and lifecycle management, but excessive indirection makes it harder to follow a request through the codebase.
Prefer the smallest architecture that clearly separates the application’s responsibilities.
5. Test at multiple levels
Test business rules independently from HTTP handling. Service tests can cover invalid quantities, missing products, and insufficient inventory.
Route tests should verify request validation, response codes, authentication behavior, and error mapping. Integration tests should confirm that database operations behave correctly with the actual persistence layer.
Use fakes for isolated service tests, but retain integration tests for behavior that mocks cannot validate, such as transactions and query semantics.
Conclusion
A maintainable Python backend is not defined by how many folders it contains. It is defined by how clearly responsibilities are separated and how safely individual parts can change.
Start with a thin API layer, focused business services, and data-access abstractions where they provide value. Add complexity only when the application’s needs justify it.