article

Clean Architecture - โครงสร้างซอฟต์แวร์ที่แข็งแกร่ง

13 min read

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 ช่วยให้เราสร้างซอฟต์แวร์ที่:

  1. Maintainable - ดูแลรักษาได้ง่าย
  2. Testable - ทดสอบได้ครบถ้วน
  3. Flexible - ยืดหยุ่น เปลี่ยนแปลงได้ง่าย
  4. Independent - ไม่ผูกติดกับ Framework หรือ Database
  5. Scalable - ขยายได้ตามความต้องการ

การปฏิบัติตาม Dependency Rule และการแยก Business Logic ออกจาก Infrastructure เป็นกุญแจสำคัญในการสร้างระบบที่แข็งแกร่งและยั่งยืน!

จำไว้ว่า Clean Architecture ไม่ใช่ Silver Bullet แต่เป็นเครื่องมือที่ช่วยจัดระเบียบโค้ดให้มีโครงสร้างที่ดี สามารถนำไปปรับใช้ได้ตามความเหมาะสมของแต่ละโปรเจคครับ!