// Hai actor khác nhau: finance vs. ops
class Invoice {
total(): Money { /* finance rules */ }
toPdf(): Buffer { /* layout team */ } // tách ra InvoicePdfRenderer
}
Design Principles & Patterns
draftedSOLID, 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.- 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).
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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~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~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~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ệm | Một câu | Ví dụ ngắn (TS) |
|---|---|---|
| SRP | Một module chỉ có một actor khiến nó phải đổi | Tách InvoicePdfRenderer khỏi InvoiceCalculator |
| OCP | Mở để mở rộng, đóng để sửa — thêm hành vi bằng code mới | Thêm PaypalProvider mà không sửa CheckoutService |
| LSP | Subtype dùng thay được supertype mà không phá contract | ReadonlyRepo extends Repo rồi save() throw → vi phạm |
| ISP | Client không bị ép phụ thuộc method nó không dùng | Reader / Writer thay vì Repository 30 method |
| DIP | High-level phụ thuộc abstraction, không phụ thuộc chi tiết | OrderService(repo: OrderRepository) thay vì new PrismaClient() |
| Composition over inheritance | Ghép hành vi bằng object/function thay vì cây kế thừa | withRetry(withCache(fetcher)) |
| Strategy | Hoán đổi thuật toán lúc runtime | const pricing: PricingRule = vipRule |
| Decorator | Bọc object để thêm hành vi, cùng interface | new CachedRepo(new SqlRepo()) |
| Adapter | Chuyển interface lạ về interface mình cần | StripeAdapter implements PaymentGateway |
| Repository / UoW | Collection-like access cho aggregate / gom thay đổi trong một transaction | uow.orders.add(o); await uow.commit() |
| Aggregate | Cụm object có một root, bảo vệ invariant trong một transaction | Order giữ OrderLine[], chỉ sửa qua order.addLine() |
| CQRS | Tá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 | Được | Mất | Khi KHÔNG dùng |
|---|---|---|---|
| Inheritance | Tái dùng nhanh, polymorphism sẵn | Coupling chặt, fragile base class | Khi quan hệ không phải "is-a" thật sự hoặc cần ghép nhiều hành vi |
| Composition | Linh hoạt, test dễ | Nhiều object nhỏ, wiring dài hơn | Khi hierarchy thực sự ổn định (AST node, UI widget framework) |
| Repository trên ORM | Tách domain khỏi ORM, test dễ | Thêm một lớp, dễ thành "generic repo" vô nghĩa | CRUD app mỏng, query phức tạp dùng trực tiếp query builder |
| DI container | Wiring tự động, scopes | Magic, lỗi runtime thay vì compile | Service nhỏ — manual constructor injection là đủ |
| Clean/Hexagonal | Core độc lập framework, test nhanh | Nhiều file, mapping DTO ↔ domain | Prototype, CRUD admin, lambda nhỏ |
| CQRS | Scale đọc/ghi độc lập, read model tối ưu | Eventual consistency, 2 model | Domain đơn giản, đọc-ghi cùng shape |
| Event Sourcing | Audit đầy đủ, time travel, rebuild projection | Schema 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ớifindAll/findById/savecho 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ũ.
References
- Refactoring.Guru — Design Patterns catalog ↗
- Martin Fowler — CQRS ↗
- Martin Fowler — Event Sourcing ↗
- Martin Fowler — Strangler Fig Application ↗
- Martin Fowler — Inversion of Control Containers and the Dependency Injection pattern ↗
- Martin Fowler — Anemic Domain Model ↗
- Martin Fowler — DDD Aggregate ↗
- Alistair Cockburn — Hexagonal Architecture ↗
- Sandi Metz — The Wrong Abstraction ↗
- NestJS — Injection scopes ↗
- TypeScript 5.0 — Decorators ↗
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
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' };
}