How to Structure a Scalable Backend Project (Clean Architecture)
Good architecture isn't about following rules — it's about making the right changes easy and the wrong ones hard.
Loading articles...
Good architecture isn't about following rules — it's about making the right changes easy and the wrong ones hard.

Most backend projects start the same way: a single file, a few routes, maybe a database call or two. It works. It's fast to write. And for a weekend project, it's perfectly fine.
Then the project grows.
A new developer joins and can't figure out where business logic lives. A feature requires touching six different files with no clear reason why. A bug in the payment flow is impossible to unit test because the database call is embedded directly in the route handler. The codebase becomes what engineers politely call a big ball of mud.
Clean Architecture — popularized by Robert C. Martin ("Uncle Bob") but echoed across decades of software engineering thought — is a response to exactly this failure mode. It organizes code around boundaries, responsibilities, and dependencies that point inward, not outward.
This article walks through a practical, language-agnostic interpretation of Clean Architecture for backend projects, with concrete folder structures, code examples, and the reasoning behind every decision.
Before touching folder names, understand the one idea that makes everything else make sense:
Different things should change for different reasons — and those changes shouldn't cascade.
Your database schema will change. Your API contract might change. Your business rules — the actual logic of what your application does — should change as rarely as possible, and when it does change, it should be the only thing that changes.
Clean Architecture achieves this through layers. Each layer has a single responsibility and depends only on layers further inward.
┌─────────────────────────────────────────┐
│ External World │
│ (HTTP, Databases, Message Queues...) │
│ │
│ ┌───────────────────────────────────┐ │
│ │ Interface Layer │ │
│ │ (Controllers, Serializers) │ │
│ │ │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Application Layer │ │ │
│ │ │ (Services) │ │ │
│ │ │ │ │ │
│ │ │ ┌───────────────────────┐ │ │ │
│ │ │ │ Domain Layer │ │ │ │
│ │ │ │ (Models, Business │ │ │ │
│ │ │ │ Rules) │ │ │ │
│ │ │ └───────────────────────┘ │ │ │
│ │ └─────────────────────────────┘ │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
The dependency rule: source code dependencies must point inward. The domain layer knows nothing about services. Services know nothing about controllers. Controllers know nothing about Express, Fastify, or whatever HTTP framework you're using today — and might replace tomorrow.
Here is a production-ready folder structure that maps directly to these layers:
src/
├── config/
│ ├── database.ts
│ ├── env.ts
│ └── logger.ts
│
├── domain/
│ ├── models/
│ │ ├── User.ts
│ │ ├── Order.ts
│ │ └── Product.ts
│ ├── errors/
│ │ ├── AppError.ts
│ │ ├── NotFoundError.ts
│ │ └── ValidationError.ts
│ └── interfaces/
│ ├── IUserRepository.ts
│ └── IOrderRepository.ts
│
├── application/
│ └── services/
│ ├── UserService.ts
│ ├── OrderService.ts
│ └── AuthService.ts
│
├── infrastructure/
│ └── repositories/
│ ├── UserRepository.ts
│ ├── OrderRepository.ts
│ └── BaseRepository.ts
│
├── interfaces/
│ ├── http/
│ │ ├── controllers/
│ │ │ ├── UserController.ts
│ │ │ ├── OrderController.ts
│ │ │ └── AuthController.ts
│ │ ├── middlewares/
│ │ │ ├── auth.middleware.ts
│ │ │ ├── error.middleware.ts
│ │ │ └── validate.middleware.ts
│ │ └── routes/
│ │ ├── user.routes.ts
│ │ ├── order.routes.ts
│ │ └── index.ts
│ └── serializers/
│ ├── UserSerializer.ts
│ └── OrderSerializer.ts
│
├── utils/
│ ├── hash.ts
│ ├── jwt.ts
│ ├── pagination.ts
│ └── date.ts
│
└── main.ts
Every folder has a deliberate reason to exist. Let's go through each one.
domain/ — The Heart of the ApplicationThis is the innermost layer. It contains what your application is, independent of how it's accessed or where data is stored.
domain/models/ — Pure business entities. No ORM decorators, no HTTP concerns, no database types. Just the shape of your data and any domain logic that belongs to it.
// domain/models/User.ts
export class User {
id: string;
email: string;
passwordHash: string;
role: 'admin' | 'customer';
createdAt: Date;
isAdmin(): boolean {
return this.role === 'admin';
}
hasVerifiedEmail(): boolean {
return this.emailVerifiedAt !== null;
}
}
Notice: no @Entity(), no @Column(), no mongoose.Schema. This class can be instantiated in a unit test without touching a database, a framework, or a config file.
domain/interfaces/ — Contracts (interfaces or abstract classes) that the application layer depends on. This is the critical abstraction that decouples services from the actual database implementation.
// domain/interfaces/IUserRepository.ts
export interface IUserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
create(user: Omit<User, 'id' | 'createdAt'>): Promise<User>;
update(id: string, data: Partial<User>): Promise<User>;
delete(id: string): Promise<void>;
}
domain/errors/ — Typed, domain-meaningful error classes. Throwing a generic Error with a string message is a code smell; it forces callers to parse strings to understand what went wrong.
// domain/errors/AppError.ts
export class AppError extends Error {
constructor(
public readonly message: string,
public readonly statusCode: number,
public readonly code: string
) {
super(message);
this.name = 'AppError';
}
}
// domain/errors/NotFoundError.ts
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404, 'RESOURCE_NOT_FOUND');
}
}
application/services/ — The Use CasesServices encode what your application does. Each service method represents a use case: "create a user," "place an order," "process a refund."
Services depend on repository interfaces (from the domain layer), not concrete implementations. They contain business logic, orchestrate calls, and throw domain errors.
// application/services/UserService.ts
import { IUserRepository } from '../../domain/interfaces/IUserRepository';
import { NotFoundError } from '../../domain/errors/NotFoundError';
import { ValidationError } from '../../domain/errors/ValidationError';
import { hashPassword } from '../../utils/hash';
export class UserService {
constructor(private readonly userRepo: IUserRepository) {}
async createUser(data: CreateUserDTO): Promise<User> {
const existing = await this.userRepo.findByEmail(data.email);
if (existing) {
throw new ValidationError('Email already in use');
}
const passwordHash = await hashPassword(data.password);
return this.userRepo.create({
email: data.email,
passwordHash,
role: 'customer',
});
}
async getUserById(id: string): Promise<User> {
const user = await this.userRepo.findById(id);
if (!user) {
throw new NotFoundError('User');
}
return user;
}
}
Key discipline: services never import from interfaces/http/ or reference req, res, or any HTTP concept. If you find yourself passing a request object into a service, stop — you're leaking the wrong abstraction.
infrastructure/repositories/ — The Data Access LayerRepositories implement the interfaces defined in the domain layer. This is where your ORM, SQL queries, or API calls actually live.
// infrastructure/repositories/UserRepository.ts
import { IUserRepository } from '../../domain/interfaces/IUserRepository';
import { User } from '../../domain/models/User';
import { db } from '../../config/database';
export class UserRepository implements IUserRepository {
async findById(id: string): Promise<User | null> {
const row = await db('users').where({ id }).first();
return row ? this.toModel(row) : null;
}
async findByEmail(email: string): Promise<User | null> {
const row = await db('users').where({ email }).first();
return row ? this.toModel(row) : null;
}
async create(data: Omit<User, 'id' | 'createdAt'>): Promise<User> {
const [row] = await db('users').insert(data).returning('*');
return this.toModel(row);
}
async update(id: string, data: Partial<User>): Promise<User> {
const [row] = await db('users')
.where({ id })
.update(data)
.returning('*');
return this.toModel(row);
}
async delete(id: string): Promise<void> {
await db('users').where({ id }).delete();
}
private toModel(row: Record<string, unknown>): User {
const user = new User();
user.id = row.id as string;
user.email = row.email as string;
user.passwordHash = row.password_hash as string;
user.role = row.role as 'admin' | 'customer';
user.createdAt = new Date(row.created_at as string);
return user;
}
}
The toModel mapper is doing something important: it translates between the database's representation (snake_case columns, raw types) and the domain model's representation. This mapping lives here, not in the model, and not in the service.
interfaces/http/controllers/ — The Entry PointsControllers are thin. Their job is to:
Nothing more.
// interfaces/http/controllers/UserController.ts
import { Request, Response, NextFunction } from 'express';
import { UserService } from '../../../application/services/UserService';
import { UserSerializer } from '../../serializers/UserSerializer';
import { createUserSchema } from '../validators/user.validators';
export class UserController {
constructor(private readonly userService: UserService) {}
async create(req: Request, res: Response, next: NextFunction) {
try {
const { error, value } = createUserSchema.validate(req.body);
if (error) {
return res.status(400).json({ message: error.details[0].message });
}
const user = await this.userService.createUser(value);
return res.status(201).json(UserSerializer.toJSON(user));
} catch (err) {
next(err);
}
}
async getById(req: Request, res: Response, next: NextFunction) {
try {
const user = await this.userService.getUserById(req.params.id);
return res.json(UserSerializer.toJSON(user));
} catch (err) {
next(err);
}
}
}
No business logic here. If a controller method is more than ~20 lines, it's doing too much. Move it to the service.
interfaces/serializers/ — Shape the OutputSerializers control exactly what data leaves your API. They prevent accidental exposure of sensitive fields and decouple your internal model from your API contract.
// interfaces/serializers/UserSerializer.ts
import { User } from '../../domain/models/User';
interface UserJSON {
id: string;
email: string;
role: string;
createdAt: string;
}
export class UserSerializer {
static toJSON(user: User): UserJSON {
return {
id: user.id,
email: user.email,
role: user.role,
createdAt: user.createdAt.toISOString(),
// passwordHash is deliberately omitted
};
}
static toJSONList(users: User[]): UserJSON[] {
return users.map(UserSerializer.toJSON);
}
}
Never return raw model objects from controllers. The day you add a passwordHash or ssn field to your model and forget to exclude it from the response is the day you have an incident report to write.
interfaces/http/middlewares/ — Cross-Cutting ConcernsMiddlewares handle concerns that apply across many routes: authentication, error formatting, request logging, rate limiting.
// interfaces/http/middlewares/error.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../../../domain/errors/AppError';
import { logger } from '../../../config/logger';
export function errorMiddleware(
err: Error,
req: Request,
res: Response,
_next: NextFunction
) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
},
});
}
// Unexpected errors — log and return generic message
logger.error(err);
return res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
},
});
}
This single middleware handles all error responses. Because services throw typed AppError subclasses, the error formatting is automatic and consistent — no if (type === 'NotFoundError') scattered across controllers.
utils/ — Stateless, Pure HelpersUtilities are functions with no side effects and no dependencies on the rest of your application. They're the functions you can unit test in isolation with a single import.
// utils/pagination.ts
export interface PaginationParams {
page: number;
limit: number;
}
export interface PaginatedResult<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export function paginate<T>(
items: T[],
total: number,
{ page, limit }: PaginationParams
): PaginatedResult<T> {
return {
data: items,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
export function getPaginationOffset({ page, limit }: PaginationParams): number {
return (page - 1) * limit;
}
What belongs in utils/: JWT signing/verification, password hashing, date formatting, string manipulation, file path helpers.
What doesn't belong in utils/: anything that calls the database, anything that throws domain errors, anything that depends on configuration at import time.
config/ — Environment and Infrastructure SetupConfig files initialize infrastructure: database connections, logger instances, environment variable validation.
// config/env.ts
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
JWT_EXPIRES_IN: z.string().default('7d'),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment variables:', parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
Validating environment variables at startup — and crashing immediately if they're wrong — is one of the highest-leverage reliability improvements you can make to any backend. Fail fast, fail loud.
The architecture above relies on dependency injection: services receive their repositories as constructor arguments rather than importing them directly.
This isn't just an architectural nicety — it's what makes testing possible. In a unit test, you inject a mock repository; in production, you inject the real one.
// main.ts
import express from 'express';
import { env } from './config/env';
import { UserRepository } from './infrastructure/repositories/UserRepository';
import { UserService } from './application/services/UserService';
import { UserController } from './interfaces/http/controllers/UserController';
import { userRoutes } from './interfaces/http/routes/user.routes';
import { errorMiddleware } from './interfaces/http/middlewares/error.middleware';
const app = express();
app.use(express.json());
// Composition root — wire everything together here
const userRepository = new UserRepository();
const userService = new UserService(userRepository);
const userController = new UserController(userService);
app.use('/api/users', userRoutes(userController));
// Error middleware always last
app.use(errorMiddleware);
app.listen(env.PORT, () => {
console.log(`Server running on port ${env.PORT}`);
});
For larger applications, use a DI container (like tsyringe, InversifyJS, or Awilix) instead of wiring manually.
Clean Architecture is worth little if it doesn't make testing easier. Here's how the layers map to test types:
| Layer | Test type | What you mock |
|---|---|---|
utils/ | Unit tests | Nothing |
domain/models/ | Unit tests | Nothing |
application/services/ | Unit tests | Repository interfaces |
infrastructure/repositories/ | Integration tests | Real DB (test container) |
interfaces/http/controllers/ | Integration tests | Services (optional) |
| Full stack | E2E tests | Nothing |
// __tests__/UserService.test.ts
import { UserService } from '../application/services/UserService';
import { NotFoundError } from '../domain/errors/NotFoundError';
// Mock repository — just a plain object implementing the interface
const mockUserRepo = {
findById: jest.fn(),
findByEmail: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
const userService = new UserService(mockUserRepo);
describe('UserService.getUserById', () => {
it('throws NotFoundError when user does not exist', async () => {
mockUserRepo.findById.mockResolvedValue(null);
await expect(userService.getUserById('non-existent-id'))
.rejects
.toThrow(NotFoundError);
});
it('returns user when found', async () => {
const mockUser = { id: '1', email: 'test@test.com' };
mockUserRepo.findById.mockResolvedValue(mockUser);
const result = await userService.getUserById('1');
expect(result).toEqual(mockUser);
});
});
No HTTP server. No database. No config files. The test runs in milliseconds and tests exactly one thing.
Putting business logic in controllers. Controllers should be so thin that reading one tells you almost nothing about what the application does — only how it's accessed.
Importing repositories directly in services. Services must depend on interfaces, not concrete classes. The moment a service imports UserRepository instead of IUserRepository, you've lost the ability to swap implementations and made unit testing painful.
Skipping serializers. "I'll just return the model directly for now" is how passwordHash ends up in your API response at 2am.
A utils/ folder that becomes a dumping ground. If a function has side effects, depends on config, or is domain-specific, it doesn't belong in utils/. Create a proper service for it.
One giant service per entity. A UserService with 20 methods is a symptom. Break it apart by use case: UserRegistrationService, UserAuthService, UserProfileService.
This architecture is a starting point, not a mandate. For a small team or early-stage product, the full structure may be overkill — and that's fine. Start with:
src/
├── controllers/
├── services/
├── repositories/
├── models/
└── utils/
...and introduce the domain/interfaces/ abstraction, serializers, and typed errors as the project grows and pain points emerge. The key is keeping the dependency direction correct from the beginning: controllers call services, services call repositories — never the reverse.
Clean Architecture is not about folder names. It's about making explicit decisions regarding which parts of your code know about which other parts — and being disciplined about the direction of that knowledge.
When a junior developer can look at your folder structure and immediately know where to put a new piece of logic, you have good architecture. When a requirement changes and the change touches exactly the files you'd expect — and nothing else — you have good architecture. When you can write a unit test for a business rule in under a minute without spinning up a database, you have good architecture.
The structure described in this article is one path to get there. It's been validated across projects of many sizes and teams of many shapes. Take what works, adapt what doesn't, and — above all — enforce the boundaries.
The code you write today is the legacy someone else will inherit tomorrow. Make it worth inheriting.

Create elementAI Explainer Videos That Convert With Simple Text Prompts.
Learn More