Design Pattern nâng cao trong TypeScript: Thiết kế hệ thống linh hoạt và an toàn về kiểu


Tuan Duc Tran

Design Pattern không phải là những đoạn code để sao chép nguyên xi. Chúng là tên gọi cho các cách giải quyết lặp lại đối với những vấn đề thiết kế quen thuộc: tạo object theo nhiều biến thể, thay đổi thuật toán mà không sửa caller, kết nối hai interface không tương thích, hoặc điều phối nhiều hành động thành một quy trình có thể hoàn tác.

Trong TypeScript, pattern không chỉ được thể hiện bằng class và interface. Generic, union type, type guard, function type, mapped type và module boundary cũng là những công cụ thiết kế quan trọng. Một pattern tốt tận dụng hệ thống kiểu để làm cho cách sử dụng đúng trở nên tự nhiên và cách sử dụng sai trở nên khó xảy ra.

Nguyên tắc quan trọng: Đừng áp dụng pattern vì tên của nó nghe có vẻ chuyên nghiệp. Hãy bắt đầu từ một điểm biến đổi cụ thể, mô hình hóa contract nhỏ nhất có thể và chỉ thêm abstraction khi nó làm giảm coupling hoặc bảo vệ một invariant có thật.

1. Cách đọc Design Pattern trong TypeScript

Mỗi pattern nên được đánh giá qua bốn câu hỏi. Vấn đề thiết kế nào đang lặp lại? Phần nào của hệ thống có khả năng thay đổi? Contract nào cần được bảo vệ? TypeScript có thể giúp compiler phát hiện cách dùng sai ở đâu?

Góc nhìnCâu hỏi thực tếDấu hiệu pattern có giá trị
Biến đổiĐiều gì thường xuyên thay đổi?Thuật toán, provider, format hoặc trạng thái được tách khỏi caller.
ContractCaller cần biết điều gì?Interface hoặc function type nhỏ mô tả đúng năng lực cần dùng.
RuntimeĐiều gì chỉ biết được khi chạy?Dữ liệu ngoài hệ thống được validate tại boundary.
Chi phíAbstraction tạo thêm phức tạp nào?Số dependency, class và generic tăng có lý do rõ ràng.

TypeScript có structural typing. Một object được xem là phù hợp với interface nếu nó có đủ thuộc tính và method tương thích, ngay cả khi class của nó không khai báo implements. Điều này làm Adapter, Strategy và Dependency Injection linh hoạt hơn, nhưng cũng đòi hỏi contract phải đủ nhỏ để không vô tình chấp nhận một object chỉ “trông có vẻ đúng”.

2. Strategy Pattern: thay đổi thuật toán mà không đổi use case

Strategy đóng gói một nhóm thuật toán cùng mục đích sau một interface chung. Caller phụ thuộc vào strategy, không phụ thuộc vào từng cách tính cụ thể.

interface ShippingQuote {
  readonly carrier: string;
  readonly amountInCents: number;
}
 
interface ShippingStrategy {
  quote(order: Order): Promise<ShippingQuote>;
}
 
class StandardShipping implements ShippingStrategy {
  async quote(order: Order): Promise<ShippingQuote> {
    return {
      carrier: "standard",
      amountInCents: order.totalInCents >= 5_000_000 ? 0 : 30_000,
    };
  }
}
 
class ExpressShipping implements ShippingStrategy {
  async quote(order: Order): Promise<ShippingQuote> {
    return {
      carrier: "express",
      amountInCents: 90_000,
    };
  }
}
 
class ShippingService {
  constructor(private readonly strategy: ShippingStrategy) {}
 
  quote(order: Order): Promise<ShippingQuote> {
    return this.strategy.quote(order);
  }
}

Strategy phù hợp khi có nhiều thuật toán cùng một output contract và việc chọn thuật toán có thể thay đổi theo cấu hình, tenant hoặc ngữ cảnh. Nếu chỉ có một nhánh đơn giản không có khả năng mở rộng, một hàm thuần hoặc một union có thể dễ đọc hơn.

Strategy dạng function

Trong TypeScript, không nhất thiết phải tạo class cho mọi strategy. Function type thường là lựa chọn nhẹ hơn khi strategy không có state hoặc lifecycle riêng.

type DiscountRule = (subtotalInCents: number) => number;
 
const noDiscount: DiscountRule = (subtotal) => subtotal;
const memberDiscount: DiscountRule = (subtotal) =>
  Math.round(subtotal * 0.9);
 
function calculateTotal(
  subtotalInCents: number,
  rule: DiscountRule,
): number {
  return rule(subtotalInCents);
}

Khi Strategy trở thành conditional explosion

Nếu caller phải chọn strategy bằng hàng chục if hoặc switch, vấn đề không nằm ở Strategy mà ở nơi đăng ký strategy. Hãy dùng registry có key được giới hạn bằng literal union.

type ShippingMethod = "standard" | "express" | "pickup";
 
const strategies = {
  standard: new StandardShipping(),
  express: new ExpressShipping(),
  pickup: new PickupShipping(),
} satisfies Record<ShippingMethod, ShippingStrategy>;
 
function getShippingStrategy(method: ShippingMethod): ShippingStrategy {
  return strategies[method];
}

satisfies kiểm tra object có tuân thủ contract nhưng vẫn giữ inference cụ thể của các property. Nó không phải runtime validation và không thay thế việc kiểm tra dữ liệu đến từ HTTP hoặc JSON.

3. Factory Pattern: tạo object qua một boundary có kiểm soát

Factory hữu ích khi việc khởi tạo cần chọn implementation, kiểm tra điều kiện hoặc kết hợp nhiều dependency. Factory không nên chỉ bọc một lệnh new mà không tạo thêm quyết định nào.

type PaymentMethod = "card" | "bank-transfer";
 
interface PaymentGateway {
  charge(input: ChargeInput): Promise<ChargeResult>;
}
 
class CardGateway implements PaymentGateway {
  async charge(input: ChargeInput): Promise<ChargeResult> {
    return { approved: true, transactionId: `card-${input.orderId}` };
  }
}
 
class BankTransferGateway implements PaymentGateway {
  async charge(input: ChargeInput): Promise<ChargeResult> {
    return { approved: true, transactionId: `bank-${input.orderId}` };
  }
}
 
function createPaymentGateway(method: PaymentMethod): PaymentGateway {
  switch (method) {
    case "card":
      return new CardGateway();
    case "bank-transfer":
      return new BankTransferGateway();
  }
}

Khi factory có nhiều dependency, hãy truyền chúng từ Composition Root thay vì để factory tự tạo toàn bộ object graph. Điều này giúp test thay thế dependency dễ hơn và tránh việc logic nghiệp vụ biết chi tiết hạ tầng.

Generic factory cho class constructor

TypeScript có thể mô tả constructor bằng construct signature. Pattern này hữu ích khi một service cần tạo nhiều entity có cùng quy ước constructor.

type Constructor<T> = new (...args: never[]) => T;
 
function create<T>(ClassRef: Constructor<T>): T {
  return new ClassRef();
}

Nếu constructor nhận tham số, hãy mô hình hóa tuple argument thay vì nới thành any[].

type ConstructorWithArgs<T, TArgs extends readonly unknown[]> =
  new (...args: TArgs) => T;
 
function instantiate<T, TArgs extends readonly unknown[]>(
  ClassRef: ConstructorWithArgs<T, TArgs>,
  ...args: TArgs
): T {
  return new ClassRef(...args);
}

Đừng làm generic factory quá trừu tượng. Một function với ba hoặc bốn type parameter khó hiểu thường kém giá trị hơn factory cụ thể có tên miền nghiệp vụ.

4. Abstract Factory: tạo cả họ object tương thích

Abstract Factory phù hợp khi nhiều object phải được tạo thành một bộ nhất quán. Ví dụ, adapter cho PostgreSQL không nên vô tình dùng transaction của một connection khác.

interface UserRepository {
  findById(id: UserId): Promise<User | undefined>;
}
 
interface Transaction {
  commit(): Promise<void>;
  rollback(): Promise<void>;
}
 
interface PersistenceFactory {
  createUserRepository(): UserRepository;
  createTransaction(): Transaction;
}
 
class PostgresPersistenceFactory implements PersistenceFactory {
  constructor(private readonly connection: PostgresConnection) {}
 
  createUserRepository(): UserRepository {
    return new PostgresUserRepository(this.connection);
  }
 
  createTransaction(): Transaction {
    return new PostgresTransaction(this.connection);
  }
}

Abstract Factory đáng dùng khi tính nhất quán giữa các implementation là một invariant. Nếu các object không có quan hệ lifecycle hoặc không cần cùng provider, factory riêng lẻ sẽ đơn giản hơn.

5. Builder Pattern: xây object phức tạp nhưng không cho phép trạng thái sai

Builder giúp tạo object có nhiều tùy chọn mà không phải truyền một constructor dài. Builder tốt phải trả lời câu hỏi: field nào bắt buộc, field nào optional và khi nào object được xem là hoàn chỉnh?

interface QueryOptions {
  readonly filters: readonly string[];
  readonly sort?: string;
  readonly limit?: number;
}
 
class QueryBuilder {
  private readonly filters: string[] = [];
  private sortField?: string;
  private limitValue?: number;
 
  where(condition: string): this {
    this.filters.push(condition);
    return this;
  }
 
  orderBy(field: string): this {
    this.sortField = field;
    return this;
  }
 
  limit(value: number): this {
    if (!Number.isInteger(value) || value <= 0) {
      throw new Error("Limit must be a positive integer");
    }
    this.limitValue = value;
    return this;
  }
 
  build(): QueryOptions {
    return {
      filters: [...this.filters],
      sort: this.sortField,
      limit: this.limitValue,
    };
  }
}

Type-state builder

Nếu builder có các bước bắt buộc, có thể dùng type-state để build() chỉ xuất hiện sau khi các bước đó hoàn tất.

interface MissingName {
  name(value: string): HasName;
}
 
interface HasName {
  email(value: string): ReadyUser;
}
 
interface ReadyUser {
  build(): NewUser;
}
 
interface NewUser {
  readonly name: string;
  readonly email: string;
}
 
function newUser(): MissingName {
  let nameValue: string | undefined;
  return {
    name(value) {
      nameValue = value;
      return {
        email(emailValue) {
          if (!nameValue || !emailValue.includes("@")) {
            throw new Error("Invalid user");
          }
          return {
            build: () => ({ name: nameValue!, email: emailValue }),
          };
        },
      };
    },
  };
}

Type-state builder có thể bảo vệ thứ tự gọi ở compile time, nhưng syntax và số lượng type tăng nhanh. Chỉ dùng khi thứ tự bước là một phần quan trọng của contract; nếu không, factory nhận input object thường rõ hơn.

6. Adapter Pattern: bảo vệ domain khỏi SDK bên ngoài

Adapter chuyển đổi interface của một dependency thành interface mà domain mong muốn. Đây là một anti-corruption layer hữu ích quanh payment provider, HTTP client, database driver hoặc SDK có kiểu dữ liệu không phù hợp.

interface ExchangeRateProvider {
  getRate(from: Currency, to: Currency): Promise<number>;
}
 
interface ExternalRatesClient {
  latest(input: { base: string; symbols: string[] }): Promise<{
    rates: Record<string, number>;
  }>;
}
 
class RatesClientAdapter implements ExchangeRateProvider {
  constructor(private readonly client: ExternalRatesClient) {}
 
  async getRate(from: Currency, to: Currency): Promise<number> {
    const response = await this.client.latest({
      base: from,
      symbols: [to],
    });
    const rate = response.rates[to];
    if (rate === undefined) {
      throw new Error(`Missing exchange rate for ${from}/${to}`);
    }
    return rate;
  }
}

Adapter là nơi phù hợp để đổi tên field, chuẩn hóa lỗi, parse response và giới hạn API surface của SDK. Domain không nên nhận nguyên object response của provider chỉ vì nó tiện.

7. Decorator Pattern: thêm hành vi mà không sửa object lõi

Decorator bọc một object và thêm hành vi trước hoặc sau khi gọi implementation bên trong. Các ví dụ thường gặp là logging, caching, retry, metrics và authorization.

interface UserReader {
  findById(id: UserId): Promise<User | undefined>;
}
 
class LoggingUserReader implements UserReader {
  constructor(private readonly inner: UserReader) {}
 
  async findById(id: UserId): Promise<User | undefined> {
    console.info("Loading user", id);
    const result = await this.inner.findById(id);
    console.info("User loaded", Boolean(result));
    return result;
  }
}
 
class CachedUserReader implements UserReader {
  private readonly cache = new Map<UserId, User>();
 
  constructor(private readonly inner: UserReader) {}
 
  async findById(id: UserId): Promise<User | undefined> {
    const cached = this.cache.get(id);
    if (cached) return cached;
    const user = await this.inner.findById(id);
    if (user) this.cache.set(id, user);
    return user;
  }
}

Decorator chain nên có thứ tự rõ ràng. Caching trước authorization có thể tạo ra rủi ro nếu quyền truy cập phụ thuộc actor; retry quanh một operation không idempotent có thể tạo side effect lặp lại.

Decorator syntax của TypeScript

TypeScript hỗ trợ decorator syntax theo các chế độ khác nhau tùy cấu hình và runtime. Decorator syntax không nên bị nhầm với Decorator Pattern: một cái là cơ chế ngôn ngữ/metaprogramming, cái kia là cách tổ chức object. Khi dùng decorator trên class hoặc method, hãy kiểm tra target runtime, module system và framework đang sử dụng.

Trong nhiều trường hợp, decorator function hoặc object wrapper tường minh dễ debug và dễ test hơn metadata ẩn. Hãy chọn decorator syntax khi framework yêu cầu hoặc khi metadata là một phần có chủ đích của thiết kế.

8. Observer và Event Map: sự kiện có kiểu đầu-cuối

Observer cho phép một subject thông báo thay đổi cho nhiều subscriber. Bản triển khai đơn giản thường mất type safety vì event name và payload là các string rời rạc. Event map giúp liên kết tên event với payload tương ứng.

type EventMap = {
  "user.created": { readonly userId: UserId };
  "user.deleted": { readonly userId: UserId; readonly reason: string };
};
 
type EventHandler<T> = (payload: T) => void | Promise<void>;
 
class EventBus<TEvents extends Record<string, unknown>> {
  private readonly handlers = new Map<
    keyof TEvents,
    Set<EventHandler<never>>
  >();
 
  on<TKey extends keyof TEvents>(
    key: TKey,
    handler: EventHandler<TEvents[TKey]>,
  ): () => void {
    const handlers = this.handlers.get(key) ?? new Set();
    handlers.add(handler as EventHandler<never>);
    this.handlers.set(key, handlers);
    return () => handlers.delete(handler as EventHandler<never>);
  }
 
  async emit<TKey extends keyof TEvents>(
    key: TKey,
    payload: TEvents[TKey],
  ): Promise<void> {
    const handlers = this.handlers.get(key) ?? [];
    await Promise.all(
      [...handlers].map((handler) =>
        (handler as EventHandler<TEvents[TKey]>)(payload),
      ),
    );
  }
}

Type assertion ở trong implementation trên được cô lập bởi abstraction và không xuất hiện trong API của caller. Một implementation production cần quyết định rõ thứ tự handler, chính sách lỗi, retry, backpressure và cleanup subscription.

Subscriber phải có cách unsubscribe. Nếu giữ closure hoặc event listener vô thời hạn, application có thể bị memory leak. API trả về hàm cleanup giúp lifecycle trở nên rõ ràng.

9. Command Pattern: biến hành động thành object có thể điều phối

Command đóng gói một hành động thành một object có execute, thường kèm undo, metadata hoặc authorization context. Pattern phù hợp với queue, audit log, retry, transaction boundary và undo/redo.

interface Command<TResult = void> {
  execute(): Promise<TResult>;
}
 
class CreateUserCommand implements Command<UserId> {
  constructor(
    private readonly input: CreateUserInput,
    private readonly users: UserWriter,
  ) {}
 
  execute(): Promise<UserId> {
    return this.users.create(this.input);
  }
}
 
class CommandBus {
  async dispatch<TResult>(command: Command<TResult>): Promise<TResult> {
    return command.execute();
  }
}

Khi hệ thống có nhiều command, đừng để CommandBus trở thành một service locator với Map<string, unknown>. Dùng generic hoặc registry có key cụ thể để command handler giữ được quan hệ giữa command và result.

Middleware quanh Command Bus

Logging, authorization và transaction có thể được mô hình hóa thành middleware. Middleware cần giữ nguyên type của result và không được nuốt exception mà không có chính sách rõ.

type CommandMiddleware = <T>(
  command: Command<T>,
  next: () => Promise<T>,
) => Promise<T>;
 
const withTiming: CommandMiddleware = async (command, next) => {
  const startedAt = Date.now();
  try {
    return await next();
  } finally {
    console.info(command.constructor.name, Date.now() - startedAt);
  }
};

10. State Machine: biến trạng thái thành mô hình có thể kiểm chứng

State Machine phù hợp khi object có tập trạng thái hữu hạn và mỗi trạng thái cho phép một nhóm transition khác nhau. Discriminated union thường là nền tảng tốt hơn nhiều boolean.

type OrderState =
  | { readonly status: "draft"; readonly items: readonly OrderItem[] }
  | { readonly status: "submitted"; readonly submittedAt: Date }
  | { readonly status: "paid"; readonly paidAt: Date }
  | { readonly status: "cancelled"; readonly reason: string };
 
type OrderEvent =
  | { readonly type: "submit" }
  | { readonly type: "pay" }
  | { readonly type: "cancel"; readonly reason: string };
 
function transition(state: OrderState, event: OrderEvent): OrderState {
  switch (state.status) {
    case "draft":
      if (event.type === "submit") {
        return { status: "submitted", submittedAt: new Date() };
      }
      if (event.type === "cancel") {
        return { status: "cancelled", reason: event.reason };
      }
      throw new Error("Draft order can only be submitted or cancelled");
 
    case "submitted":
      if (event.type === "pay") {
        return { status: "paid", paidAt: new Date() };
      }
      if (event.type === "cancel") {
        return { status: "cancelled", reason: event.reason };
      }
      throw new Error("Submitted order cannot be submitted again");
 
    case "paid":
      throw new Error("Paid order cannot transition");
 
    case "cancelled":
      throw new Error("Cancelled order cannot transition");
  }
}

State Machine làm cho transition rõ ràng, nhưng không tự giải quyết concurrency. Nếu hai request cùng transition một order, cần optimistic locking, database transaction hoặc cơ chế serialization ở runtime.

11. Repository và Unit of Work

Repository che giấu persistence detail và cung cấp ngôn ngữ gần với domain. Nó không nên trở thành bản sao của ORM với hàng trăm method truy vấn tùy tiện.

interface OrderRepository {
  findById(id: OrderId): Promise<Order | undefined>;
  save(order: Order): Promise<void>;
}
 
interface UnitOfWork {
  run<T>(work: (repositories: Repositories) => Promise<T>): Promise<T>;
}
 
interface Repositories {
  readonly orders: OrderRepository;
  readonly users: UserReader;
}
 
class SubmitOrderUseCase {
  constructor(private readonly unitOfWork: UnitOfWork) {}
 
  execute(orderId: OrderId): Promise<void> {
    return this.unitOfWork.run(async ({ orders }) => {
      const order = await orders.findById(orderId);
      if (!order) throw new Error("Order not found");
      await orders.save(order.submit());
    });
  }
}

Unit of Work nên bảo vệ một boundary transaction có ý nghĩa. Nếu chỉ bọc mọi function trong một abstraction mà không có transaction hoặc consistency guarantee thật, nó sẽ làm code khó hiểu hơn.

12. Dependency Injection và Composition Root

Dependency Injection không nhất thiết cần một framework. Cốt lõi của nó là truyền dependency từ bên ngoài thay vì để class tự khởi tạo dependency cụ thể.

class RegisterUserUseCase {
  constructor(
    private readonly users: UserWriter,
    private readonly hasher: PasswordHasher,
    private readonly clock: Clock,
  ) {}
 
  async execute(input: RegisterUserInput): Promise<UserId> {
    const passwordHash = await this.hasher.hash(input.password);
    return this.users.create({
      email: input.email,
      displayName: input.displayName,
      passwordHash,
      createdAt: this.clock.now(),
    });
  }
}

Composition Root là nơi duy nhất biết implementation cụ thể cần lắp ghép.

const clock = new SystemClock();
const hasher = new ArgonPasswordHasher();
const users = new PostgresUserWriter(db);
const registerUser = new RegisterUserUseCase(users, hasher, clock);

Đừng đưa container vào domain service rồi gọi container.resolve(...) ở mọi nơi. Service locator làm dependency ẩn, khiến constructor không còn mô tả contract thực sự và làm test khó đọc.

13. Mixin và trait-like composition

Mixin là kỹ thuật kết hợp behavior vào class thông qua generic class expression. Nó hữu ích khi nhiều class không có quan hệ kế thừa tự nhiên nhưng chia sẻ một capability.

type AbstractConstructor<T = object> = abstract new (...args: never[]) => T;
 
type Timestamped = {
  createdAt: Date;
  updatedAt: Date;
};
 
function Timestamped<TBase extends AbstractConstructor>(Base: TBase) {
  return class extends Base implements Timestamped {
    readonly createdAt = new Date();
    updatedAt = new Date();
 
    touch(): void {
      this.updatedAt = new Date();
    }
  };
}
 
class Document {
  constructor(public readonly title: string) {}
}
 
const TimestampedDocument = Timestamped(Document);
const document = new TimestampedDocument("Design Patterns");
document.touch();

Mixin làm tăng độ phức tạp của type và thứ tự khởi tạo. Nếu capability có thể là object composition hoặc service riêng, hãy cân nhắc giải pháp đó trước.

Trong code production, tránh để any lan vào constructor type của mixin. Có thể dùng unknown[] hoặc generic constructor cụ thể hơn nếu cần kiểm soát chặt, nhưng đừng làm type signature phức tạp đến mức mất lợi ích đọc hiểu.

14. Singleton và Global State: pattern cần hoài nghi

Singleton thường được dùng để bảo đảm một instance, nhưng nó cũng tạo global state, ẩn dependency và làm test phụ thuộc thứ tự. Module cache đã cung cấp một dạng singleton tự nhiên trong nhiều runtime; không cần thêm class Singleton chỉ để ngăn new.

Thay vì:

class Config {
  private static instance: Config;
 
  static getInstance(): Config {
    return (this.instance ??= new Config());
  }
}

Hãy dùng Composition Root:

const config = loadConfig(process.env);
const service = new ReportService(config);

Singleton chỉ hợp lý khi identity duy nhất là một invariant thực sự của runtime, lifecycle được quản lý rõ và ảnh hưởng toàn cục được chấp nhận. Trong phần lớn application service, dependency được truyền tường minh dễ kiểm soát hơn.

15. Type-safe Event Sourcing và immutable update

Event Sourcing lưu các event thay vì chỉ lưu trạng thái cuối. TypeScript có thể mô hình hóa event union để reducer phải xử lý các loại event đã biết.

type AccountEvent =
  | { readonly type: "account-opened"; readonly owner: string }
  | { readonly type: "money-deposited"; readonly cents: number }
  | { readonly type: "money-withdrawn"; readonly cents: number };
 
interface AccountSnapshot {
  readonly owner: string;
  readonly balanceInCents: number;
}
 
function applyEvent(
  state: AccountSnapshot,
  event: AccountEvent,
): AccountSnapshot {
  switch (event.type) {
    case "account-opened":
      return { owner: event.owner, balanceInCents: 0 };
    case "money-deposited":
      return {
        ...state,
        balanceInCents: state.balanceInCents + event.cents,
      };
    case "money-withdrawn":
      if (event.cents > state.balanceInCents) {
        throw new Error("Insufficient balance");
      }
      return {
        ...state,
        balanceInCents: state.balanceInCents - event.cents,
      };
  }
}

Event Sourcing không chỉ là dùng union type. Hệ thống thật cần versioning event, idempotency, ordering, snapshot, replay failure và migration. Type safety giúp mô hình hóa contract nhưng không thay thế operational design.

16. Khi kết hợp nhiều pattern

Pattern thường xuất hiện theo cụm. Một use case có thể dùng Dependency Injection để nhận Repository, Adapter để bọc SDK, Strategy để chọn chính sách, Decorator để thêm metrics và Command để đưa hành động vào queue. Điều này có thể hợp lý, nhưng cũng dễ tạo một “pattern maze” mà không ai biết logic thật nằm ở đâu.

Hãy giữ một flow có thể đọc theo chiều dọc:

const gateway = new MetricsPaymentGateway(
  new RetryPaymentGateway(
    new ProviderPaymentAdapter(providerClient),
  ),
);
 
const checkout = new CheckoutUseCase(
  orderRepository,
  gateway,
  pricingStrategy,
);

Mỗi wrapper nên có một lý do tồn tại và một contract không đổi. Nếu phải mở năm file để hiểu một thao tác đơn giản, hãy xem lại xem có abstraction nào chỉ làm forwarding hay không.

17. Testing các pattern nâng cao

Test pattern không có nghĩa là test số lượng class. Hãy test contract và hành vi mà pattern bảo vệ.

PatternĐiều cần kiểm thử
StrategyCác strategy cho cùng input contract và khác nhau đúng ở thuật toán.
FactoryImplementation được chọn đúng và dependency được lắp ghép đúng.
AdapterMapping field, lỗi provider và dữ liệu thiếu được chuẩn hóa.
DecoratorHành vi được thêm đúng thứ tự, lỗi không bị nuốt, cleanup hoạt động.
ObserverPayload đúng event, unsubscribe và chính sách lỗi của subscriber.
State MachineTransition hợp lệ, transition bị từ chối và exhaustiveness.
RepositoryContract persistence, transaction boundary và mapping domain.
DIComposition Root lắp đúng implementation; unit test dùng fake nhỏ.

Một fake nên thực hiện đúng contract tối thiểu, không cần sao chép toàn bộ database hoặc SDK. Structural typing giúp fake nhỏ dễ đáp ứng interface, nhưng test vẫn cần mô phỏng các hành vi có ý nghĩa như lỗi, timeout hoặc duplicate request.

function createFakeUserReader(
  users: readonly User[],
): UserReader {
  return {
    async findById(id) {
      return users.find((user) => user.id === id);
    },
  };
}

18. Chi phí type system khi dùng pattern

Pattern nâng cao có thể làm compiler phải xử lý nhiều generic, intersection, conditional type và union. Type design tốt không chỉ đúng mà còn phải dễ chẩn đoán khi có lỗi.

Hãy ưu tiên những nguyên tắc sau:

  • Dùng type parameter để biểu đạt quan hệ giữa input và output, không dùng generic chỉ để làm API có vẻ tổng quát.
  • Tách conditional type phức tạp thành type trung gian có tên.
  • Giới hạn recursion depth trong type đệ quy.
  • Dùng interface cho contract object lớn thay vì nối nhiều intersection khó đọc.
  • Khai báo return type cho function export để lỗi xuất hiện tại boundary.
  • Không để any trong abstraction dùng chung.
  • Dùng import type khi dependency chỉ tồn tại ở compile time.
  • Đo thời gian typecheck trước khi tối ưu; đừng hy sinh khả năng đọc chỉ vì một giả thuyết về hiệu năng.

Một pattern được xem là thành công khi compiler giúp caller dùng nó đúng, chứ không phải khi signature của nó có nhiều type parameter nhất.

19. Quy trình lựa chọn pattern

Bắt đầu bằng code đơn giản và quan sát nơi thay đổi thực sự xuất hiện. Khi một điểm biến đổi lặp lại, đặt tên cho contract ở đó. Khi có implementation thứ hai hoặc boundary hạ tầng đầu tiên, cân nhắc Strategy, Factory hoặc Adapter. Khi behavior cần ghép tuần tự, cân nhắc Decorator hoặc Middleware. Khi trạng thái có transition hữu hạn, dùng discriminated union hoặc State Machine.

Trước khi thêm pattern, hãy trả lời:

  1. Pattern này bảo vệ invariant hoặc điểm biến đổi nào?
  2. Contract nhỏ nhất cần expose là gì?
  3. TypeScript có thể làm cách dùng sai thất bại ở compile time không?
  4. Runtime validation và error boundary nằm ở đâu?
  5. Test sẽ kiểm tra behavior hay chỉ kiểm tra implementation?
  6. Nếu xóa pattern này, code có thực sự khó thay đổi hơn không?

Nếu không trả lời được câu hỏi đầu tiên, nhiều khả năng pattern đang được áp dụng quá sớm.

Kết luận

Design Pattern nâng cao trong TypeScript không nằm ở việc biến mọi function thành class hoặc tạo interface cho mọi object. Giá trị của pattern đến từ việc cô lập thay đổi, làm rõ dependency, bảo vệ invariant và đưa những quy tắc quan trọng vào contract mà compiler có thể hỗ trợ.

Strategy và Factory quản lý biến thể; Builder kiểm soát quá trình tạo object; Adapter bảo vệ domain khỏi dependency bên ngoài; Decorator và Middleware ghép behavior; Observer và Command điều phối sự kiện; State Machine mô hình hóa transition; Repository, Unit of Work và Dependency Injection tạo ranh giới cho persistence và hạ tầng; mixin cung cấp composition cho capability phù hợp.

Hãy áp dụng pattern với mức độ vừa đủ. Một abstraction tốt làm code dễ đọc hơn khi hệ thống phát triển; một abstraction thừa chỉ đổi sự phức tạp từ một file sang nhiều file. Trong TypeScript, thiết kế tốt là thiết kế khiến contract rõ, inference hữu ích, runtime boundary đáng tin và thay đổi tương lai có chi phí thấp hơn.

Tài liệu tham khảo

  1. TypeScript Handbook — More on Functions
  2. TypeScript Handbook — Generics
  3. TypeScript Handbook — Narrowing
  4. TypeScript Handbook — Classes
  5. TypeScript Handbook — Mixins
  6. TypeScript Handbook — Decorators
  7. TypeScript 5.0 — Decorators
  8. TypeScript Handbook — Modules
  9. TypeScript Handbook — Conditional Types
  10. TypeScript Handbook — Template Literal Types