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.
The Core Principle: Separation of Concerns
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.
: 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.
Share this article:
The dependency rule
The Folder Structure
Here is a production-ready folder structure that maps directly to these layers:
Every folder has a deliberate reason to exist. Let's go through each one.
Layer by Layer
domain/ — The Heart of the Application
This 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.
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/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 Cases
Services 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 Layer
Repositories 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.
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 Output
Serializers control exactly what data leaves your API. They prevent accidental exposure of sensitive fields and decouple your internal model from your API contract.
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.
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 Helpers
Utilities 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.
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.
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.
Wiring It All Together: Dependency Injection
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.
Testing Strategy That Follows the Architecture
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.
Common Mistakes to Avoid
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.
Adapting the Structure
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:
...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.
Conclusion
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.