0

Interface Contracts Tập 2: "Mở khóa" SOLID với Dependency Inversion.

Một trong những nỗi đau lớn nhất của lập trình viên là "Code dính chặt vào nhau" (Tight Coupling). UI dính vào Business Logic, Business Logic dính chặt vào Database. Sếp yêu cầu đổi Database từ MySQL sang MongoDB, và bạn phải đập đi viết lại toàn bộ hệ thống.

Để giải quyết vấn đề này, chúng ta sử dụng chữ D trong nguyên lý SOLID: Dependency Inversion Principle (DIP - Nguyên lý đảo ngược sự phụ thuộc).

Nguyên lý này phát biểu hai ý:

  1. Các module cấp cao (Business Logic) không được phụ thuộc vào các module cấp thấp (Database, UI). Cả hai phải phụ thuộc vào Abstractions (Interface Contracts).
  2. Abstractions (Interface) không được phụ thuộc vào chi tiết. Chi tiết phải phụ thuộc vào Abstractions.

Hãy cùng xem tư duy Contract giải quyết bài toán đổi Database mượt mà như thế nào bằng TypeScript.


1. Vấn đề: Code bị dính chặt (Tight Coupling)

Đây là cách những lập trình viên mới thường viết code:

// Tồi: UserService phụ thuộc trực tiếp vào MySQL
import { MySQLDatabase } from './database/MySQLDatabase';

export class UserService {
    private db: MySQLDatabase;

    constructor() {
        // Chết dở! UserService tự khởi tạo MySQL. 
        // Nó bị "trói buộc" hoàn toàn vào MySQL.
        this.db = new MySQLDatabase(); 
    }

    public registerUser(name: string, email: string): void {
        // Logic nghiệp vụ...
        this.db.insertUser(name, email);
    }
}

Lỗi chí mạng: Khi muốn dùng MongoDB, bạn phải vào tận class UserService (nơi chứa logic kinh doanh cốt lõi) để sửa code. Điều này vi phạm nguyên tắc Open/Closed (Hạn chế sửa code cũ, chỉ nên viết thêm code mới).


2. Giải pháp: Đảo ngược sự phụ thuộc bằng Interface Contract

Chúng ta sẽ chèn một "Bản hợp đồng" vào giữa UserService và Database.

Bước 2.1: Ký kết hợp đồng (Định nghĩa Interface)

Tầng Business Logic sẽ đưa ra yêu cầu: "Tôi không quan tâm anh lưu dữ liệu bằng cái gì, tôi chỉ cần anh cam kết có hàm save nhận vào một user."

// IUserRepository.ts (Bản hợp đồng)
export interface User {
    name: string;
    email: string;
}

export interface IUserRepository {
    save(user: User): void;
    findByEmail(email: string): User | null;
}

Bước 2.2: Các bên cung cấp ký hợp đồng (Implementation)

Bây giờ, đội ngũ làm Database có thể viết bao nhiêu loại Database tùy thích, miễn là tuân thủ IUserRepository.

// MySQLUserRepository.ts
import { IUserRepository, User } from './IUserRepository';

export class MySQLUserRepository implements IUserRepository {
    public save(user: User): void {
        console.log(`[MySQL] INSERT INTO users (name, email) VALUES ('${user.name}', '${user.email}')`);
    }

    public findByEmail(email: string): User | null {
        console.log(`[MySQL] SELECT * FROM users WHERE email = '${email}'`);
        return null;
    }
}
// MongoUserRepository.ts
import { IUserRepository, User } from './IUserRepository';

export class MongoUserRepository implements IUserRepository {
    public save(user: User): void {
        console.log(`[MongoDB] db.users.insertOne({ name: '${user.name}', email: '${user.email}' })`);
    }

    public findByEmail(email: string): User | null {
        console.log(`[MongoDB] db.users.findOne({ email: '${email}' })`);
        return null;
    }
}

Bước 2.3: Module cấp cao sử dụng Hợp đồng (Dependency Injection)

UserService bây giờ không tự tạo Database nữa. Nó yêu cầu ai đó (như hàm main) truyền (inject) một class đã ký hợp đồng IUserRepository vào cho nó.

// UserService.ts
import { IUserRepository, User } from './IUserRepository';

export class UserService {
    // Tiêm (Inject) Interface vào qua Constructor
    constructor(private readonly userRepository: IUserRepository) {}

    public registerUser(name: string, email: string): void {
        // 1. Logic nghiệp vụ (Domain Logic)
        if (!email.includes('@')) {
            throw new Error("Email không hợp lệ!");
        }

        const existingUser = this.userRepository.findByEmail(email);
        if (existingUser) {
            throw new Error("Email đã tồn tại!");
        }

        // 2. Tương tác dữ liệu qua Hợp đồng, không quan tâm là SQL hay NoSQL
        const newUser: User = { name, email };
        this.userRepository.save(newUser);
        
        console.log("Đăng ký người dùng thành công!");
    }
}

3. Lắp ráp hệ thống thực tế (Tại điểm khởi chạy ứng dụng)

Sức mạnh thực sự của Interface Contract thể hiện ở hàm main (hoặc file cấu hình DI Container trong các framework như NestJS, Spring Boot).

// index.ts
import { UserService } from './UserService';
import { MySQLUserRepository } from './MySQLUserRepository';
import { MongoUserRepository } from './MongoUserRepository';

// Ngày 1: Công ty dùng MySQL
console.log("--- CHẠY VỚI MYSQL ---");
const mysqlRepo = new MySQLUserRepository();
const userServiceWithSQL = new UserService(mysqlRepo);
userServiceWithSQL.registerUser("Alice", "alice@example.com");

// Ngày 100: Công ty quyết định chuyển sang MongoDB
// Nhìn xem! Chúng ta KHÔNG sửa DÙ CHỈ 1 DÒNG CODE trong UserService
console.log("\n--- CHẠY VỚI MONGODB ---");
const mongoRepo = new MongoUserRepository();
const userServiceWithMongo = new UserService(mongoRepo);
userServiceWithMongo.registerUser("Bob", "bob@example.com");

Tổng kết Tập 2

Nhờ tư duy thiết kế Interface Contract và nguyên lý Dependency Inversion, chúng ta đạt được 3 điều tuyệt vời:

  • Plug & Play (Cắm và Chạy): Database giống như một cái USB. UserService là cái laptop. Miễn là đầu cắm đúng chuẩn (Interface), bạn cắm USB hãng nào vào cũng chạy được.
  • Dễ test vô cùng: Khi viết Unit Test cho UserService, bạn không cần bật MySQL lên. Bạn chỉ cần tạo một MockUserRepository implements IUserRepository lưu dữ liệu vào RAM là xong.
  • Mở rộng an toàn: Có database mới? Cứ tạo class mới implements Interface là xong, logic cốt lõi được an toàn tuyệt đối.

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí