Design Patterns in TypeScript · Part 6 — Decorator & Middleware
Add behavior without touching the original: the Decorator pattern via higher-order functions, wrapping a service to add caching/logging/retry, the middleware chain, and how TS decorators (and the new standard) compare.
Phần 6/10 trong series Design Patterns in TypeScript. Trước: Tiếp:
Đây là Phần 6 của series 10 bài về các design pattern mà mọi senior web nên nắm — giải thích bằng TypeScript chạy được, use case web thực tế, và bài tập ở cuối mỗi phần.
Ở Phần 5 — Observer & Pub/Sub bạn đã tách ai nghe event khỏi ai bắn event. Mùi khác xuất hiện khi bạn cần hành vi xuyên suốt trên cùng một thao tác: log mọi lần gọi, cache đọc, retry lỗi, gắn header auth. Subclass làm số tổ hợp bùng nổ — LoggedCachedRetriedUserService không phải con đường sự nghiệp. Decorator (và họ hàng middleware) nói: bọc lõi, giữ cùng interface, thêm hành vi trước hoặc sau khi ủy quyền.
Ý đồ
Decorator gắn trách nhiệm lên object động mà không subclass cho mỗi tổ hợp. Mỗi decorator implement cùng interface với lõi, giữ tham chiếu tới object (hoặc function) bên trong, và ủy quyền — chạy logic thêm trên đường vào hoặc ra.
Bạn dùng khi các concern trực giao với logic nghiệp vụ: telemetry, cache, rate limit, retry, auth — và bạn muốn xếp chồng chúng như lớp. Lợi ích là Open/Closed: mở rộng hành vi bằng wrapper mới, không sửa lõi. Rủi ro là hành tây quá sâu không ai debug được và decorator đổi ngữ nghĩa im lặng.
Decorator function (HOF) — dạng idiomatic TS
Trong TS và JS, decorator phổ biến nhất là higher-order function: function nhận function và trả function có cùng chữ ký gọi.
Bắt đầu với lõi async trần:
interface User {
id: string;
name: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
typeof value.id === 'string' &&
typeof value.name === 'string'
);
}
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`https://api.example.com/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: unknown = await res.json();
if (!isUser(data)) throw new Error('Invalid user payload');
return data;
}
Mẹo typing: ràng T là “callable bất kỳ” không dùng any:
// Every function is assignable to this bound — captures args + return type.
type AnyFn = (...args: never[]) => unknown;
type FnDecorator<T extends AnyFn> = (fn: T) => T;
(...args: never[]) => unknown là kiểu function rộng nhất TS cho phép mà không thoát sang any. Khi bọc T, dùng Parameters<T> và ReturnType<T> để arity và kiểu trả về đi theo.
Logging — quan sát lần gọi mà không sửa fetchUser. Ta chuyên async để ReturnType<T> vẫn là Promise và wrapper không cần type assertion:
function withLogging<T extends (...args: never[]) => Promise<unknown>>(
fn: T,
label: string,
): (...args: Parameters<T>) => ReturnType<T> {
return async (...args: Parameters<T>): ReturnType<T> => {
console.log(`[${label}] →`, args);
const value = await fn(...args);
console.log(`[${label}] ←`, value);
return value;
};
}
Retry và cache cùng kiểu bọc:
function withRetry<T extends (...args: never[]) => Promise<unknown>>(
fn: T,
options: { maxAttempts?: number; delayMs?: number } = {},
): (...args: Parameters<T>) => ReturnType<T> {
const { maxAttempts = 3, delayMs = 200 } = options;
return async (...args: Parameters<T>): ReturnType<T> => {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn(...args);
} catch (error) {
lastError = error;
if (attempt === maxAttempts) break;
await new Promise((r) => setTimeout(r, delayMs * attempt));
}
}
throw lastError;
};
}
function withCache<T extends (...args: never[]) => Promise<unknown>>(
fn: T,
keyFn: (...args: Parameters<T>) => string,
store = new Map<string, Awaited<ReturnType<T>>>(),
): (...args: Parameters<T>) => ReturnType<T> {
return async (...args: Parameters<T>): ReturnType<T> => {
const key = keyFn(...args);
const hit = store.get(key);
if (hit !== undefined) return hit;
const value = await fn(...args);
store.set(key, value);
return value;
};
}
Map gõ ở Awaited<ReturnType<T>>; wrapper async trả ReturnType<T> (Promise), nên cache hit type-check không cần assertion.
Compose — thứ tự quan trọng: decorator ngoài chạy trước trên đường vào:
const fetchUserInstrumented = withCache(
withRetry(withLogging(fetchUser, 'fetchUser'), { maxAttempts: 3 }),
(id) => `user:${id}`,
);
// Call chain: cache → retry → log → fetchUser
// await fetchUserInstrumented('42');
Đọc từ trong ra: withLogging bọc lõi; withRetry bọc tiếp; withCache bọc cả chồng. Cache ngoài retry tránh đập mạng khi miss nhưng vẫn retry lần gọi trong.
Helper pipe nhỏ giúp compose dễ đọc khi mỗi lớp giữ cùng chữ ký async:
type AsyncFn<A extends never[], R> = (...args: A) => Promise<R>;
type AsyncWrap<A extends never[], R> = (fn: AsyncFn<A, R>) => AsyncFn<A, R>;
function pipe<A extends never[], R>(
fn: AsyncFn<A, R>,
...wraps: AsyncWrap<A, R>[]
): AsyncFn<A, R> {
return wraps.reduce((acc, wrap) => wrap(acc), fn);
}
// pipe(fetchUser, (f) => withLogging(f, 'u'), (f) => withRetry(f, {}), ...)
Decorator object / class
Khi lõi là service object (repository, client thanh toán, adapter storage), bọc interface, không phải một function:
interface UserRepository {
findById(id: string): Promise<User | null>;
}
class HttpUserRepository implements UserRepository {
constructor(private baseUrl: string) {}
async findById(id: string): Promise<User | null> {
const res = await fetch(`${this.baseUrl}/users/${id}`);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: unknown = await res.json();
if (data === null) return null;
if (!isUser(data)) throw new Error('Invalid user payload');
return data;
}
}
class CachingUserRepository implements UserRepository {
private cache = new Map<string, User | null>();
constructor(private inner: UserRepository) {}
async findById(id: string): Promise<User | null> {
if (this.cache.has(id)) return this.cache.get(id) ?? null;
const user = await this.inner.findById(id);
this.cache.set(id, user);
return user;
}
}
// Wiring at the composition root — swap layers without changing callers
const repo: UserRepository = new CachingUserRepository(
new HttpUserRepository('https://api.example.com'),
);
Caller phụ thuộc UserRepository, không phải HttpUserRepository. Bạn có thể xếp LoggingUserRepository, RateLimitedUserRepository, v.v., mỗi cái ủy quyền cho inner. Cùng pattern inject Phần 1: kiểu ở biên, chồng concrete ở root.
Chuỗi middleware
Middleware là Decorator xếp thành pipeline: mỗi lớp nhận context và next(), chạy code trước/sau next(), chuyển tiếp. Express, Koa, Redux, và nhiều fetch interceptor dùng mô hình hành tây này.
interface RequestContext {
path: string;
headers: Record<string, string>;
status?: number;
body?: string;
}
type Middleware<C> = (ctx: C, next: () => Promise<void>) => Promise<void>;
function compose<C>(middlewares: readonly Middleware<C>[]): Middleware<C> {
return async (ctx, finalNext) => {
let index = -1;
async function dispatch(i: number): Promise<void> {
if (i <= index) {
throw new Error('next() called multiple times');
}
index = i;
const layer = middlewares[i];
if (layer) {
await layer(ctx, () => dispatch(i + 1));
} else {
await finalNext();
}
}
await dispatch(0);
};
}
const auth: Middleware<RequestContext> = async (ctx, next) => {
if (!ctx.headers.authorization) {
ctx.status = 401;
ctx.body = 'Unauthorized';
return; // short-circuit — do not call next()
}
await next();
};
const logger: Middleware<RequestContext> = async (ctx, next) => {
const start = performance.now();
await next();
console.log(`${ctx.path} ${ctx.status ?? '-'} ${(performance.now() - start).toFixed(1)}ms`);
};
const handler: Middleware<RequestContext> = async (ctx, next) => {
ctx.status = 200;
ctx.body = `OK ${ctx.path}`;
await next();
};
const run = compose([logger, auth, handler]);
await run({ path: '/users', headers: { authorization: 'Bearer x' } }, async () => {});
// logger runs → auth runs → handler sets 200 → logger logs duration
Mô hình hành tây: middleware vào chạy trên xuống dưới trước next(); sau khi next() resolve, code bung ngược dưới lên. Vì vậy logger bọc cả stack và đo tổng thời gian. Thứ tự không trang trí: auth trước handler chặn hit chưa auth; auth sau handler thì muộn.
Cú pháp @decorator của TypeScript
TS còn có decorator cấp ngôn ngữ — metadata và bọc gắn bằng @ trên class, method, field, hoặc accessor. Hai thời kỳ quan trọng:
| Era | Flag / version | What it targets |
|---|---|---|
| Legacy (experimental) | experimentalDecorators + emitDecoratorMetadata | Mostly classes; loose semantics; Angular/NestJS-era tooling |
| Standard (TC39 Stage 3) | TS 5.0+ with --experimentalDecorators off; useDefineForClassFields aligned | Functions, classes, fields, accessors per the decorator metadata proposal |
Decorator chuẩn là function gọi lúc định nghĩa class; nhận object context (kind, name, addInitializer, v.v.) và có thể thay hoặc bọc giá trị. Chúng mạnh khi framework đọc metadata (routing, validate, DI).
Với code app bạn sở hữu, HOF bọc và class decorator rõ ràng (như CachingUserRepository) vẫn đơn giản hơn: không flag compiler, debug dễ, portable giữa bundler. @Injectable() / @Get() kiểu NestJS là đường framework trên cùng ý tưởng — dùng khi framework sở hữu pipeline.
Use case web thực tế
- Bọc
fetch— inject header auth, retry backoff, cache response theo URL + method. - Log / telemetry — bọc API client hoặc route handler; một decorator đẩy trace lên APM.
- Rate limit — decorator token bucket từ chối trước khi chạm origin.
- Memoize — decorator function thuần với
Map(cẩn memory và invalidate cache). - Middleware Express / Koa / Hono —
composemảng cho HTTP; cùng mô hình hành tây. - Redux middleware —
(store) => (next) => (action) => ...là decorator trên dispatch. - Test double — bọc service thật bằng fake in-memory ở composition root không đổi kiểu production.
Cạm bẫy
- Mất
this— HOF bọc method trần làm gãythis; dùng arrow trong class,.bind(), hoặc bọc ở cấp object. - Gãy chữ ký — decorator bỏ tham số optional hoặc đổi kiểu trả về phá hợp đồng; caller và test lệch.
- Stack phụ thuộc thứ tự — cache trong hay ngoài retry đổi hành vi; ghi rõ hành tây mong muốn.
- Bọc quá nhiều — bảy lớp không tên trong stack trace; ưu tiên class wrapper có tên hoặc
piperõ ở root. - Nuốt lỗi — decorator bắt lỗi trả
undefinedche failure; rethrow hoặc map sang kiểuResultcó chủ đích. - Cache mutable dùng chung —
Mapcấp module trên server rò dữ liệu giữa tenant; scope cache theo request hoặc user.
Bảng tra nhanh
// HOF decorator — same Parameters / ReturnType
function withX<T extends (...args: never[]) => Promise<unknown>>(
fn: T,
): (...args: Parameters<T>) => ReturnType<T> {
return async (...args) => fn(...args);
}
// Object decorator — implement same interface, delegate to inner
class LoggingRepo implements UserRepository {
constructor(private inner: UserRepository) {}
findById(id: string) {
console.log('findById', id);
return this.inner.findById(id);
}
}
// Middleware — (ctx, next) => { await next(); }
const app = compose([logger, auth, handler]);
// Compose HOFs — order matters
const f = withCache(withRetry(withLogging(core, 'c'), {}), (id) => id);
Quyết định: một function, nhiều concern → stack HOF; interface service → class wrapper; pipeline HTTP → compose middleware; metadata framework → @decorator khi framework sở hữu.
Bài tập / Exercises
1. Cài HOF generic withRetry (chỉ async) có maxAttempts và delayMs; chứng minh retry rồi ném lỗi cuối.
Lời giải
function withRetry<T extends (...args: never[]) => Promise<unknown>>(
fn: T,
options: { maxAttempts?: number; delayMs?: number } = {},
): (...args: Parameters<T>) => ReturnType<T> {
const { maxAttempts = 3, delayMs = 50 } = options;
return async (...args: Parameters<T>): ReturnType<T> => {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn(...args);
} catch (error) {
lastError = error;
if (attempt < maxAttempts) {
await new Promise((r) => setTimeout(r, delayMs));
}
}
}
throw lastError;
};
}
let calls = 0;
const flaky = withRetry(async () => {
calls += 1;
if (calls < 3) throw new Error('fail');
return 'ok';
}, { maxAttempts: 5, delayMs: 1 });
await flaky(); // 'ok' after 3 calls2. Cho fetchUser(id: string): Promise<User>, compose ba decorator — withLogging, withRetry, withCache — và giải thích vì sao lớp cache ở ngoài hay trong.
Lời giải
const cachedRetryLogged = withCache(
withRetry(withLogging(fetchUser, 'fetchUser'), { maxAttempts: 3 }),
(id) => `user:${id}`,
);
// Outermost cache: on hit, neither retry nor logging nor network runs.
// If cache were innermost, a miss would still hit retry+log every time.3. Cài compose cho Middleware<C> và thêm middleware timing log thời lượng sau next().
Lời giải
type Middleware<C> = (ctx: C, next: () => Promise<void>) => Promise<void>;
function compose<C>(stack: readonly Middleware<C>[]): Middleware<C> {
return async (ctx, done) => {
let idx = -1;
async function dispatch(i: number): Promise<void> {
if (i <= idx) throw new Error('next() called twice');
idx = i;
const fn = stack[i];
if (fn) await fn(ctx, () => dispatch(i + 1));
else await done();
}
await dispatch(0);
};
}
const timing: Middleware<{ path: string; ms?: number }> = async (ctx, next) => {
const t0 = performance.now();
await next();
ctx.ms = performance.now() - t0;
};4. Bọc HttpUserRepository bằng CachingUserRepository invalidate một id khi gọi giả định updateUser.
Lời giải
interface UserRepository {
findById(id: string): Promise<User | null>;
updateUser(user: User): Promise<void>;
}
class CachingUserRepository implements UserRepository {
private cache = new Map<string, User | null>();
constructor(private inner: UserRepository) {}
async findById(id: string): Promise<User | null> {
if (this.cache.has(id)) return this.cache.get(id) ?? null;
const user = await this.inner.findById(id);
this.cache.set(id, user);
return user;
}
async updateUser(user: User): Promise<void> {
await this.inner.updateUser(user);
this.cache.delete(user.id);
}
}Nâng cao:viết HOF withRateLimit dùng token bucket (tối đa N lần gọi mỗi cửa sổ) và compose ngoài withRetry cho API client — giải thích khi bucket hết token.
Lời giải
function withRateLimit<T extends (...args: never[]) => Promise<unknown>>(
fn: T,
options: { max: number; windowMs: number },
): (...args: Parameters<T>) => ReturnType<T> {
let tokens = options.max;
let resetAt = Date.now() + options.windowMs;
return async (...args: Parameters<T>): ReturnType<T> => {
const now = Date.now();
if (now >= resetAt) {
tokens = options.max;
resetAt = now + options.windowMs;
}
if (tokens <= 0) throw new Error('Rate limit exceeded');
tokens -= 1;
return fn(...args);
};
}
// Outermost rate limit: rejects before retry/logging spend work.
// const call = withRateLimit(withRetry(withLogging(api, 'api'), {}), { max: 10, windowMs: 60_000 });Điểm chính
- Decorator = cùng interface vào ra, bọc và ủy quyền, thêm hành vi trước/sau.
- Trong app TS, HOF decorator là mặc định cho function; class wrapper cho interface service.
- Middleware là pipeline decorator với
next()— chú ý thứ tự hành tây. @decoratorcho metadata framework; ưu tiên wrapper rõ khi bạn sở hữu code.- Cảnh giác
this, lệch chữ ký, nuốt lỗi, và cache mutable dùng chung trên server.
Tiếp theo
Phần 7 — Adapter & Facade: chuyển API lạ sang shape app hiểu, và che subsystem rải rác sau một điểm vào gọn.