Clean Architecture helps create maintainable, testable systems by enforcing separation of concerns through layered architecture. Let’s implement it in TypeScript with clear file structure and code explanations.
Project Structure
- main.ts
1. Domain Layer - Business Core
Entity: Pure Business Object
src/core/domain/entities/user.entity.ts
1234567891011
export class User {
constructor(
public readonly id: string | null,
public readonly name: string,
public readonly email: string
) {}
validateEmail(): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.email);
}
}
Explanation
- Pure business logic with no external dependencies
- Enforces data validation rules
- Framework-agnostic implementation
2. Application Layer - Use Cases
Use Case: Business Process
src/core/application/use-cases/create-user.use-case.ts
12345678910111213141516
import { User } from '../../domain/entities/user.entity';
import { UserRepository } from '../interfaces/user.repository.interface';
export class CreateUserUseCase {
constructor(private readonly userRepository: UserRepository) {}
async execute(userData: Omit<User, 'id'>): Promise<User> {
const user = new User(null, userData.name, userData.email);
if (!user.validateEmail()) {
throw new Error('Invalid email format');
}
return this.userRepository.save(user);
}
}
Explanation
- Orchestrates business workflow
- Depends on abstract repository interface
- Contains zero infrastructure details
Repository Interface
src/core/application/interfaces/user.repository.interface.ts
123456
import { User } from '../../domain/entities/user.entity';
export interface UserRepository {
save(user: User): Promise<User>;
findById(id: string): Promise<User | null>;
}
Explanation
- Defines data access contract
- Implemented by infrastructure layer
- Enables dependency inversion
3. Infrastructure Layer - Implementation Details
MongoDB Implementation
src/infrastructure/data/mongo/user.repository.ts
123456789101112131415161718192021
import { User } from '../../../core/domain/entities/user.entity';
import { UserRepository } from '../../../core/application/interfaces/user.repository.interface';
import { Db } from 'mongodb';
export class MongoUserRepository implements UserRepository {
constructor(private readonly db: Db) {}
async save(user: User): Promise<User> {
const result = await this.db.collection('users').insertOne({
name: user.name,
email: user.email
});
return new User(result.insertedId.toString(), user.name, user.email);
}
async findById(id: string): Promise<User | null> {
const document = await this.db.collection('users').findOne({ _id: id });
return document ? new User(document._id, document.name, document.email) : null;
}
}
Explanation
- Implements repository interface
- Contains database-specific code
- Easily swappable with other implementations
4. Presentation Layer - Delivery Mechanism
Express Controller
src/presentation/controllers/user.controller.ts
123456789101112131415
import { Request, Response } from 'express';
import { CreateUserUseCase } from '@core/application/use-cases/create-user.use-case';
export class UserController {
constructor(private readonly createUserUseCase: CreateUserUseCase) {}
async createUser(req: Request, res: Response) {
try {
const user = await this.createUserUseCase.execute(req.body);
res.status(201).json(user);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
}
Explanation
- Handles HTTP-specific concerns
- Converts web requests to use case inputs
- Transforms outputs to HTTP responses
Express Routes
src/presentation/routes/user.routes.ts
12345678910
import { Router } from 'express';
import { UserController } from '@controllers/user.controller';
export function createUserRoutes(userController: UserController) {
const router = Router();
router.post('/users', (req, res) => userController.createUser(req, res));
return router;
}
5. Composition Root - Dependency Wiring
src/main.ts
123456789101112131415161718192021222324252627282930313233
import express from 'express';
import { MongoClient } from 'mongodb';
import { MongoUserRepository } from './infrastructure/data/mongo/user.repository';
import { CreateUserUseCase } from './core/application/use-cases/create-user.use-case';
import { UserController } from './presentation/controllers/user.controller';
import { createUserRoutes } from './presentation/routes/user.routes';
async function bootstrap() {
const app = express();
app.use(express.json());
// Database setup
const client = await MongoClient.connect('mongodb://localhost:27017');
const db = client.db('clean-arch-demo');
// Repository implementation
const userRepository = new MongoUserRepository(db);
// Use case composition
const createUserUseCase = new CreateUserUseCase(userRepository);
// Controller setup
const userController = new UserController(createUserUseCase);
// Routes
app.use('/api', createUserRoutes(userController));
app.listen(3000, () => {
console.log('Server running on port 3000');
});
}
bootstrap();
Dependency Flow
1234
Presentation → Application → Domain
↑ ↑
Infrastructure →────┘
Key Benefits
-
Independent Testability
- Domain layer tests: Pure business logic
- Application layer tests: Mock repositories
- Infrastructure tests: Integration tests
- Presentation tests: API contract tests
-
Technology Agnosticism
src/infrastructure/data/postgres/user.repository.ts1234// Example PostgreSQL implementation export class PostgresUserRepository implements UserRepository { // Different SQL implementation }
-
Long-term Maintainability
- Business rules remain stable during tech stack changes
- Clear boundaries reduce cognitive load
-
Team Scalability
- Different teams can work on separate layers
- Parallel development with contract-first approach
Testing Strategy
Domain Layer Test
tests/domain/user.entity.test.ts
123456
import { User } from '@entities/user.entity';
test('Valid email returns true', () => {
const user = new User(null, 'John', '[email protected]');
expect(user.validateEmail()).toBe(true);
});
Use Case Test
tests/application/create-user.use-case.test.ts
1234567891011
import { CreateUserUseCase } from '@use-cases/create-user.use-case';
const mockRepository = {
save: jest.fn().mockResolvedValue(new User('1', 'Test', '[email protected]'))
};
test('Execute calls repository save', async () => {
const useCase = new CreateUserUseCase(mockRepository);
await useCase.execute({ name: 'Test', email: '[email protected]' });
expect(mockRepository.save).toHaveBeenCalled();
});
Conclusion
This implementation demonstrates how Clean Architecture:
- Protects business rules from technical details
- Enables technology decisions postponement
- Facilitates independent component testing
- Supports gradual complexity growth
The layered approach proves particularly valuable for applications expecting long-term evolution or potential technology migrations. While introducing initial complexity, it pays dividends in maintainability for mature projects.