Building Clean Architectures in Modern Backend Frameworks
Emily Parker
Product Engineer · Leapcell

Introduction
In the ever-evolving landscape of backend development, maintaining a codebase that is not only functional but also scalable, testable, and easy to understand is paramount. As projects grow in complexity, the initial rapid development often gives way to entangled dependencies, brittle tests, and a high cost of change. This challenge resonates deeply across various technology stacks, making the adoption of robust architectural patterns a critical concern for developers and organizations alike. The principles of Clean Architecture, championed by Robert C. Martin, offer a compelling solution by emphasizing the separation of concerns and independence from frameworks, databases, and UI. This article delves into how these powerful principles can be practically applied within three prominent backend frameworks: Django, NestJS, and FastAPI, enabling developers to build more resilient and maintainable applications.
Understanding Core Concepts
Before diving into the specifics of each framework, it's essential to clarify the foundational concepts behind Clean Architecture.
- Entities (Domain Layer): These encapsulate enterprise-wide business rules. They are the purest and highest-level policies, independent of any application-specific concerns. Changes in the database, UI, or even external frameworks should not affect entities.
- Use Cases (Application Layer): This layer contains application-specific business rules. It orchestrates the flow of data to and from the entities, dictating how the application will use the entities. Use cases should not know about the database or web framework. Interfaces (abstract classes) are often defined here for external concerns.
- Interface Adapters (Infrastructure Layer): This layer acts as a gateway between the Use Cases and external concerns. It adapts data from a format convenient for the Use Cases and Entities to a format suitable for frameworks, databases, or external services, and vice-versa. Examples include Presenters, Gateways, and Controllers.
- Frameworks & Drivers (External Layer): This outermost layer consists of concrete implementations of interfaces defined in the inner layers. This includes web frameworks (Django, NestJS, FastAPI), databases (ORM implementations), and external API integrations.
The fundamental principle here is the Dependency Rule: dependencies can only point inwards. Inner circles should never know anything about outer circles.
Implementing Clean Architecture
Let's explore how these concepts translate into practical code examples across our chosen frameworks.
Django
Django, with its "batteries included" philosophy, can sometimes lead to tightly coupled applications if not approached carefully. However, its modularity allows for effective implementation of Clean Architecture.
Project Structure Example:
my_django_project/
├── core/ # Entities & Use Cases
│ ├── domain/ # Entities (pure Python objects)
│ │ ├── models.py # e.g., User, Product entities
│ │ └── __init__.py
│ ├── use_cases/ # Use cases (business logic)
│ │ ├── create_user.py
│ │ ├── get_product.py
│ │ └── __init__.py
│ ├── interfaces/ # Abstractions for external services
│ │ ├── user_repository.py # e.g., abstractUserRepository
│ │ └── __init__.py
└── application/ # Interface Adapters & Frameworks
├── users/
│ ├── adapters/ # Adapters for database/web
│ │ ├── repositories.py # Concrete ORM implementations (e.g., UserDjangoRepository)
│ │ └── controllers.py # Django views/API views calling use cases
│ ├── urls.py
│ └── __init__.py
├── products/
│ └── ...
├── config/ # Django project settings
├── manage.py
└── ...
Code Example (Django):
-
core/domain/models.py
(Entity):# Pure Python object, no Django ORM inheritance class User: def __init__(self, id: str, username: str, email: str): self.id = id self.username = username self.email = email def update_email(self, new_email: str): # Business rule if "@" not in new_email: raise ValueError("Invalid email format") self.email = new_email
-
core/interfaces/user_repository.py
(Abstract Repository):from abc import ABC, abstractmethod from core.domain.models import User class UserRepository(ABC): @abstractmethod def get_by_id(self, user_id: str) -> User: pass @abstractmethod def save(self, user: User): pass
-
core/use_cases/create_user.py
(Use Case):from core.domain.models import User from core.interfaces.user_repository import UserRepository class CreateUser: def __init__(self, user_repository: UserRepository): self.user_repository = user_repository def execute(self, user_id: str, username: str, email: str) -> User: user = User(id=user_id, username=username, email=email) # Potentially more business logic before saving self.user_repository.save(user) return user
-
application/users/adapters/repositories.py
(Concrete Django ORM Repository):from django.db import models from core.domain.models import User as DomainUser from core.interfaces.user_repository import UserRepository # Django ORM Model (Infrastructure detail) class UserORM(models.Model): id = models.CharField(max_length=255, primary_key=True) username = models.CharField(max_length=255) email = models.EmailField() class Meta: db_table = 'users' class DjangoUserRepository(UserRepository): def get_by_id(self, user_id: str) -> DomainUser: orm_user = UserORM.objects.get(id=user_id) return DomainUser(id=orm_user.id, username=orm_user.username, email=orm_user.email) def save(self, user: DomainUser): UserORM.objects.update_or_create( id=user.id, defaults={'username': user.username, 'email': user.email} )
-
application/users/adapters/controllers.py
(Django View/Controller):from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status from core.use_cases.create_user import CreateUser from application.users.adapters.repositories import DjangoUserRepository import uuid class CreateUserAPIView(APIView): def post(self, request): user_id = str(uuid.uuid4()) username = request.data.get('username') email = request.data.get('email') if not username or not email: return Response({'error': 'Username and email are required'}, status=status.HTTP_400_BAD_REQUEST) repo = DjangoUserRepository() create_user_use_case = CreateUser(user_repository=repo) try: user = create_user_use_case.execute(user_id=user_id, username=username, email=email) return Response({'id': user.id, 'username': user.username, 'email': user.email}, status=status.HTTP_201_CREATED) except ValueError as e: return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
This setup ensures the core business logic (User
entity and CreateUser
use case) remains completely unaware of Django's ORM or HTTP specifics.
NestJS
NestJS is heavily inspired by Angular and Spring, promoting a modular and structured approach with its use of modules, controllers, services, and providers. This naturally aligns well with Clean Architecture.
Project Structure Example:
src/
├── core/ # Entities & Use Cases
│ ├── domain/ # Entities (Interfaces/Classes)
│ │ ├── user.ts
│ │ └── product.ts
│ ├── use-cases/ # Application-specific business logic
│ │ ├── create-user.use-case.ts
│ │ ├── get-product.use-case.ts
│ │ └── interfaces/ # Abstract Repositories/Gateways defined here
│ │ ├── user-repository.interface.ts
│ │ └── product-repository.interface.ts
└── infrastructure/ # Interface Adapters & Frameworks
├── user/
│ ├── adapters/ # Concrete implementations for repositories and controllers
│ │ ├── user.controller.ts # Handles HTTP requests, injects use cases
│ │ ├── user.entity.ts # TypeORM entity
│ │ └── user.repository.ts # Concrete TypeORM repository implementation
│ ├── user.module.ts
│ └── dtos/ # Data Transfer Objects
│ └── create-user.dto.ts
├── product/
│ └── ...
├── main.ts
└── app.module.ts
Code Example (NestJS):
-
src/core/domain/user.ts
(Entity):// Pure TypeScript class/interface, framework-agnostic export interface User { id: string; username: string; email: string; updateEmail(newEmail: string): void; } export class UserEntity implements User { constructor(public id: string, public username: string, public email: string) {} updateEmail(newEmail: string): void { if (!newEmail.includes('@')) { throw new Error('Invalid email format'); } this.email = newEmail; } }
-
src/core/use-cases/interfaces/user-repository.interface.ts
(Abstract Repository):import { User } from '../domain/user'; export interface IUserRepository { getById(id: string): Promise<User | null>; save(user: User): Promise<void>; } export const USER_REPOSITORY = 'USER_REPOSITORY'; // Token for dependency injection
-
src/core/use-cases/create-user.use-case.ts
(Use Case):import { Inject, Injectable } from '@nestjs/common'; import { User, UserEntity } from '../domain/user'; import { IUserRepository, USER_REPOSITORY } from './interfaces/user-repository.interface'; @Injectable() export class CreateUserUseCase { constructor( @Inject(USER_REPOSITORY) private readonly userRepository: IUserRepository, ) {} async execute(id: string, username: string, email: string): Promise<User> { const user = new UserEntity(id, username, email); // Additional business logic await this.userRepository.save(user); return user; } }
-
src/infrastructure/user/adapters/user.entity.ts
(TypeORM Entity - Infrastructure detail):import { Entity, PrimaryColumn, Column } from 'typeorm'; @Entity('users') export class UserTypeORMEntity { @PrimaryColumn() id: string; @Column() username: string; @Column() email: string; }
-
src/infrastructure/user/adapters/user.repository.ts
(Concrete TypeORM Repository):import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { IUserRepository } from '../../../core/use-cases/interfaces/user-repository.interface'; import { User, UserEntity } from '../../../core/domain/user'; import { UserTypeORMEntity } from './user.entity'; import { Injectable } from '@nestjs/common'; @Injectable() export class UserTypeORMRepository implements IUserRepository { constructor( @InjectRepository(UserTypeORMEntity) private readonly ormRepository: Repository<UserTypeORMEntity>, ) {} async getById(id: string): Promise<User | null> { const ormUser = await this.ormRepository.findOne({ where: { id } }); return ormUser ? new UserEntity(ormUser.id, ormUser.username, ormUser.email) : null; } async save(user: User): Promise<void> { const ormUser = this.ormRepository.create(user); // Map domain to ORM entity await this.ormRepository.save(ormUser); } }
-
src/infrastructure/user/adapters/user.controller.ts
(NestJS Controller):import { Body, Controller, Post, Res, HttpStatus } from '@nestjs/common'; import { CreateUserUseCase } from '../../../core/use-cases/create-user.use-case'; import { CreateUserDto } from '../dtos/create-user.dto'; import { Response } from 'express'; // or @nestjs/common/response for Nest's abstraction import { v4 as uuidv4 } from 'uuid'; @Controller('users') export class UsersController { constructor(private readonly createUserUseCase: CreateUserUseCase) {} @Post() async createUser(@Body() createUserDto: CreateUserDto, @Res() res: Response) { try { const userId = uuidv4(); const user = await this.createUserUseCase.execute(userId, createUserDto.username, createUserDto.email); return res.status(HttpStatus.CREATED).json({ id: user.id, username: user.username, email: user.email }); } catch (error) { if (error instanceof Error) { return res.status(HttpStatus.BAD_REQUEST).json({ message: error.message }); } return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ message: 'An unexpected error occurred' }); } } }
NestJS's robust dependency injection system makes it straightforward to inject the abstract repository into the use case and then the use case into the controller, maintaining the dependency rule.
FastAPI
FastAPI, known for its performance and modern Python features like type hints and async/await, can also be structured elegantly with Clean Architecture, leveraging its dependency injection system.
Project Structure Example:
my_fastapi_project/
├── core/ # Entities & Use Cases
│ ├── domain/ # Entities (Pydantic models or plain Python classes)
│ │ ├── user.py
│ │ └── product.py
│ ├── use_cases/ # Application-specific business logic
│ │ ├── create_user.py
│ │ ├── get_product.py
│ │ └── interfaces/ # Abstract Repositories/Gateways
│ │ ├── user_repository.py
│ │ └── product_repository.py
└── infrastructure/ # Interface Adapters & Frameworks
├── api/ # FastAPI route definitions
│ ├── v1/
│ │ ├── endpoints/
│ │ │ ├── user.py # Controllers (FastAPI endpoints)
│ │ │ └── product.py
│ │ └── routers.py
├── persistence/ # Database implementations
│ ├── repositories/ # Concrete SQLAlchemy/ORM repositories
│ │ ├── user_sqlalchemy_repo.py
│ │ └── product_sqlalchemy_repo.py
│ ├── database.py # DB connection setup
│ └── models.py # SQLAlchemy ORM models
├── main.py # FastAPI app initialization
└── dependencies.py # Dependency injection setup
Code Example (FastAPI):
-
core/domain/user.py
(Entity):from pydantic import BaseModel # Can use Pydantic for validation, but the core logic is separate class User(BaseModel): id: str username: str email: str def update_email(self, new_email: str): if "@" not in new_email: raise ValueError("Invalid email format") self.email = new_email
-
core/use_cases/interfaces/user_repository.py
(Abstract Repository):from abc import ABC, abstractmethod from typing import Optional from core.domain.user import User class UserRepository(ABC): @abstractmethod async def get_by_id(self, user_id: str) -> Optional[User]: pass @abstractmethod async def save(self, user: User) -> None: pass
-
core/use_cases/create_user.py
(Use Case):from core.domain.user import User from core.use_cases.interfaces.user_repository import UserRepository class CreateUser: def __init__(self, user_repository: UserRepository): self.user_repository = user_repository async def execute(self, user_id: str, username: str, email: str) -> User: user = User(id=user_id, username=username, email=email) await self.user_repository.save(user) return user
-
infrastructure/persistence/models.py
(SQLAlchemy ORM Model - Infrastructure detail):from sqlalchemy import Column, String from sqlalchemy.ext.declarative import declarative_base Base = declarative_base() class UserORM(Base): __tablename__ = "users" id = Column(String, primary_key=True, index=True) username = Column(String, unique=True, index=True) email = Column(String, unique=True, index=True)
-
infrastructure/persistence/repositories/user_sqlalchemy_repo.py
(Concrete SQLAlchemy Repository):from sqlalchemy.orm import Session from core.domain.user import User as DomainUser from core.use_cases.interfaces.user_repository import UserRepository from infrastructure.persistence.models import UserORM from typing import Optional class SQLAlchemyUserRepository(UserRepository): def __init__(self, db: Session): self.db = db async def get_by_id(self, user_id: str) -> Optional[DomainUser]: orm_user = self.db.query(UserORM).filter(UserORM.id == user_id).first() return DomainUser.model_validate(orm_user) if orm_user else None async def save(self, user: DomainUser) -> None: orm_user = self.db.query(UserORM).filter(UserORM.id == user.id).first() if orm_user: for key, value in user.model_dump().items(): setattr(orm_user, key, value) else: orm_user = UserORM(**user.model_dump()) self.db.add(orm_user) self.db.commit() self.db.refresh(orm_user)
-
infrastructure/dependencies.py
(FastAPI Dependency Injection Helper):from sqlalchemy.orm import Session from infrastructure.persistence.database import get_db from core.use_cases.interfaces.user_repository import UserRepository from infrastructure.persistence.repositories.user_sqlalchemy_repo import SQLAlchemyUserRepository from core.use_cases.create_user import CreateUser def get_user_repository(db: Session = Depends(get_db)) -> UserRepository: return SQLAlchemyUserRepository(db) def get_create_user_use_case(user_repo: UserRepository = Depends(get_user_repository)) -> CreateUser: return CreateUser(user_repository=user_repo)
-
infrastructure/api/v1/endpoints/user.py
(FastAPI Endpoint/Controller):from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel import uuid from core.domain.user import User as DomainUser from core.use_cases.create_user import CreateUser from infrastructure.dependencies import get_create_user_use_case router = APIRouter() class CreateUserRequest(BaseModel): username: str email: str class CreateUserResponse(BaseModel): id: str username: str email: str @router.post("/users", response_model=CreateUserResponse, status_code=status.HTTP_201_CREATED) async def create_user_endpoint( request: CreateUserRequest, create_user_uc: CreateUser = Depends(get_create_user_use_case) ): try: user_id = str(uuid.uuid4()) user = await create_user_uc.execute(user_id=user_id, username=request.username, email=request.email) return CreateUserResponse.model_validate(user) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
FastAPI's Depends
feature is incredibly powerful for injecting dependencies, allowing for a clean separation between the HTTP layer and the application's core logic.
Conclusion
Adopting Clean Architecture in Django, NestJS, or FastAPI provides a robust blueprint for building maintainable, testable, and scalable backend applications. By strictly adhering to the Dependency Rule and separating concerns into distinct layers, developers can achieve systems that are independent of external frameworks and databases, enabling easier evolution and adaptation over time. This approach ensures that the core business logic remains pristine and unaffected by technological churn, empowering applications to stand the test of time.
Ultimately, Clean Architecture is not just a pattern, but a mindset that champions long-term quality and architectural integrity over short-term expediency.