Skip to content
DevRecall
← Roadmap
20

Design Principles & Patterns

drafted

SOLID, GoF trong TypeScript, DI, Clean/Hexagonal architecture, DDD tactical, CQRS — và khi nào KHÔNG dùng.

0/47 rated · mastery 0%

Bài học

12 bài · ~154 phút đọc. Học theo thứ tự, rồi làm câu hỏi.
  1. 01~13 phút· 3 câu

    Nguyên lý thiết kế: coupling, DRY/KISS/YAGNI, composition

    Principle khác pattern ra sao, coupling và cohesion là thước đo thật, DRY nói về knowledge chứ không phải ký tự trùng, wrong abstraction hình thành thế nào, và vì sao composition thường thắng inheritance (fragile base class chạy thật).

  2. 02~12 phút· 5 câuverify

    SOLID phần 1: SRP, OCP, LSP

    SRP định nghĩa theo actor chứ không phải 'làm một việc', OCP là thêm hành vi bằng code mới và khi nào nó thành over-engineering, LSP là behavioral contract mà structural typing của TypeScript không kiểm (method bivariance, subtype throw) — kèm ví dụ refund COD làm sập batch job chạy thật.

  3. 03~13 phút· 5 câuverify

    ISP, DIP và Dependency Injection

    Interface nhỏ theo role, DIP khác DI và IoC thế nào, composition root và manual DI, vì sao Service Locator bị coi là anti-pattern, NestJS request scope lan ngược lên chuỗi inject (đếm instance chạy thật), và circular import làm dependency thành undefined/ReferenceError.

  4. 04~13 phút· 4 câu

    Creational patterns: Factory, Builder, Singleton, Prototype

    Factory function vs Factory Method vs Abstract Factory, exhaustive check bằng never, Builder khi nào còn đáng dùng và type-state builder (cần phantom field mới chạy), Singleton trong Node là module cache theo resolved path, bug giỏ hàng lẫn user do state theo request (repro thật), và Prototype: vì sao Object.create không phải clone.

  5. 05~12 phút· 4 câuverify

    Structural patterns: Adapter, Facade, Decorator, Proxy

    Bốn pattern 'bọc một thứ khác' và intent của từng cái, Composite/Bridge/Flyweight ngắn gọn, GoF Decorator khác TypeScript @decorator ra sao (TC39 vs legacy, vì sao NestJS chưa đổi), JS Proxy làm vỡ #private (chạy thật), và cache decorator làm lộ giá giữa tenant.

  6. 06~13 phút· 4 câu

    Behavioral patterns: Strategy, Template Method, Command, State

    Strategy là truyền function, Template Method là khung cố định qua kế thừa và cách viết lại bằng higher-order function, Command biến request thành dữ liệu serializable để queue/retry/undo, State machine dạng bảng transition, Iterator bằng generator — và GoF pattern nào co lại thành function trong TypeScript.

  7. 07~12 phút· 3 câuverify

    Observer, EventEmitter và Chain of Responsibility

    Observer in-process khác pub/sub qua broker, EventEmitter gọi listener đồng bộ nên một listener throw chặn các listener sau (chạy thật), leak SSE và MaxListenersExceededWarning, middleware là Chain of Responsibility, Express 4 làm treo request khi async middleware throw còn Express 5 thì không, và Mediator.

  8. 08~12 phút· 3 câuverify

    Clean / Hexagonal architecture

    Ports & adapters và dependency rule, driving vs driven adapter, composition root, cấu trúc thư mục theo feature, validation thuộc tầng nào, test từng tầng, enforce bằng dependency-cruiser (chạy thật, bắt 4 vi phạm), và cách quyết định khi 11 file cho một CRUD là ceremony.

  9. 09~13 phút· 4 câuverify

    DDD tactical: Entity, Value Object, Aggregate

    Entity vs Value Object (Money bằng bigint), Aggregate là ranh giới nhất quán của invariant thật, bốn quy tắc aggregate nhỏ của Vaughn Vernon, aggregate 10.000 dòng gây optimistic-lock conflict (đo trên Postgres 17), invariant xuyên aggregate 'tối đa 3 đơn chưa trả' và race của nó, anemic vs rich model, domain error: exception hay Result.

  10. 10~11 phút· 3 câuverify

    Repository, Unit of Work và Transactional Outbox

    Repository theo aggregate trên ORM có đáng không, Unit of Work và transaction (Prisma/TypeORM), truyền transaction bằng AsyncLocalStorage, dual-write problem và Transactional Outbox, relay với FOR UPDATE SKIP LOCKED, at-least-once và consumer idempotent — tất cả chạy thật trên Postgres 17.

  11. 11~13 phút· 3 câuverify

    CQRS và Event Sourcing

    CQRS tách write model và read model, projection lag và các cách xử lý read-after-write; Event Sourcing lưu event thay vì state: fold, expected version (Postgres unique key), snapshot, upcasting, crypto-shredding cho GDPR — chạy thật — và vì sao một ledger append-only thường giải được 'audit + undo' mà không cần ES cho cả platform.

  12. 12~16 phút· 6 câuverify

    Thiết kế mở rộng được, anti-pattern và refactor legacy

    Anti-pattern hay gặp trong backend Node/TS và cách phát hiện, branded type chống primitive obsession, thay 40 chỗ check plan bằng Entitlements, promotion engine bằng Strategy + Specification với rule là dữ liệu, payment provider plug-in với NextAction union và webhook idempotent, refactor legacy bằng characterization test + Strangler Fig, và đưa design principle vào team mà không thành pattern police.

Tóm tắt & cheat sheet của track

TL;DR

  • Pattern là tên chung cho một giải pháp đã lặp lại nhiều lần, không phải checklist phải áp dụng. Senior interview đo judgment: bạn biết khi nào pattern giúp và khi nào nó chỉ thêm indirection.
  • Trong TypeScript, rất nhiều GoF pattern co lại thành function: Strategy = truyền function, Command = closure, Factory = function trả object, Observer = EventEmitter. Class hierarchy là lựa chọn cuối.
  • SOLID là về quản lý thay đổi và coupling: module nào đổi vì lý do gì, ai phụ thuộc vào ai. DIP + DI là nền của Clean/Hexagonal architecture.
  • DDD tactical (Entity, Value Object, Aggregate, Domain Event) giải bài toán invariant nghiệp vụ phức tạp; CRUD đơn giản thì không cần. CQRS/Event Sourcing mạnh nhưng đắt — chỉ dùng cho một bounded context có lý do rõ ràng.
  • Câu hỏi luôn đi kèm: "When would you NOT use it?" — chuẩn bị sẵn câu trả lời cho mọi pattern.

Khái niệm cốt lõi

Khái niệmMột câuVí dụ ngắn (TS)
SRPMột module chỉ có một actor khiến nó phải đổiTách InvoicePdfRenderer khỏi InvoiceCalculator
OCPMở để mở rộng, đóng để sửa — thêm hành vi bằng code mớiThêm PaypalProvider mà không sửa CheckoutService
LSPSubtype dùng thay được supertype mà không phá contractReadonlyRepo extends Repo rồi save() throw → vi phạm
ISPClient không bị ép phụ thuộc method nó không dùngReader / Writer thay vì Repository 30 method
DIPHigh-level phụ thuộc abstraction, không phụ thuộc chi tiếtOrderService(repo: OrderRepository) thay vì new PrismaClient()
Composition over inheritanceGhép hành vi bằng object/function thay vì cây kế thừawithRetry(withCache(fetcher))
StrategyHoán đổi thuật toán lúc runtimeconst pricing: PricingRule = vipRule
DecoratorBọc object để thêm hành vi, cùng interfacenew CachedRepo(new SqlRepo())
AdapterChuyển interface lạ về interface mình cầnStripeAdapter implements PaymentGateway
Repository / UoWCollection-like access cho aggregate / gom thay đổi trong một transactionuow.orders.add(o); await uow.commit()
AggregateCụm object có một root, bảo vệ invariant trong một transactionOrder giữ OrderLine[], chỉ sửa qua order.addLine()
CQRSTách model ghi (command) và model đọc (query)Ghi vào SQL, đọc từ Elasticsearch projection

Cơ chế hoạt động

Hexagonal (Ports & Adapters) — dependency rule

Mũi tên source-code dependency luôn hướng vào core. Core định nghĩa interface (port); adapter ở ngoài implement. Nhờ vậy test use case bằng in-memory adapter, đổi DB/provider không chạm domain.

Chain of Responsibility — Express middleware là ví dụ sống

Trade-offs

Lựa chọnĐượcMấtKhi KHÔNG dùng
InheritanceTái dùng nhanh, polymorphism sẵnCoupling chặt, fragile base classKhi quan hệ không phải "is-a" thật sự hoặc cần ghép nhiều hành vi
CompositionLinh hoạt, test dễNhiều object nhỏ, wiring dài hơnKhi hierarchy thực sự ổn định (AST node, UI widget framework)
Repository trên ORMTách domain khỏi ORM, test dễThêm một lớp, dễ thành "generic repo" vô nghĩaCRUD app mỏng, query phức tạp dùng trực tiếp query builder
DI containerWiring tự động, scopesMagic, lỗi runtime thay vì compileService nhỏ — manual constructor injection là đủ
Clean/HexagonalCore độc lập framework, test nhanhNhiều file, mapping DTO ↔ domainPrototype, CRUD admin, lambda nhỏ
CQRSScale đọc/ghi độc lập, read model tối ưuEventual consistency, 2 modelDomain đơn giản, đọc-ghi cùng shape
Event SourcingAudit đầy đủ, time travel, rebuild projectionSchema evolution khó, query khó, học khóHầu hết CRUD; team chưa có kinh nghiệm

Pitfalls

  • Pattern-driven design: chọn pattern trước rồi tìm vấn đề. Bắt đầu từ lực đẩy thay đổi (what varies?).
  • DRY sai chỗ: gộp hai đoạn code trông giống nhưng đổi vì lý do khác nhau → abstraction sai, đầy boolean flag. "Duplication is far cheaper than the wrong abstraction" (Sandi Metz).
  • Singleton giữ state theo request trong Node → rò dữ liệu giữa user/tenant.
  • Generic Repository<T> với findAll/findById/save cho mọi entity → leak ORM, không mang ngữ nghĩa domain.
  • Anemic domain model + service 2000 dòng gọi là "DDD".
  • Aggregate quá to (Order chứa Customer chứa mọi Order khác) → lock contention, load chậm.
  • Event Sourcing cho cả hệ thống thay vì một bounded context cần audit.
  • Observer không unsubscribe → memory leak, handler chạy nhiều lần.

Cheat sheet

  • SOLID = Single actor · Open for extension · Liskov contract · Interface nhỏ · Depend on abstraction.
  • Creational: Factory (ẩn new, chọn implementation), Builder (object nhiều bước/tuỳ chọn), Singleton (một instance — thường là smell, dùng DI scope thay thế).
  • Structural: Adapter (đổi interface), Facade (đơn giản hoá subsystem), Decorator (thêm hành vi, cùng interface), Proxy (kiểm soát truy cập, cùng interface).
  • Behavioral: Strategy (hoán thuật toán), Observer (pub/sub in-process), Command (request thành object → queue/undo), Chain of Responsibility (middleware), Template Method (khung cố định, bước tuỳ biến qua kế thừa).
  • Decorator vs Proxy: cùng cấu trúc; Decorator thêm hành vi, Proxy kiểm soát truy cập (lazy, cache, auth).
  • Aggregate rule: tham chiếu aggregate khác bằng ID, một transaction sửa một aggregate, nhất quán giữa aggregate bằng domain event (eventual).
  • Refactor legacy: characterization tests → tìm seam → Strangler Fig → xoá code cũ.

Personal notes (Notion)

Ghi chú cá nhân — có thể sai hoặc lỗi thời, dùng để tham khảo.

Interview Questions

Showing 47 / 47

// Hai actor khác nhau: finance vs. ops
class Invoice {
  total(): Money { /* finance rules */ }
  toPdf(): Buffer { /* layout team */ }   // tách ra InvoicePdfRenderer
}
type Fetcher = (url: string) => Promise<Response>;
const withRetry = (f: Fetcher, n = 3): Fetcher => async (u) => {
  for (let i = 0; ; i++) {
    try { return await f(u); } catch (e) { if (i >= n - 1) throw e; }
  }
};
const withLog = (f: Fetcher): Fetcher => async (u) => { console.time(u); try { return await f(u); } finally { console.timeEnd(u); } };
const http = withLog(withRetry(fetch)); // ghép tuỳ ý, không cần subclass
interface Clock { now(): Date }
interface OrderRepo { save(o: Order): Promise<void> }

class PlaceOrder {
  constructor(private repo: OrderRepo, private clock: Clock) {}
  async run(cmd: PlaceOrderCmd) {
    const order = Order.create(cmd, this.clock.now());
    await this.repo.save(order);
  }
}

// composition root
const useCase = new PlaceOrder(new SqlOrderRepo(pool), { now: () => new Date() });
// test
const t = new PlaceOrder(new InMemoryOrderRepo(), { now: () => new Date('2026-01-01') });
type ShippingFee = (o: { weightKg: number; subtotal: number }) => number;

const strategies: Record<'standard' | 'express' | 'free', ShippingFee> = {
  standard: (o) => 20_000 + o.weightKg * 5_000,
  express: (o) => 50_000 + o.weightKg * 8_000,
  free: () => 0,
};

const fee = strategies[order.shippingMethod](order);
interface PaymentGateway { charge(amount: Money, token: string): Promise<ChargeResult> }

class StripeAdapter implements PaymentGateway {
  constructor(private stripe: Stripe) {}
  async charge(amount: Money, token: string) {
    const pi = await this.stripe.paymentIntents.create({ amount: amount.minor, currency: amount.currency, payment_method: token, confirm: true });
    return { id: pi.id, status: pi.status === 'succeeded' ? 'paid' : 'pending' } as const;
  }
}
interface DiscountRule { applies(c: Cart): boolean; amount(c: Cart): number }
class Pricing {
  constructor(private rules: DiscountRule[]) {}
  discount(c: Cart) { return this.rules.filter(r => r.applies(c)).reduce((s, r) => s + r.amount(c), 0); }
}
// thêm rule mới = thêm class/object mới, Pricing không đổi
class FileStorage { async write(path: string, data: Buffer) { /* ... */ } }
class ReadOnlyStorage extends FileStorage {
  async write(): Promise<void> { throw new Error('read-only'); } // compile OK, LSP broken
}
// Trước
interface OrderRepository { findById; findByUser; search; save; delete; archive; exportCsv; /* ... */ }
// Sau
interface OrderReader { findById(id: OrderId): Promise<Order | null> }
interface OrderWriter { save(o: Order): Promise<void> }
class CancelOrder { constructor(private orders: OrderReader & OrderWriter) {} }
class SqlOrderRepository implements OrderReader, OrderWriter { /* ... */ }
type Channel = 'email' | 'sms' | 'push';
interface Notifier { send(to: string, msg: string): Promise<void> }
const createNotifier = (c: Channel, deps: Deps): Notifier => {
  switch (c) {
    case 'email': return new SesNotifier(deps.ses);
    case 'sms': return new TwilioNotifier(deps.twilio);
    case 'push': return new FcmNotifier(deps.fcm);
    default: { const _exhaustive: never = c; throw new Error(_exhaustive); }
  }
};
class OrderBuilder {
  private o: Partial<Order> = { status: 'draft', lines: [] };
  withLine(sku: string, qty: number) { this.o.lines!.push({ sku, qty }); return this; }
  paid() { this.o.status = 'paid'; return this; }
  build(): Order {
    if (!this.o.lines!.length) throw new Error('order needs at least one line');
    return Object.freeze({ id: randomUUID(), ...this.o }) as Order;
  }
}
const order = new OrderBuilder().withLine('SKU-1', 2).paid().build();
const lazy = <T extends object>(factory: () => T): T => {
  let inst: T | undefined;
  return new Proxy({} as T, { get: (_t, k) => {
    const i = (inst ??= factory());
    const v = Reflect.get(i, k, i);
    return typeof v === 'function' ? v.bind(i) : v; // giữ `this` = instance thật (#private OK)
  } });
};
const s3 = lazy(() => new S3Client({ region: 'ap-southeast-1' })); // chỉ khởi tạo khi dùng lần đầu
interface Command<T = unknown> { type: string; payload: T; idempotencyKey: string }
const handlers: Record<string, (c: Command<any>) => Promise<void>> = {
  'invoice.send': async (c) => sendInvoice(c.payload.orderId),
};
async function dispatch(c: Command) {
  if (await processed.has(c.idempotencyKey)) return;
  await handlers[c.type](c);
  await processed.add(c.idempotencyKey);
}
abstract class Importer<Row> {
  async run(file: Buffer) {
    const rows = this.parse(file);
    const valid = rows.filter((r) => this.validate(r));
    await this.save(valid);
  }
  protected abstract parse(f: Buffer): Row[];
  protected abstract validate(r: Row): boolean;
  protected abstract save(rows: Row[]): Promise<void>;
}
await prisma.$transaction(async (tx) => {
  const order = await tx.order.update({ where: { id }, data: { status: 'PAID' } });
  await tx.outbox.create({ data: { type: 'order.paid', payload: { orderId: order.id } } });
}); // commit hoặc rollback cả hai
// cart.service.ts
class CartService {
  private currentUserId?: string;
  setUser(id: string) { this.currentUserId = id; }
  async getCart() {
    await delay(5); // simulate DB
    return db.cart.findFirst({ where: { userId: this.currentUserId } });
  }
}
export const cartService = new CartService();

// route
app.get('/cart', async (req, res) => {
  cartService.setUser(req.user.id);
  res.json(await cartService.getCart());
});
const priceEvents = new EventEmitter();

app.get('/prices/stream', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  priceEvents.on('price', (p) => res.write(`data: ${JSON.stringify(p)}\n\n`));
});
interface PaymentGateway {
  charge(amount: number): Promise<string>;
  refund(chargeId: string, amount: number): Promise<void>;
}
class CodGateway implements PaymentGateway { // Cash on delivery
  async charge() { return 'cod-' + Date.now(); }
  async refund() { throw new Error('COD cannot be refunded online'); }
}
// refund job, written against the interface
for (const o of cancelledOrders) await gateways[o.method].refund(o.chargeId, o.total);
class Order {
  private constructor(private status: 'pending' | 'paid' | 'cancelled', private paidAt?: Date) {}
  pay(at: Date) {
    if (this.status !== 'pending') throw new DomainError('ORDER_NOT_PAYABLE');
    this.status = 'paid'; this.paidAt = at;
  }
}
class Money {
  private constructor(readonly minor: bigint, readonly currency: 'VND' | 'USD') {}
  static of(minor: bigint, c: 'VND' | 'USD') { return new Money(minor, c); }
  add(o: Money) { if (o.currency !== this.currency) throw new Error('CURRENCY_MISMATCH'); return new Money(this.minor + o.minor, this.currency); }
  equals(o: Money) { return this.minor === o.minor && this.currency === o.currency; }
}
type OrderEvent =
  | { type: 'OrderPlaced'; v: 1; orderId: string; total: number }
  | { type: 'OrderPaid'; v: 1; orderId: string; paidAt: string };
const apply = (s: OrderState, e: OrderEvent): OrderState =>
  e.type === 'OrderPlaced' ? { ...s, status: 'pending', total: e.total }
  : e.type === 'OrderPaid' ? { ...s, status: 'paid' } : s;
const state = events.reduce(apply, initial);
// order.service.ts
import { userService, UserService } from './user.service';
export class OrderService {
  constructor(private users: UserService) {}
  owner(id: string) { return this.users.find(id); }
}
export const orderService = new OrderService(userService); // reads the binding at load time

// user.service.ts
import { orderService } from './order.service';
export class UserService {
  find(id: string) { /* ... */ }
  orders(id: string) { return orderService.owner(id); }
}
export const userService = new UserService();

// main.ts (CommonJS build)
import { userService } from './user.service'; // user.service loads first
class CachedProductRepo implements ProductRepo {
  constructor(private inner: ProductRepo, private cache: Redis) {}
  async findBySku(sku: string, ctx: { tenantId: string }) {
    const key = `product:${sku}`;
    const hit = await this.cache.get(key);
    if (hit) return JSON.parse(hit);
    const p = await this.inner.findBySku(sku, ctx);
    await this.cache.set(key, JSON.stringify(p), 'EX', 300);
    return p;
  }
}
await uow.run(async (tx) => {
  const order = await tx.orders.byId(id);
  order.pay(clock.now());                       // records OrderPaid
  await tx.orders.save(order);
  await tx.outbox.addAll(order.pullEvents());   // same transaction
});
src/orders/
  domain/order.ts, money.ts, events.ts
  application/place-order.ts, ports.ts
  adapters/http/orders.controller.ts
  adapters/sql/sql-order-repository.ts
src/main.ts   // wiring
type Result<T, E extends string> = { ok: true; value: T } | { ok: false; error: E };
function reserve(stock: number, qty: number): Result<number, 'INSUFFICIENT_STOCK'> {
  return qty <= stock ? { ok: true, value: stock - qty } : { ok: false, error: 'INSUFFICIENT_STOCK' };
}