32.2. Designing the Architecture
Table of Contents
Clarifying the Goal
In this chapter you design the architecture for your final production-ready backend project.
You are not building endpoints yet. You are deciding how the system will be structured, which parts it will have, how they talk to each other, and which responsibilities each part gets.
The focus is on:
- Identifying major components (services, databases, background workers, etc.).
- Choosing an architectural style.
- Defining clear boundaries and responsibilities.
- Planning how cross-cutting concerns like logging, security, and configuration will fit in.
All later chapters in the final project will plug into this architecture.
A good architecture is about clear boundaries, simple communication, and explicit responsibilities, not about using as many fancy patterns and tools as possible.
Requirements Summary
Before you design the architecture you should restate the main requirements of the final project in technical terms. The exact project idea can vary, but a realistic production backend usually needs:
- A public REST API, consumed by a web or mobile frontend.
- Authentication and authorization.
- A relational database (PostgreSQL) for persistent data.
- Caching (Redis) for performance and some features (sessions, rate limits, etc.).
- Background workers for slow or long-running tasks.
- File storage (for images or documents).
- Observability (logging, metrics, health checks).
- CI/CD, Docker, and deployment to a server.
From these you derive system-level requirements:
| Area | Requirements example |
|---|---|
| API | JSON REST API, versioned, secure over HTTPS |
| Data | Strong consistency, transactions, relational schema, migrations |
| Performance | Fast responses for common reads, pagination, caching, connection pooling |
| Scalability | Ability to run multiple app instances, stateless API nodes |
| Security | JWT-based auth, HTTPS, secure password storage, secure secrets |
| Reliability | Graceful shutdown, health checks, background job retries, backups |
| Operability | Structured logs, metrics, alerts, environment-based config |
You will design an architecture that satisfies these requirements without overcomplicating it.
Choosing an Architectural Style
You have many options, but for a single backend application built by one person or a small team, you typically choose between:
- A simple monolith
- A modular monolith
- Full microservices
For this final project, microservices are overkill. They introduce network overhead, distributed transactions, complex deployment, and a lot of operational burden.
A modular monolith is usually the best choice.
Modular Monolith Overview
A modular monolith is:
- One deployable application.
- Internally split into modules with clear boundaries.
- Modules communicate via function calls, not over the network.
- Shared infrastructure like the database and Redis is typically used, but each module owns its part of the domain.
Example high-level modules:
usersauthproductsorderspaymentsfilesnotifications
These are logical boundaries, not separate microservices.
Use one deployable app with multiple well-defined modules. Avoid splitting into microservices unless you have strong reasons and experience to manage the added complexity.
You will also follow a layered architecture inside the monolith.
High-Level System Architecture
At the highest level, your production backend system will usually have:
- Clients
- Web frontend (React / Vue / etc.), mobile apps, or external API clients.
- Reverse proxy / load balancer
- Nginx or Traefik in front of the app.
- Terminates TLS, routes requests to app containers.
- Application server
- FastAPI app, run by Uvicorn + Gunicorn.
- Stateless HTTP API.
- Database
- PostgreSQL instance for relational data.
- Cache and message broker
- Redis for:
- Caching reads.
- Storing short-lived keys (tokens, rate limits, sessions).
- Acting as Celery broker / result backend.
- Background workers
- Celery workers (or similar) running the same codebase but executing background tasks.
- File storage
- Local filesystem for development.
- Cloud object storage (S3 or compatible) for production.
- Observability
- Logging system (to stdout, then collected by Docker / server).
- Metrics endpoint for Prometheus.
- Health check endpoints for load balancers.
You can visualize a simple request path:
Client β HTTPS β Nginx β FastAPI (app server) β PostgreSQL / Redis β Response
And a background task path:
FastAPI β push job to Redis (Celery) β Worker consumes job β DB / email / file storage
Logical Layers Inside the Application
Inside the FastAPI app you will structure code into layers. A common simple decomposition:
- API layer (FastAPI routes)
- Service / domain layer
- Repository / persistence layer
- Infrastructure / integration layer
API Layer
Responsibilities:
- Define HTTP endpoints and routes.
- Parse and validate requests (Pydantic models).
- Handle authentication / authorization for endpoints.
- Map service-layer outputs to HTTP responses.
The API layer does not:
- Talk to the database directly.
- Contain business rules.
Example structure:
app/
api/
v1/
users.py
auth.py
products.py
orders.pyEach file defines a FastAPI router for that domain.
Service Layer
Responsibilities:
- Implement business use cases.
- Orchestrate repositories, background tasks, and validations.
- Encapsulate core business logic.
Examples of service methods:
register_usercreate_orderadd_item_to_cartprocess_payment
API endpoints call service functions or classes. Services call repositories and other infrastructure.
Repository Layer
Responsibilities:
- Handle persistence for aggregates and entities.
- Use SQLAlchemy (or similar) to interact with PostgreSQL.
- Hide SQL or ORM details from services.
Example:
UserRepository.get_by_emailOrderRepository.create_with_items
The service layer should not know about SQL, just that it can save, find, or update domain objects.
Infrastructure / Integration Layer
Responsibilities:
- External services:
- Email provider.
- Payment gateway.
- Message queues.
- File storage.
- Technical utilities:
- Caching wrapper.
- Token generation.
- Password hashing.
- Logging helpers.
This layer is where you integrate third-party libraries, like:
boto3for S3.- Payment SDKs.
- Email SDKs.
Domain Modules and Boundaries
Within the layered structure you will have domain modules. Each module implements its own:
- Pydantic schemas (request / response models).
- SQLAlchemy models (database tables).
- Services.
- Repositories.
- Routers.
Example Module Breakdown
Imagine an e-commerce-like final project. A reasonable separation could be:
| Module | Responsibilities |
|---|---|
users | User profiles, basic data, account management |
auth | Registration, login, logout, tokens, password reset, email verification |
products | Product catalog, categories, pricing |
orders | Carts, orders, order items, order lifecycle |
payments | Payment initiation, status tracking, webhook processing |
files | File uploads, image resizing, links, storage paths |
notifications | Email notifications, background sending, templates |
admin | Admin-only operations, dashboards, data exports |
Each module:
- Owns its routes under a prefix, for example
/api/v1/users,/api/v1/orders. - Owns its part of the database schema.
- Exposes services that may be used by other modules.
Keep data ownership within modules. For example, only the orders module should be responsible for changing an order status. Other modules should call its services instead of directly manipulating its tables.
Example Cross-Module Communication
authneeds user data- It calls
usersrepository or service to get user records. ordersneeds product info to calculate totals- It uses a
ProductServiceinterface rather than querying the product table directly from random places. paymentsneeds order total- It calls
OrderServiceto get final amount and status.
In a modular monolith, these are function calls in the same codebase, not network requests.
Choosing the Data Flow Patterns
You will mostly use request / response for API endpoints and command-style service methods for actions.
Example flow for creating an order:
- Client calls
POST /api/v1/orderswith cart items. - API layer:
- Validates input.
- Gets current user from auth dependency.
- Calls
OrderService.create_order(user, items). OrderService:- Validates products exist using
ProductRepository. - Calculates totals, taxes, discounts.
- Creates order and order items using
OrderRepository. - Returns order DTO.
- API layer:
- Maps order DTO to response model.
- Returns
201 Createdwith order data.
For long operations, like sending emails or processing payments:
- The service layer schedules background tasks via Celery.
- Workers perform the heavy work asynchronously.
Planning for Asynchronous vs Synchronous Parts
Since you use FastAPI, you will have async capabilities. But not everything must be async.
General rules:
- Async for:
- HTTP endpoints.
- Database calls via async driver.
- External IO calls (HTTP to email provider, payment gateway, etc.).
- Background workers may stay sync if using normal Celery with sync libraries.
Structure:
- Main FastAPI app:
- Majority of endpoints are declared as
async def. - Background workers:
- Run tasks defined in same codebase, triggered via Redis.
Design decision:
- Keep core business logic independent of async when possible.
- For example,
OrderService.create_ordermay be a regular function that accepts dependencies. - The API layer uses
awaitto fetch data from repositories and then calls pure functions for business rules.
Database and Data Access Design
At architecture level you decide:
- You use PostgreSQL as your main relational database.
- You use SQLAlchemy as ORM.
- You use Alembic for migrations.
- You use connection pooling and short-lived sessions per request.
Database Integration Pattern
You can follow a typical pattern:
- One
SessionLocalfactory from SQLAlchemy. - Dependency-injected session per request in FastAPI.
- Repositories receive a session object.
Example concept:
def get_db_session():
db = SessionLocal()
try:
yield db
finally:
db.close()
Each repository method expects db: Session.
This is an example of the repository pattern, used to decouple higher layers from the ORM.
Module Ownership Over Tables
Decide table ownership:
usersmodule:userstable.- maybe
user_profiles. authmodule:refresh_tokens,email_verifications,password_resets.productsmodule:products,categories.ordersmodule:orders,order_items, maybecarts.paymentsmodule:payments,payment_attempts.filesmodule:files,file_links.
Keep foreign keys clear and avoid circular dependencies where possible.
Caching and Redis Usage Design
You need a plan for how you will use Redis, not just "we will cache things".
Typical usages:
- Application caching
- Cache common read-heavy data, such as product lists or category trees.
- Key plan example:
products:list:page:{page}:{filters_hash}product:{product_id}- Authentication-related data
- Optional session store for session-based auth.
- Blacklist or "revoked" token lists if needed.
- Rate limiting
- Keys like
rate:user:{user_id}:{window_start}. - Background processing
- Redis as Celery broker and result backend.
General caching rules:
Design cache keys with:
- A clear prefix for the feature (for example
products:,users:). - No sensitive data in the key or value.
- Expiration (TTL) to prevent stale data from living forever.
You will not implement all caching logic here, but your architecture should consider:
- A small caching service wrapper in the infrastructure layer.
- Policies on what can be cached and cache invalidation points (for example, when a product is updated or deleted).
Background Processing Design
You must plan which tasks will be executed in the background and how they are connected.
Typical background jobs:
- Send emails (welcome, reset password, order confirmation).
- Process large or slow operations:
- Generating PDFs or reports.
- Image resizing or thumbnail generation.
- Handling third-party webhooks processing.
- Cleaning up expired data.
Architecture:
- Celery workers using same codebase.
- You keep a
tasksmodule where tasks are defined: notifications.tasks.send_emailorders.tasks.recalculate_order_summariesfiles.tasks.generate_thumbnail
Communication:
- The service layer schedules a task:
send_welcome_email.delay(user_id)- Workers take jobs from Redis and run them, interacting with the same database and services.
You also plan:
- Retry strategies (automatic Celery retries for transient failures).
- Idempotency for tasks that may run more than once.
- Logging inside tasks for debugging.
File Storage Architecture
You must decide how to handle files such as images or documents.
Design decisions:
- Abstraction:
- Define a
FileStorageinterface / service with methods: save_file,delete_file,get_url.- Implementation for:
- Local storage (development).
- S3-compatible storage (production).
- Where paths are stored:
- Store only relative paths or object keys in the database.
- Example:
files/avatars/user_123.png,orders/invoices/abc123.pdf. - Access control:
- Some files may be public (for example product images).
- Some files may be private (for example invoices, user documents).
- For private files, you may use presigned URLs, not direct public URLs.
By abstracting file storage behind a service, you can:
- Switch implementations without changing business logic.
- Keep tests simple by using an in-memory or local filesystem adapter.
Configuration and Environment Separation
Your architecture must support different environments:
- Development
- Testing
- Staging
- Production
Configuration design:
- Environment variables are primary source of configuration.
- Use a settings class (for example Pydantic
BaseSettings). - Separate settings by domain:
- App config (port, debug, allowed origins).
- Database config (URL, pool size).
- Redis config (URL).
- Security config (JWT secret, algorithm, token lifetimes).
- Email config (SMTP, API keys).
- Storage config (S3 bucket, region).
Configuration is injected, not hard-coded, so you can:
- Run tests with in-memory or local resources.
- Deploy multiple environments with different credentials.
Cross-Cutting Concerns
Some parts of your architecture cross module boundaries. You must plan how to handle them centrally.
Logging
Design:
- Use structured logging with JSON or key-value pairs.
- Attach correlation / trace IDs per request, so logs from one request can be grouped.
- Use FastAPI middleware to log:
- Request method, path, status code, latency.
- Ensure logs go to stdout so Docker and your server can collect them.
Metrics and Health Checks
Design:
- A
/healthor/healthzendpoint for load balancers. - Possibly
/metricsfor Prometheus. - Consider metrics such as:
- Request rate per endpoint.
- Error rate.
- Task queue lengths.
Security
Many security features are part of other chapters but architecturally you must:
- Use JWT for stateless auth between client and API.
- Protect sensitive endpoints with authorization checks in services or dependencies.
- Centralize:
- Password hashing utilities.
- Token creation / verification.
- Security headers configuration.
- CORS configuration.
Define clear boundaries:
authmodule is responsible for:- Generating and validating tokens.
- Authentication workflows.
- Other modules simply rely on "current user" or "current admin" dependencies.
Error Handling and Validation
You need a consistent plan for how errors flow through the system.
- Validation errors at API layer:
- Use Pydantic models and FastAPI standard error responses.
- Business rule errors:
- Define custom exceptions in the service layer.
- Map them to HTTP responses using FastAPI exception handlers.
Examples:
UserAlreadyExistsErrorβ HTTP 409.InsufficientStockErrorβ HTTP 400 or 409.PermissionDeniedErrorβ HTTP 403.
By centralizing exception handling in a dedicated module (for example app.api.errors), your endpoints can remain clean and your architecture predictable.
Example Directory Structure
A concrete example for your final project:
app/
core/
config.py # Settings classes
security.py # JWT, password hashing
logging.py # Logging setup
exceptions.py # Base exceptions
db/
base.py # Base models, metadata
session.py # SessionLocal, engine
migrations/ # Alembic migrations
api/
deps.py # Common dependencies
errors.py # Exception handlers
v1/
users.py
auth.py
products.py
orders.py
payments.py
files.py
admin.py
users/
models.py
schemas.py
repository.py
service.py
auth/
models.py
schemas.py
repository.py
service.py
tokens.py
products/
models.py
schemas.py
repository.py
service.py
orders/
models.py
schemas.py
repository.py
service.py
tasks.py
payments/
models.py
schemas.py
repository.py
service.py
gateway.py
files/
models.py
schemas.py
storage.py
service.py
tasks.py
notifications/
email_service.py
tasks.py
tasks/
celery_app.py # Celery configuration, app instance
main.py # FastAPI app factoryThis structure shows:
- Clear separation between API, domain modules, core infrastructure, and database.
- Each module is a "slice" containing models, schemas, repositories, and services.
Architectural Trade-Offs and Constraints
Every architecture is a compromise. You should explicitly accept some trade-offs:
- Single database vs multiple databases:
- Easier to manage, but modules can interfere if not disciplined.
- Single codebase vs many:
- Faster development and refactoring, but you must keep modules separate by convention.
- Modular monolith vs microservices:
- Less operational complexity and faster iteration.
- Harder to scale specific modules independently at the infrastructure level.
- For this project, that is acceptable.
- Rich domain model vs anemic:
- You likely keep most logic in service layer functions instead of deep domain object hierarchies, to keep code simpler and more approachable.
Make sure your design supports:
- Running multiple app instances behind a load balancer.
- Scaling workers separately from API instances.
- Evolving modules and possibly extracting them later if needed.
Summary and Next Steps
You now have:
- A modular monolith design, with:
- Clear domain modules (users, auth, products, orders, etc.).
- Layers:
- API, service, repository, infrastructure.
- A system architecture that includes:
- FastAPI application, PostgreSQL, Redis, Celery workers, file storage, and reverse proxy.
- Cross-cutting plans for:
- Configuration, logging, security, error handling, background jobs, and caching.
In the following chapters you will:
- Design the database schema to match these modules.
- Implement the REST API following the architecture.
- Integrate PostgreSQL, Redis, file storage, and background workers according to this plan.
Keep this architecture as your reference while implementing features. If you feel tempted to add a shortcut that breaks boundaries, consider whether it is worth the future complexity or if you can extend the architecture cleanly instead.
Views: 4
KAHIBARO