🚀Full-Stack Clean Architecture: Best Practices with ⚡FastAPI, ⚛️React, and 🐘PostgreSQL
Imagine it’s 2:00 AM. Your application just got featured on a major tech platform, and traffic is spiking to 100x its usual volume. Is your codebase ready to handle the load, or will tangled dependencies and tightly coupled layers bring everything crashing down? Transitioning from a small side-project to a robust system demands more than just writing code that works — it requires an architecture that can scale.
In this article, we’ll dive deep into building a production-ready application using FastAPI, React, and PostgreSQL. We will explore how to decouple your business logic, manage dependencies flawlessly, adhere to SOLID and DRY principles, and structure your code for massive scalability across both the backend and frontend.
keywords — Clean Architecture, FastAPI, React, PostgreSQL, Dependency Injection, System Design, SOLID, DRY, React Query, Docker, Design Patterns, TanStack
What is Clean Architecture?
Clean Architecture is a software design philosophy introduced by Robert C. Martin (Uncle Bob). Its primary goal is the separation of concerns, dividing the software into distinct layers. This approach ensures that the business logic — the core of your application — remains independent of external agencies like user interfaces, databases, or third-party APIs. By adhering to Clean Architecture, you build systems that are inherently testable, highly maintainable, and remarkably adaptable to changing technologies or business requirements over time.
At the heart of Clean Architecture lie the SOLID and DRY principles. These principles govern how classes, modules, and functions should be structured to prevent technical debt.
If you are unfamiliar with the foundational definitions and want to see simple, beginner-friendly examples of SOLID principles before diving into this codebase, I highly recommend checking out my previous article: Python code so fresh, it’s SOLID.
In order to proceed further, it would be better if you open the code to look into it, which can be found here: https://github.com/aps08/fullstack-clean-architecture
Backend Architecture
Our FastAPI backend (server) is meticulously organized to support Clean Architecture, which relies on a one-way flow of dependencies: Routes → Services → Repositories → Models.
app/routes/: API endpoints that handle incoming HTTP requests and route them to the service layer.app/services/: Core business logic and application use cases.app/repositories/: Data access and database abstraction using SQLAlchemy (raw SQL or ORM queries).app/models/: Declarative SQLAlchemy models defining the physical database schema.app/schemas/: Pydantic models for strict request/response validation.app/core/&app/utils/: Configurations, Dependency Injection containers, and shared helpers.migrations/: Alembic database migration scripts.tests/: Pytest suite including unit, integration, and mock tests.
This flow perfectly aligns with the Dependency Inversion Principle (DIP). The higher-level business logic (Services) does not depend on the low-level database (Models) directly. Instead, both depend on abstractions (Repositories). We have example for this later in the article.
1. SOLID Principles in Action
Clean Architecture naturally enforces SOLID principles. Let’s look at how our backend (server/app) implements each of them:
Single Responsibility Principle (SRP)
Every class should have a single responsibility. In our codebase, CoreMiddleware (app/core/middleware.py) is strictly responsible for request tracking and database session management, never meddling in business logic.
class CoreMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
async with self.db.session_scope():
response = await call_next(request)
return responseOpen/Closed Principle (OCP)
Software entities should be open for extension but closed for modification. Our BaseService (app/services/base_service.py) implements standard CRUD operations. TodoService (app/services/todo_service.py) extends it to handle attachments without changing or breaking the base logic.
class TodoService(BaseService):
async def add(self, schema) -> Todos:
todo = await super().add(schema)
return todoLiskov Substitution Principle (LSP)
Derived classes must be substitutable for their base classes. Our TodoRepository (app/repositories/todo_repository.py) inherits from BaseRepository and can perfectly substitute it without altering the expected contract of database operations.
class TodoRepository(BaseRepository[Todos, BaseModel]):
def __init__(self, session: AsyncSession):
super().__init__(model=Todos, session=session)Interface Segregation Principle (ISP)
Clients shouldn’t be forced to depend on methods they don’t use. Rather than having a monolithic DatabaseRepository with hundreds of queries, we segregate them into distinct files. For example, AuthService (app/services/auth_service.py) only receives UserRepository, rather than the entire database access layer.
class AuthService(BaseService[Users, UserCreate | FindUser | UserUpdate]):
def __init__(self, user_repository: UserRepository, autosend_client: AutosendClient):
self.user_repository = user_repository
super().__init__(user_repository)Dependency Inversion Principle (DIP)
High-level modules should depend on abstractions, not concrete implementations. Our TodoService does not instantiate database sessions directly. It depends on injected repositories, managed elegantly by app/core/container.py.
class Container(containers.DeclarativeContainer):
db = providers.Singleton(Database, url=config.SQLALCHEMY_DATABASE_URI)
todo_repository = providers.Factory(TodoRepository, session=db.provided.scoped_session)
todo_service = providers.Factory(TodoService, todo_repository=todo_repository)2. Service, Repository and DRY
The Service layer is where your actual business logic lives. It orchestrates repositories. By centralizing business logic here, we strictly follow the DRY (Don’t Repeat Yourself) principle. Multiple routes can use the same service without duplicating logic.
class TodoService(BaseService):
"""Service for managing tasks and orchestrating attachments."""
def __init__(self, todo_repository: TodoRepository, attachment_repository: AttachmentRepository):
self.todo_repository = todo_repository
self.attachment_repository = attachment_repository
super().__init__(todo_repository)
async def add(self, schema) -> Todos:
"""Creates a todo and links pre-uploaded attachments."""
attachment_ids = schema.pop("attachment_ids", None)
todo = await super().add(schema)
if attachment_ids:
for aid in attachment_ids:
attachment = await self.attachment_repository.read_by_id(aid)
await self.attachment_repository.update_attribute(attachment, "todo_id", todo.id)
return todoSimilarly, the repository layer ensures DRY by avoiding duplicated database logic. The same repository can be used in multiple services that need its data access methods. For example, rather than writing attachment queries twice, AttachmentRepository is injected cleanly into both TodoService and AttachmentService:
# app/core/container.py
class Container(containers.DeclarativeContainer):
# One DRY repository to handle all attachment DB logic
attachment_repository = providers.Factory(AttachmentRepository, session=db.provided.scoped_session)
# Injected into TodoService
todo_service = providers.Factory(
TodoService,
todo_repository=todo_repository,
attachment_repository=attachment_repository
)
# Injected into AttachmentService
attachment_service = providers.Factory(
AttachmentService,
attachment_repository=attachment_repository,
todo_repository=todo_repository,
)Furthermore, by inheriting from BaseRepository, we avoid writing raw CRUD (Create, Read, Update, Delete) queries multiple times for different entities, which is the ultimate embodiment of DRY at the database level.
Frontend Architecture
Best practices on the server mean nothing if the frontend is a tangled mess of spaghetti code. In our React web application, we achieve clean architecture by heavily decoupling our UI from data fetching and state management. Our strictly structured web/src folder separates concerns exactly like the backend:
features/: Modular, page-specific components.components/: Reusable UI components and common elements.hooks/: React Query hooks for fetching (queries/), modifying (mutations/) data, and shared logic (shared/).routes/: File-based routing using TanStack Router.models/: Client-side Zod validation schemas that mirror the backend.store/&lib/: Client-side global state and client configuration instances (Axios, QueryClient).utils/&constants/: Shared utility functions and application configurations.assets/&public/: Static files, images, icons, and global styles.tests/: Playwright E2E tests, Vitest unit tests, and mock handlers.
1. SOLID Principles in Action
Single Responsibility Principle (SRP)
React components should ideally only handle rendering UI. We strictly decouple HTTP requests from our components by moving them into lib/client.ts. This file has one responsibility: configuring Axios.
// web/src/lib/client.ts
const client = axios.create({
baseURL: import.meta.env.VITE_API_URL || "http://localhost:8000",
withCredentials: true,
headers: { "Content-Type": "application/json" },
});
export default client;Open/Closed Principle (OCP)
Our components are built to be extended without being rewritten. For example, our Button component (web/src/components/common/Button.tsx) accepts a variant prop to change its look, but also spreads standard HTML button attributes (...props). It is closed for core modification, but open to extension (e.g., passing custom onClick handlers or disabled states).
// web/src/components/common/Button.tsx
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "outline" | "ghost";
}
export function Button({ variant = "primary", className = "", children, ...props }: ButtonProps) {
return (
<button className={`${baseStyles} ${variants[variant]} ${className}`} {...props}>
{children}
</button>
);
}Liskov Substitution Principle (LSP)
Strict Object-Oriented LSP doesn’t perfectly translate to functional React components, which favor composition over inheritance. However, the core idea is that a derived element should be perfectly substitutable for its base element. As shown in the OCP example above, because our Button component accepts and spreads React.ButtonHTMLAttributes<HTMLButtonElement>, it can seamlessly act as a drop-in replacement for a native HTML <button> anywhere in our application without breaking expected behavior.
Interface Segregation Principle (ISP)
Components shouldn’t be forced to depend on massive data objects when they only need a few properties. Rather than passing an entire API response object (which contains massive metadata) into our Pagination component, we segregate its interface. It only demands exactly the primitive values it needs to function:
// web/src/components/common/Pagination.tsx
interface PaginationProps {
currentPage: number;
totalCount: number;
pageSize: number;
onPageChange: (page: number) => void;
}
export function Pagination({ currentPage, totalCount, pageSize, onPageChange }: PaginationProps) {
// ... renders UI
}Dependency Inversion Principle (DIP)
Instead of components depending directly on low-level fetch() or axios instances, they depend on high-level abstractions (hooks). Our TodoList UI component doesn't care how data arrives or what URL is being hit; it simply consumes the abstract useTodos hook interface. This allows us to easily swap out the data-fetching implementation without ever touching the UI code.
// web/src/features/todo/TodoList.tsx
export function TodoList() {
// DIP: Depending on an abstraction, not the low-level fetch implementation.
const { data, isLoading, isFetching } = useTodos({
title: debouncedSearch,
page,
});
// ...2. Custom Hooks and DRY
Rather than duplicating loading states, error handling, and useEffect blocks across multiple pages, we utilize custom TanStack Query hooks.
// web/src/hooks/queries/todos.ts
export const useTodos = (params?: FindTodoParams) => {
return useQuery({
queryKey: ["todos", params],
queryFn: async () => {
const response = await client.get<TodoListResponse>("/v2/todos/", { params });
return response.data;
},
placeholderData: keepPreviousData,
});
};By abstracting this into useTodos(), we apply DRY. If the /v2/todos/ API endpoint changes, we only have to update this single hook, rather than hunting down every component that fetches todos.
Other Principles
While SOLID and DRY form the core of our architecture, several other pragmatic principles heavily influence our full-stack codebase.
KISS (Keep It Simple, Stupid)
Don’t over-engineer solutions.
Backend: If a route just needs to return a basic list, don’t build a complex caching mechanism around it unless necessary. In our application, we rely on FastAPI’s built-in dependency injection for simple query parsing rather than building custom parsers.
@router.get("/todos/")
async def get_todo_list(find_query: Annotated[FindTodo, Depends()], service: TodoServiceDep):
# Simply delegates to the service—KISS in action.
return await service.get_list(find_query.model_dump())Frontend: Why manage global state if you don’t have to? We kept things simple by intentionally avoiding complex global state managers like Redux. Instead, we rely entirely on TanStack Query to cache our server state, allowing components to simply call hooks and render.
YAGNI (You Aren’t Gonna Need It)
Don’t add functionality until it’s actually required.
Backend: For instance, our TodoRepository adheres strictly to YAGNI. We didn't build a massive custom query method for full-text search just because we might need it later; we rely on the core CRUD operations until requirements demand otherwise.
class TodoRepository(BaseRepository[Todos, BaseModel]):
def __init__(self, session: AsyncSession):
super().__init__(model=Todos, session=session)
# Notice the absence of over-engineered, unused custom SQL queries here!Frontend: It’s tempting to add an overarching Context API wrapper “just in case” the app gets larger. Our TodoList component strictly adheres to YAGNI: it manages its own local pagination state via a simple useState(1). We won't add a global store for this pagination until another page explicitly needs to read that exact state.
// web/src/features/todo/TodoList.tsx
export function TodoList() {
// YAGNI: Simple local state is perfectly fine. No global store needed.
const [page, setPage] = useState(1);
const [searchTerm, setSearchTerm] = useState("");
const { data, isLoading } = useTodos({ title: searchTerm, page });
// ...WET (Write Everything Twice / We Enjoy Typing)
A common mistake is trying to make code generic (DRY) too early. Premature abstraction often leads to overly complex code. The WET principle suggests it is perfectly okay to write similar code twice initially, so you can clearly see the pattern before you try to abstract it.
Backend: For example, before we built our generic BaseRepository, we wrote the get_by_id query manually for every single repository:
# The WET Phase (Repeating ourselves to learn the pattern)
class UserRepository:
async def get_by_id(self, id: UUID):
return await self.session.execute(select(Users).where(Users.id == id))
class TodoRepository:
async def get_by_id(self, id: UUID):
# This logic is almost completely identical to UserRepository!
return await self.session.execute(select(Todos).where(Todos.id == id))Only after writing this code twice did the underlying pattern become obvious. At that point, we confidently refactored the code and moved get_by_id into a single, generic BaseRepository that all other repositories inherit from. We embraced WET to achieve a cleaner DRY!
Frontend: Just like the backend, if you have two slightly similar UI cards on the frontend, it is often better to build two separate components first. Once you visually confirm they share the exact same structural HTML, only then should you abstract them into a shared generic <Card> component. We embraced WET during early UI prototyping to avoid overly rigid components.
Design Patterns
By strictly adhering to Clean Architecture, our codebase naturally implements several of the classic Gang of Four (GoF) design patterns to solve common software engineering problems:
- Creational Patterns: We utilize the Factory pattern heavily in our dependency injection container (
app/core/container.py) to handle the complex assembly of services. On the frontend, our Axios client (web/src/lib/client.ts) acts as a strict Singleton instance shared across the application. - Structural Patterns: Our backend Service layer acts as a Facade, hiding the complexities of database transactions from our API routes. Meanwhile, our
BaseRepositoryacts as an Adapter, wrapping complex SQLAlchemy ORM operations into simple, generic domain methods. - Behavioral Patterns: The
BaseRepositoryalso leverages the Template Method pattern, providing a strict skeleton for CRUD operations that specific repositories can inherit. On the frontend, TanStack React Query operates on the Observer pattern, allowing components to subscribe to cache invalidations and seamlessly re-render.
If you want to dive deep into the theory, mechanics, and Python examples of these patterns, check out my comprehensive three-part series on Design Patterns:
- Python Design Pattern: Creational (Part 1)
- Python Design Pattern: Structural (Part 2)
- Python Design Pattern: Behavioral (Part 3)
What else is present in the repository?
Beyond just architectural patterns, the codebase is fully equipped with production-grade configurations and deployment scripts:
- Multi-Stage Docker Deployments (with Nginx): Both the FastAPI backend and the React frontend feature multi-stage
Dockerfilesetups. For the frontend, the final build stage utilizes Nginx to serve the static compiled assets, ensuring high-performance delivery. This guarantees the final production images are highly secure, lightweight, and contain only the exact assets needed to run the application, stripping away heavy development dependencies. - Production Gunicorn Server: The backend doesn’t just run on a raw development server. It uses a robust
start.shscript that boots the application using Gunicorn with 4 worker processes (uvicorn.workers.UvicornWorker). Because Python's Global Interpreter Lock (GIL) limits a single process to a single CPU core, each Gunicorn worker effectively utilizes one core. Therefore, when deploying this configuration, ensure your host system has at least 4 CPU cores (or adjust the worker count accordingly) to achieve true parallel concurrency without context-switching bottlenecks. - Automated Database Readiness Checks: Inside
server/scripts/backend_pre_start.py, we implemented a robust verification script. Before Alembic attempts to run database migrations on startup, this script actively polls the PostgreSQL database to ensure the connection is healthy, preventing frustrating crash-loops in Docker Compose environments. - Highly Optimized Vite Build: The frontend
vite.config.tsis hyper-optimized for production. It leveragesTerserfor aggressive minification (automatically dropping console logs and dead code), utilizes the React Compiler for automatic memoization, and employs TanStack Router's auto-code-splitting to guarantee lightning-fast page loads. - Maintainability Tooling: To keep this architecture truly “clean” as the codebase grows, we employ strict static analysis and formatting. The backend uses Ruff for lightning-fast linting and formatting, alongside Mypy for rigorous static type checking. On the frontend, we use ESLint and Prettier to enforce consistent code style and catch bugs early, guaranteeing that the repository remains easily maintainable and robust across large teams.
- API Rate Limiting with SlowAPI: To protect the backend from abuse and ensure high availability during traffic spikes, the repository is pre-configured with SlowAPI. This seamlessly integrates with FastAPI to provide robust rate limiting out of the box, safeguarding your core endpoints.
Questions for you to figure out
Here are a few architectural brain-teasers based on this codebase:
- Gunicorn Workers & Schedulers: As mentioned earlier, the backend Docker setup uses Gunicorn with 4 workers. If we add an in-memory scheduled job (like APScheduler without an external queue/Redis) directly inside the FastAPI app, will the job execute exactly once, or will 4 duplicate scheduled jobs be triggered simultaneously?
- Testing vs. Rate Limiting: We integrated SlowAPI to prevent DDoS attacks and rate-limit excessive requests. However, during automated testing, we are making dozens of API calls per second. Why isn’t SlowAPI throwing
429 Too Many Requestserrors during our test suite execution? - FastAPI Dependency Graph: In our
dependencies.pyfile, we have acheck_ownershipdependency that internally callsget_current_user. If a developer accidentally injects bothcheck_ownershipandget_current_userinto the exact same route definition, will theget_current_userlogic execute twice per request? - Telemetry & Tracing: Where exactly in the codebase are we generating and setting a unique
request_idfor every incoming request? Furthermore, how are we accurately calculating and logging both the total API response time and the internal database query execution time?
Let me know your answers in the comments!
