Clean Architecture คืออะไร?
Clean Architecture เป็นแนวคิดการออกแบบซอฟต์แวร์ที่เสนอโดย Robert C. Martin (Uncle Bob) มุ่งเน้นการสร้างระบบที่:
- Independent of Frameworks - ไม่ผูกติดกับ Framework
- Testable - ทดสอบได้ง่ายโดยไม่ต้องพึ่งพา External Dependencies
- Independent of UI - Business Logic ไม่ผูกติดกับ UI
- Independent of Database - ไม่ผูกติดกับ Database เฉพาะตัว
- Independent of External Systems - ไม่ผูกติดกับ External Services
หลักการ Dependency Rule
กฎสำคัญที่สุดคือ Dependencies ต้องชี้เข้าสู่ศูนย์กลางเท่านั้น
graph TB
A[External Interfaces] --> B[Interface Adapters]
B --> C[Application Business Rules]
C --> D[Enterprise Business Rules]
subgraph "Outer Layer"
A
end
subgraph "Adapter Layer"
B
end
subgraph "Use Case Layer"
C
end
subgraph "Domain Layer"
D
end
โครงสร้าง Clean Architecture
1. Domain Layer (Enterprise Business Rules)
// Domain/Entities/User.js
class User {
constructor(id, email, password, profile) {
this.id = id;
this.email = email;
this.password = password;
this.profile = profile;
this.createdAt = new Date();
this.isActive = true;
}
// Business Rules
validateEmail() {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(this.email)) {
throw new Error('Invalid email format');
}
}
changePassword(newPassword, currentPassword) {
if (!this.verifyPassword(currentPassword)) {
throw new Error('Current password is incorrect');
}
if (newPassword.length < 8) {
throw new Error('Password must be at least 8 characters');
}
this.password = this.hashPassword(newPassword);
}
deactivate() {
if (!this.isActive) {
throw new Error('User is already inactive');
}
this.isActive = false;
}
verifyPassword(password) {
// Hash verification logic
return bcrypt.compare(password, this.password);
}
hashPassword(password) {
return bcrypt.hash(password, 10);
}
}
// Domain/ValueObjects/Email.js
class Email {
constructor(value) {
if (!this.isValid(value)) {
throw new Error('Invalid email address');
}
this.value = value;
}
isValid(email) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
toString() {
return this.value;
}
equals(other) {
return other instanceof Email && this.value === other.value;
}
}
// Domain/Repositories/UserRepository.js (Interface)
class UserRepository {
async findById(id) {
throw new Error('Method must be implemented');
}
async findByEmail(email) {
throw new Error('Method must be implemented');
}
async save(user) {
throw new Error('Method must be implemented');
}
async delete(id) {
throw new Error('Method must be implemented');
}
}
2. Application Layer (Use Cases)
// Application/UseCases/CreateUser.js
class CreateUserUseCase {
constructor(userRepository, emailService, passwordEncoder) {
this.userRepository = userRepository;
this.emailService = emailService;
this.passwordEncoder = passwordEncoder;
}
async execute(request) {
// Validate input
this.validateRequest(request);
// Check if user already exists
const existingUser = await this.userRepository.findByEmail(request.email);
if (existingUser) {
throw new Error('User already exists with this email');
}
// Create user entity
const hashedPassword = await this.passwordEncoder.encode(request.password);
const user = new User(
this.generateId(),
new Email(request.email),
hashedPassword,
request.profile
);
// Save user
await this.userRepository.save(user);
// Send welcome email
await this.emailService.sendWelcomeEmail(user.email, user.profile.firstName);
return {
id: user.id,
email: user.email.value,
createdAt: user.createdAt
};
}
validateRequest(request) {
if (!request.email) {
throw new Error('Email is required');
}
if (!request.password) {
throw new Error('Password is required');
}
if (!request.profile?.firstName) {
throw new Error('First name is required');
}
}
generateId() {
return require('uuid').v4();
}
}
// Application/UseCases/GetUser.js
class GetUserUseCase {
constructor(userRepository) {
this.userRepository = userRepository;
}
async execute(userId) {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new Error('User not found');
}
if (!user.isActive) {
throw new Error('User account is deactivated');
}
return {
id: user.id,
email: user.email.value,
profile: user.profile,
createdAt: user.createdAt
};
}
}
// Application/Services/EmailService.js (Interface)
class EmailService {
async sendWelcomeEmail(email, firstName) {
throw new Error('Method must be implemented');
}
async sendPasswordResetEmail(email, resetToken) {
throw new Error('Method must be implemented');
}
}
3. Interface Adapters Layer
// Infrastructure/Repositories/MongoUserRepository.js
const User = require('../../Domain/Entities/User');
const UserRepository = require('../../Domain/Repositories/UserRepository');
class MongoUserRepository extends UserRepository {
constructor(mongoClient) {
super();
this.db = mongoClient.db('app');
this.collection = this.db.collection('users');
}
async findById(id) {
const doc = await this.collection.findOne({ _id: id });
return doc ? this.mapToDomain(doc) : null;
}
async findByEmail(email) {
const emailValue = typeof email === 'string' ? email : email.value;
const doc = await this.collection.findOne({ email: emailValue });
return doc ? this.mapToDomain(doc) : null;
}
async save(user) {
const doc = this.mapToDocument(user);
await this.collection.replaceOne(
{ _id: user.id },
doc,
{ upsert: true }
);
}
async delete(id) {
await this.collection.deleteOne({ _id: id });
}
mapToDomain(doc) {
const user = new User(
doc._id,
doc.email,
doc.password,
doc.profile
);
user.isActive = doc.isActive;
user.createdAt = doc.createdAt;
return user;
}
mapToDocument(user) {
return {
_id: user.id,
email: typeof user.email === 'string' ? user.email : user.email.value,
password: user.password,
profile: user.profile,
isActive: user.isActive,
createdAt: user.createdAt
};
}
}
// Infrastructure/Services/SendGridEmailService.js
const EmailService = require('../../Application/Services/EmailService');
class SendGridEmailService extends EmailService {
constructor(sendGridClient) {
super();
this.client = sendGridClient;
}
async sendWelcomeEmail(email, firstName) {
const msg = {
to: email,
from: 'noreply@example.com',
subject: 'Welcome!',
html: `<h1>Welcome ${firstName}!</h1><p>Thanks for joining us.</p>`
};
await this.client.send(msg);
}
async sendPasswordResetEmail(email, resetToken) {
const resetLink = `https://example.com/reset-password?token=${resetToken}`;
const msg = {
to: email,
from: 'noreply@example.com',
subject: 'Password Reset',
html: `<p>Click <a href="${resetLink}">here</a> to reset your password.</p>`
};
await this.client.send(msg);
}
}
// Infrastructure/Encoders/BcryptPasswordEncoder.js
const bcrypt = require('bcrypt');
class BcryptPasswordEncoder {
async encode(plaintext) {
return bcrypt.hash(plaintext, 10);
}
async matches(plaintext, hash) {
return bcrypt.compare(plaintext, hash);
}
}
4. Frameworks & Drivers Layer
// Web/Controllers/UserController.js
class UserController {
constructor(createUserUseCase, getUserUseCase) {
this.createUserUseCase = createUserUseCase;
this.getUserUseCase = getUserUseCase;
}
async createUser(req, res) {
try {
const result = await this.createUserUseCase.execute({
email: req.body.email,
password: req.body.password,
profile: {
firstName: req.body.firstName,
lastName: req.body.lastName
}
});
res.status(201).json({
success: true,
data: result
});
} catch (error) {
res.status(400).json({
success: false,
error: error.message
});
}
}
async getUser(req, res) {
try {
const user = await this.getUserUseCase.execute(req.params.id);
res.json({
success: true,
data: user
});
} catch (error) {
const statusCode = error.message === 'User not found' ? 404 : 400;
res.status(statusCode).json({
success: false,
error: error.message
});
}
}
}
// Web/Routes/userRoutes.js
const express = require('express');
const router = express.Router();
function createUserRoutes(userController) {
router.post('/users', (req, res) => userController.createUser(req, res));
router.get('/users/:id', (req, res) => userController.getUser(req, res));
return router;
}
module.exports = createUserRoutes;
// Main/DependencyInjection/Container.js
class DIContainer {
constructor() {
this.services = new Map();
}
register(name, factory) {
this.services.set(name, factory);
}
get(name) {
const factory = this.services.get(name);
if (!factory) {
throw new Error(`Service ${name} not found`);
}
return factory(this);
}
}
// Main/config/dependencies.js
const { MongoClient } = require('mongodb');
const sgMail = require('@sendgrid/mail');
function setupDependencies() {
const container = new DIContainer();
// Infrastructure
container.register('mongoClient', () => {
return new MongoClient(process.env.MONGODB_URI);
});
container.register('sendGridClient', () => {
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
return sgMail;
});
// Repositories
container.register('userRepository', (c) => {
return new MongoUserRepository(c.get('mongoClient'));
});
// Services
container.register('emailService', (c) => {
return new SendGridEmailService(c.get('sendGridClient'));
});
container.register('passwordEncoder', () => {
return new BcryptPasswordEncoder();
});
// Use Cases
container.register('createUserUseCase', (c) => {
return new CreateUserUseCase(
c.get('userRepository'),
c.get('emailService'),
c.get('passwordEncoder')
);
});
container.register('getUserUseCase', (c) => {
return new GetUserUseCase(c.get('userRepository'));
});
// Controllers
container.register('userController', (c) => {
return new UserController(
c.get('createUserUseCase'),
c.get('getUserUseCase')
);
});
return container;
}
module.exports = setupDependencies;
Project Structure
src/
├── Domain/
│ ├── Entities/
│ │ ├── User.js
│ │ ├── Order.js
│ │ └── Product.js
│ ├── ValueObjects/
│ │ ├── Email.js
│ │ ├── Money.js
│ │ └── Address.js
│ ├── Repositories/
│ │ ├── UserRepository.js
│ │ └── OrderRepository.js
│ └── Services/
│ └── DomainService.js
│
├── Application/
│ ├── UseCases/
│ │ ├── User/
│ │ │ ├── CreateUser.js
│ │ │ ├── GetUser.js
│ │ │ └── UpdateUser.js
│ │ └── Order/
│ │ ├── CreateOrder.js
│ │ └── ProcessOrder.js
│ ├── Services/
│ │ ├── EmailService.js
│ │ └── PaymentService.js
│ └── DTOs/
│ ├── CreateUserRequest.js
│ └── UserResponse.js
│
├── Infrastructure/
│ ├── Repositories/
│ │ ├── MongoUserRepository.js
│ │ └── PostgresOrderRepository.js
│ ├── Services/
│ │ ├── SendGridEmailService.js
│ │ └── StripePaymentService.js
│ └── Adapters/
│ ├── HttpClient.js
│ └── FileStorage.js
│
├── Web/
│ ├── Controllers/
│ │ ├── UserController.js
│ │ └── OrderController.js
│ ├── Routes/
│ │ ├── userRoutes.js
│ │ └── orderRoutes.js
│ ├── Middleware/
│ │ ├── authMiddleware.js
│ │ └── validationMiddleware.js
│ └── Validators/
│ ├── userValidation.js
│ └── orderValidation.js
│
└── Main/
├── config/
│ ├── database.js
│ ├── dependencies.js
│ └── environment.js
├── server.js
└── app.js
Testing Strategy
1. Unit Tests (Domain & Application)
// Tests/Domain/Entities/User.test.js
const User = require('../../../src/Domain/Entities/User');
const Email = require('../../../src/Domain/ValueObjects/Email');
describe('User Entity', () => {
describe('validateEmail', () => {
it('should throw error for invalid email format', () => {
const user = new User('1', 'invalid-email', 'password', {});
expect(() => user.validateEmail()).toThrow('Invalid email format');
});
it('should pass for valid email format', () => {
const user = new User('1', 'test@example.com', 'password', {});
expect(() => user.validateEmail()).not.toThrow();
});
});
describe('changePassword', () => {
it('should change password when current password is correct', async () => {
const user = new User('1', 'test@example.com', 'hashedCurrentPassword', {});
user.verifyPassword = jest.fn().mockReturnValue(true);
user.hashPassword = jest.fn().mockReturnValue('hashedNewPassword');
await user.changePassword('newPassword123', 'currentPassword');
expect(user.password).toBe('hashedNewPassword');
expect(user.verifyPassword).toHaveBeenCalledWith('currentPassword');
});
it('should throw error when current password is incorrect', () => {
const user = new User('1', 'test@example.com', 'hashedPassword', {});
user.verifyPassword = jest.fn().mockReturnValue(false);
expect(() =>
user.changePassword('newPassword', 'wrongPassword')
).toThrow('Current password is incorrect');
});
});
});
// Tests/Application/UseCases/CreateUser.test.js
const CreateUserUseCase = require('../../../src/Application/UseCases/CreateUser');
describe('CreateUserUseCase', () => {
let createUserUseCase;
let mockUserRepository;
let mockEmailService;
let mockPasswordEncoder;
beforeEach(() => {
mockUserRepository = {
findByEmail: jest.fn(),
save: jest.fn()
};
mockEmailService = {
sendWelcomeEmail: jest.fn()
};
mockPasswordEncoder = {
encode: jest.fn().mockResolvedValue('hashedPassword')
};
createUserUseCase = new CreateUserUseCase(
mockUserRepository,
mockEmailService,
mockPasswordEncoder
);
});
it('should create user successfully', async () => {
mockUserRepository.findByEmail.mockResolvedValue(null);
const request = {
email: 'test@example.com',
password: 'password123',
profile: { firstName: 'John', lastName: 'Doe' }
};
const result = await createUserUseCase.execute(request);
expect(mockUserRepository.save).toHaveBeenCalled();
expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith(
'test@example.com',
'John'
);
expect(result.email).toBe('test@example.com');
});
it('should throw error when user already exists', async () => {
mockUserRepository.findByEmail.mockResolvedValue({ id: '1' });
const request = {
email: 'existing@example.com',
password: 'password123',
profile: { firstName: 'John' }
};
await expect(createUserUseCase.execute(request))
.rejects.toThrow('User already exists with this email');
});
});
2. Integration Tests
// Tests/Integration/UserRepository.test.js
const { MongoMemoryServer } = require('mongodb-memory-server');
const { MongoClient } = require('mongodb');
const MongoUserRepository = require('../../src/Infrastructure/Repositories/MongoUserRepository');
const User = require('../../src/Domain/Entities/User');
describe('MongoUserRepository Integration', () => {
let mongoServer;
let client;
let repository;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const uri = mongoServer.getUri();
client = new MongoClient(uri);
await client.connect();
repository = new MongoUserRepository(client);
});
afterAll(async () => {
await client.close();
await mongoServer.stop();
});
beforeEach(async () => {
await client.db('app').collection('users').deleteMany({});
});
it('should save and retrieve user', async () => {
const user = new User('1', 'test@example.com', 'password', {
firstName: 'John',
lastName: 'Doe'
});
await repository.save(user);
const retrieved = await repository.findById('1');
expect(retrieved.id).toBe('1');
expect(retrieved.email).toBe('test@example.com');
expect(retrieved.profile.firstName).toBe('John');
});
});
3. End-to-End Tests
// Tests/E2E/user.e2e.test.js
const request = require('supertest');
const app = require('../../src/Main/app');
describe('User API E2E', () => {
beforeEach(async () => {
// Setup test database
await setupTestDatabase();
});
afterEach(async () => {
// Cleanup test database
await cleanupTestDatabase();
});
it('should create user and return user data', async () => {
const userData = {
email: 'test@example.com',
password: 'password123',
firstName: 'John',
lastName: 'Doe'
};
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(201);
expect(response.body.success).toBe(true);
expect(response.body.data.email).toBe('test@example.com');
expect(response.body.data.id).toBeDefined();
});
it('should get user by id', async () => {
// Create user first
const createResponse = await request(app)
.post('/api/users')
.send({
email: 'test@example.com',
password: 'password123',
firstName: 'John',
lastName: 'Doe'
});
const userId = createResponse.body.data.id;
// Get user
const response = await request(app)
.get(`/api/users/${userId}`)
.expect(200);
expect(response.body.data.email).toBe('test@example.com');
expect(response.body.data.profile.firstName).toBe('John');
});
});
Advanced Patterns
1. CQRS Implementation
// Application/Commands/CreateUserCommand.js
class CreateUserCommand {
constructor(email, password, firstName, lastName) {
this.email = email;
this.password = password;
this.firstName = firstName;
this.lastName = lastName;
}
}
// Application/Queries/GetUserQuery.js
class GetUserQuery {
constructor(userId) {
this.userId = userId;
}
}
// Application/Handlers/CommandHandler.js
class CreateUserCommandHandler {
constructor(userRepository, eventBus) {
this.userRepository = userRepository;
this.eventBus = eventBus;
}
async handle(command) {
// Validate command
this.validate(command);
// Create user
const user = User.create(command);
await this.userRepository.save(user);
// Publish domain events
const events = user.getUncommittedEvents();
for (const event of events) {
await this.eventBus.publish(event);
}
user.markEventsAsCommitted();
}
}
// Application/Handlers/QueryHandler.js
class GetUserQueryHandler {
constructor(userReadRepository) {
this.userReadRepository = userReadRepository;
}
async handle(query) {
return await this.userReadRepository.findById(query.userId);
}
}
2. Domain Events
// Domain/Events/UserCreated.js
class UserCreated {
constructor(userId, email, firstName) {
this.eventId = require('uuid').v4();
this.eventType = 'UserCreated';
this.aggregateId = userId;
this.data = { userId, email, firstName };
this.occurredOn = new Date();
}
}
// Domain/Entities/User.js (Enhanced)
class User {
constructor(id, email, password, profile) {
this.id = id;
this.email = email;
this.password = password;
this.profile = profile;
this.uncommittedEvents = [];
}
static create(email, password, profile) {
const id = require('uuid').v4();
const user = new User(id, email, password, profile);
user.addEvent(new UserCreated(id, email, profile.firstName));
return user;
}
addEvent(event) {
this.uncommittedEvents.push(event);
}
getUncommittedEvents() {
return this.uncommittedEvents;
}
markEventsAsCommitted() {
this.uncommittedEvents = [];
}
}
3. Specification Pattern
// Domain/Specifications/UserSpecification.js
class Specification {
isSatisfiedBy(candidate) {
throw new Error('Must be implemented');
}
and(other) {
return new AndSpecification(this, other);
}
or(other) {
return new OrSpecification(this, other);
}
not() {
return new NotSpecification(this);
}
}
class ActiveUserSpecification extends Specification {
isSatisfiedBy(user) {
return user.isActive === true;
}
}
class AdultUserSpecification extends Specification {
isSatisfiedBy(user) {
const age = this.calculateAge(user.birthDate);
return age >= 18;
}
calculateAge(birthDate) {
return Math.floor((new Date() - birthDate) / (365.25 * 24 * 60 * 60 * 1000));
}
}
class PremiumUserSpecification extends Specification {
isSatisfiedBy(user) {
return user.subscriptionType === 'premium';
}
}
// การใช้งาน
const eligibleForPromotionSpec = new ActiveUserSpecification()
.and(new AdultUserSpecification())
.and(new PremiumUserSpecification());
const users = await userRepository.findAll();
const eligibleUsers = users.filter(user =>
eligibleForPromotionSpec.isSatisfiedBy(user)
);
Best Practices
1. Dependency Injection
// ใช้ Constructor Injection
class OrderService {
constructor(orderRepository, paymentService, emailService) {
this.orderRepository = orderRepository;
this.paymentService = paymentService;
this.emailService = emailService;
}
}
// ❌ หลีกเลี่ยง Service Locator
class BadOrderService {
processOrder(order) {
const paymentService = ServiceLocator.get('paymentService'); // ไม่ดี
// ...
}
}
2. Interface Segregation
// ❌ Fat Interface
class BadUserService {
createUser() { }
updateUser() { }
deleteUser() { }
sendEmail() { }
processPayment() { }
generateReport() { }
}
// ✅ Segregated Interfaces
class UserManagementService {
createUser() { }
updateUser() { }
deleteUser() { }
}
class EmailService {
sendWelcomeEmail() { }
sendPasswordResetEmail() { }
}
class PaymentService {
processPayment() { }
refundPayment() { }
}
3. Error Handling
// Domain/Exceptions/DomainException.js
class DomainException extends Error {
constructor(message, code = null) {
super(message);
this.name = this.constructor.name;
this.code = code;
}
}
class UserNotFoundException extends DomainException {
constructor(userId) {
super(`User with ID ${userId} not found`, 'USER_NOT_FOUND');
}
}
class InvalidEmailException extends DomainException {
constructor(email) {
super(`Invalid email address: ${email}`, 'INVALID_EMAIL');
}
}
// Application Layer
class GetUserUseCase {
async execute(userId) {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new UserNotFoundException(userId);
}
return user;
}
}
// Web Layer
class UserController {
async getUser(req, res) {
try {
const user = await this.getUserUseCase.execute(req.params.id);
res.json({ data: user });
} catch (error) {
if (error instanceof UserNotFoundException) {
return res.status(404).json({ error: error.message });
}
if (error instanceof DomainException) {
return res.status(400).json({ error: error.message });
}
// Unexpected error
console.error(error);
res.status(500).json({ error: 'Internal server error' });
}
}
}
สรุป
Clean Architecture ช่วยให้เราสร้างซอฟต์แวร์ที่:
- Maintainable - ดูแลรักษาได้ง่าย
- Testable - ทดสอบได้ครบถ้วน
- Flexible - ยืดหยุ่น เปลี่ยนแปลงได้ง่าย
- Independent - ไม่ผูกติดกับ Framework หรือ Database
- Scalable - ขยายได้ตามความต้องการ
การปฏิบัติตาม Dependency Rule และการแยก Business Logic ออกจาก Infrastructure เป็นกุญแจสำคัญในการสร้างระบบที่แข็งแกร่งและยั่งยืน!
จำไว้ว่า Clean Architecture ไม่ใช่ Silver Bullet แต่เป็นเครื่องมือที่ช่วยจัดระเบียบโค้ดให้มีโครงสร้างที่ดี สามารถนำไปปรับใช้ได้ตามความเหมาะสมของแต่ละโปรเจคครับ!