Overview
Clean Architecture emphasizes separation of concerns through concentric layers. The innermost layers (entities, use cases) contain business logic independent of external frameworks and tools. Outer layers handle UI, database, and infrastructure concerns.
Key Concepts
Concentric Layers
- Entities: Core business rules (innermost)
- Use Cases: Application-specific business rules
- Interface Adapters: Translates between use cases and external systems
- Frameworks & Drivers: Web frameworks, databases, etc. (outermost)
Dependency Rule
Dependencies point inward only. Inner layers never depend on outer layers.
Independence
- Independent of frameworks
- Testable
- Independent of UI
- Independent of database
- Independent of external agencies
Modularity
Organize code by business domain rather than technical layer.
Code Examples
Project Structure
src/
├── domain/ # Core business entities
│ ├── User/
│ │ └── User.ts
│ ├── Post/
│ │ └── Post.ts
│ └── Comment/
│ └── Comment.ts
├── usecases/ # Application business rules
│ ├── User/
│ │ ├── CreateUserUseCase.ts
│ │ └── GetUserUseCase.ts
│ ├── Post/
│ │ ├── CreatePostUseCase.ts
│ │ └── ListPostsUseCase.ts
│ └── Comment/
│ └── CreateCommentUseCase.ts
├── adapters/ # Interface adapters
│ ├── http/
│ │ ├── UserController.ts
│ │ ├── PostController.ts
│ │ └── routes.ts
│ ├── persistence/
│ │ ├── UserRepository.ts
│ │ ├── PostRepository.ts
│ │ └── CommentRepository.ts
│ └── presenters/
│ ├── UserPresenter.ts
│ └── PostPresenter.ts
├── frameworks/ # External frameworks
│ ├── database/
│ │ └── PostgresDatabase.ts
│ ├── http/
│ │ └── ExpressServer.ts
│ └── cache/
│ └── RedisCache.ts
└── di/ # Dependency Injection
└── Container.ts
Domain Layer - Entities
typescript
1// src/domain/User/User.ts
2export interface IUser {
3 id: string
4 email: string
5 username: string
6 passwordHash: string
7 createdAt: Date
8 updatedAt: Date
9}
10
11export class User implements IUser {
12 id: string
13 email: string
14 username: string
15 passwordHash: string
16 createdAt: Date
17 updatedAt: Date
18
19 constructor(
20 id: string,
21 email: string,
22 username: string,
23 passwordHash: string,
24 createdAt: Date,
25 updatedAt: Date
26 ) {
27 this.id = id
28 this.email = email
29 this.username = username
30 this.passwordHash = passwordHash
31 this.createdAt = createdAt
32 this.updatedAt = updatedAt
33 }
34
35 // Business logic methods
36 updateEmail(newEmail: string): void {
37 if (!this.isValidEmail(newEmail)) {
38 throw new Error('Invalid email format')
39 }
40 this.email = newEmail
41 this.updatedAt = new Date()
42 }
43
44 private isValidEmail(email: string): boolean {
45 return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
46 }
47}
48
49// src/domain/User/UserRepository.ts
50export interface IUserRepository {
51 findById(id: string): Promise<User | null>
52 findByEmail(email: string): Promise<User | null>
53 save(user: User): Promise<void>
54 delete(id: string): Promise<void>
55 findAll(): Promise<User[]>
56}
Use Case Layer
typescript
1// src/usecases/User/CreateUserUseCase.ts
2import { User, IUserRepository } from '../../domain/User'
3
4export interface CreateUserRequest {
5 email: string
6 username: string
7 password: string
8}
9
10export interface CreateUserResponse {
11 id: string
12 email: string
13 username: string
14}
15
16export class CreateUserUseCase {
17 constructor(private userRepository: IUserRepository) {}
18
19 async execute(request: CreateUserRequest): Promise<CreateUserResponse> {
20 // Check if user already exists
21 const existingUser = await this.userRepository.findByEmail(request.email)
22 if (existingUser) {
23 throw new Error('User with this email already exists')
24 }
25
26 // Create user entity
27 const user = new User(
28 this.generateId(),
29 request.email,
30 request.username,
31 this.hashPassword(request.password),
32 new Date(),
33 new Date()
34 )
35
36 // Save to repository
37 await this.userRepository.save(user)
38
39 // Return response
40 return {
41 id: user.id,
42 email: user.email,
43 username: user.username,
44 }
45 }
46
47 private generateId(): string {
48 return Math.random().toString(36).substring(7)
49 }
50
51 private hashPassword(password: string): string {
52 // In production, use bcrypt or similar
53 return Buffer.from(password).toString('base64')
54 }
55}
56
57// src/usecases/User/GetUserUseCase.ts
58export interface GetUserResponse {
59 id: string
60 email: string
61 username: string
62 createdAt: Date
63}
64
65export class GetUserUseCase {
66 constructor(private userRepository: IUserRepository) {}
67
68 async execute(userId: string): Promise<GetUserResponse | null> {
69 const user = await this.userRepository.findById(userId)
70 if (!user) {
71 return null
72 }
73
74 return {
75 id: user.id,
76 email: user.email,
77 username: user.username,
78 createdAt: user.createdAt,
79 }
80 }
81}
Adapter Layer - Controllers
typescript
1// src/adapters/http/UserController.ts
2import { CreateUserUseCase, CreateUserRequest } from '../../usecases/User'
3import { GetUserUseCase } from '../../usecases/User/GetUserUseCase'
4import { IUserPresenter } from './presenters/UserPresenter'
5
6export class UserController {
7 constructor(
8 private createUserUseCase: CreateUserUseCase,
9 private getUserUseCase: GetUserUseCase,
10 private userPresenter: IUserPresenter
11 ) {}
12
13 async createUser(req: any, res: any): Promise<void> {
14 try {
15 const request: CreateUserRequest = {
16 email: req.body.email,
17 username: req.body.username,
18 password: req.body.password,
19 }
20
21 const response = await this.createUserUseCase.execute(request)
22 res.status(201).json(this.userPresenter.present(response))
23 } catch (error: any) {
24 res.status(400).json({ error: error.message })
25 }
26 }
27
28 async getUser(req: any, res: any): Promise<void> {
29 try {
30 const userId = req.params.id
31 const response = await this.getUserUseCase.execute(userId)
32
33 if (!response) {
34 res.status(404).json({ error: 'User not found' })
35 return
36 }
37
38 res.json(this.userPresenter.present(response))
39 } catch (error: any) {
40 res.status(500).json({ error: error.message })
41 }
42 }
43}
44
45// src/adapters/http/routes.ts
46import { Router } from 'express'
47import { UserController } from './UserController'
48
49export function setupRoutes(router: Router, userController: UserController) {
50 router.post('/users', (req, res) => userController.createUser(req, res))
51 router.get('/users/:id', (req, res) => userController.getUser(req, res))
52}
Adapter Layer - Repository Implementation
typescript
1// src/adapters/persistence/UserRepository.ts
2import { User, IUserRepository } from '../../domain/User'
3import { Kysely } from 'kysely'
4
5interface UserRow {
6 id: string
7 email: string
8 username: string
9 password_hash: string
10 created_at: Date
11 updated_at: Date
12}
13
14export class UserRepository implements IUserRepository {
15 constructor(private db: Kysely<any>) {}
16
17 async findById(id: string): Promise<User | null> {
18 const row = await this.db
19 .selectFrom('users')
20 .selectAll()
21 .where('id', '=', id)
22 .executeTakeFirst()
23
24 if (!row) return null
25 return this.mapRowToUser(row)
26 }
27
28 async findByEmail(email: string): Promise<User | null> {
29 const row = await this.db
30 .selectFrom('users')
31 .selectAll()
32 .where('email', '=', email)
33 .executeTakeFirst()
34
35 if (!row) return null
36 return this.mapRowToUser(row)
37 }
38
39 async save(user: User): Promise<void> {
40 await this.db
41 .insertInto('users')
42 .values({
43 id: user.id,
44 email: user.email,
45 username: user.username,
46 password_hash: user.passwordHash,
47 created_at: user.createdAt,
48 updated_at: user.updatedAt,
49 })
50 .execute()
51 }
52
53 async delete(id: string): Promise<void> {
54 await this.db.deleteFrom('users').where('id', '=', id).execute()
55 }
56
57 async findAll(): Promise<User[]> {
58 const rows = await this.db.selectFrom('users').selectAll().execute()
59
60 return rows.map((row) => this.mapRowToUser(row))
61 }
62
63 private mapRowToUser(row: UserRow): User {
64 return new User(
65 row.id,
66 row.email,
67 row.username,
68 row.password_hash,
69 row.created_at,
70 row.updated_at
71 )
72 }
73}
Presenter
typescript
1// src/adapters/http/presenters/UserPresenter.ts
2export interface IUserPresenter {
3 present(data: any): any
4}
5
6export class UserPresenter implements IUserPresenter {
7 present(userData: any) {
8 return {
9 id: userData.id,
10 email: userData.email,
11 username: userData.username,
12 createdAt: userData.createdAt,
13 }
14 }
15}
Dependency Injection Container
typescript
1// src/di/Container.ts
2import { Kysely } from 'kysely'
3import { CreateUserUseCase } from '../usecases/User'
4import { GetUserUseCase } from '../usecases/User/GetUserUseCase'
5import { UserRepository } from '../adapters/persistence/UserRepository'
6import { UserController } from '../adapters/http/UserController'
7import { UserPresenter } from '../adapters/http/presenters/UserPresenter'
8
9export class Container {
10 private static instance: Container
11 private services: Map<string, any> = new Map()
12
13 static getInstance(): Container {
14 if (!Container.instance) {
15 Container.instance = new Container()
16 }
17 return Container.instance
18 }
19
20 registerService(key: string, factory: () => any): void {
21 this.services.set(key, factory)
22 }
23
24 getService(key: string): any {
25 const factory = this.services.get(key)
26 if (!factory) {
27 throw new Error(`Service ${key} not found`)
28 }
29 return factory()
30 }
31
32 setupDefaultServices(db: Kysely<any>): void {
33 // Repositories
34 this.registerService('userRepository', () => new UserRepository(db))
35
36 // Use Cases
37 this.registerService(
38 'createUserUseCase',
39 () => new CreateUserUseCase(this.getService('userRepository'))
40 )
41 this.registerService(
42 'getUserUseCase',
43 () => new GetUserUseCase(this.getService('userRepository'))
44 )
45
46 // Presenters
47 this.registerService('userPresenter', () => new UserPresenter())
48
49 // Controllers
50 this.registerService(
51 'userController',
52 () =>
53 new UserController(
54 this.getService('createUserUseCase'),
55 this.getService('getUserUseCase'),
56 this.getService('userPresenter')
57 )
58 )
59 }
60}
Best Practices
1. Layer Separation
- Keep business logic in domain and use cases
- Controllers should be thin and delegate to use cases
- Don't leak framework details into inner layers
2. Dependency Direction
- Always point dependencies inward
- Use dependency injection for external dependencies
- Define interfaces at the layer that needs them
3. Testing
- Domain entities are easily testable (no dependencies)
- Use case tests mock repositories
- Integration tests verify adapter/framework layer
4. Module Organization
- Group by business domain, not technical layer
- Keep related entities and use cases together
- Each module should have clear responsibility
5. Error Handling
- Define domain-specific exceptions
- Let use cases throw application exceptions
- Controllers translate to HTTP responses
Common Patterns
Request/Response Objects
Use DTOs to decouple between layers:
typescript
1// Request and Response are independent of HTTP framework
2interface CreateUserRequest {
3 email: string
4 username: string
5 password: string
6}
7
8interface CreateUserResponse {
9 id: string
10 email: string
11 username: string
12}
Entity Validation
typescript
1export class User {
2 static create(email: string, username: string, passwordHash: string): User | Error {
3 if (!this.isValidEmail(email)) {
4 return new Error('Invalid email')
5 }
6 return new User(generateId(), email, username, passwordHash, new Date(), new Date())
7 }
8}
Use Case Composition
typescript
1export class UpdateUserAndNotifyUseCase {
2 constructor(
3 private updateUserUseCase: UpdateUserUseCase,
4 private notifyUserUseCase: NotifyUserUseCase
5 ) {}
6
7 async execute(request: any) {
8 const updateResponse = await this.updateUserUseCase.execute(request)
9 await this.notifyUserUseCase.execute(updateResponse.id)
10 return updateResponse
11 }
12}