Design Patterns in TypeScript · Part 10 — Proxy & Dependency Injection
Control access and wire your app for testability: the JS Proxy for reactivity/validation/lazy loading, Dependency Injection and Inversion of Control, the composition root, and choosing constructor injection over service-locator.
Phần 10/10 trong series Design Patterns in TypeScript. Trước: Chốt series.
Đây là Phần 10 — chốt series của loạt 10 bài về 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 9 — State & State Machines bạn mô hình hóa hành vi đổi theo giai đoạn. Proxy — người đứng thay kiểm soát cách bạn chạm object thật; và Dependency Injection (DI) — cộng sự được truyền vào, không tự new bên trong. Cùng nhau trả lời: ai được chạm cái này? và ai nối đồ thị object?.
Ý đồ
Proxy (GoF) cung cấp đại diện cùng interface với subject thật, để thêm kiểm soát truy cập, lazy load, cache, hoặc ủy quyền remote mà không đổi caller. Dependency Injection đảo việc tạo object: class khai báo cần gì (interface); composition root cấp implementation cụ thể.
Dùng Proxy khi tạo hoặc truy cập đắt, nhạy cảm, hoặc xa; dùng DI khi new ẩn khiến code khó test và cứng. Lợi ích là tách concern; rủi ro là ma thuật (proxy không trong suốt) và nước framework (container che mất đồ thị).
JS Proxy — Proxy / Reflect có sẵn
JavaScript có sẵn Proxy meta-programming chặn thao tác trên object (get, set, apply, …). Luôn ghép trap với Reflect để hành vi mặc định đúng (đặc biệt property kế thừa và this). Reactivity Vue 3, draft Immer, và nhiều thư viện observable dùng cơ chế này bên dưới.
Proxy validate
Từ chối ghi không hợp lệ ở biên thay vì để state hỏng lan ra:
function isKeyOf<T extends object>(obj: T, key: PropertyKey): key is keyof T {
return typeof key === 'string' && key in obj;
}
type ValidatorMap<T extends Record<string, unknown>> = {
[K in keyof T]?: (value: unknown) => value is T[K];
};
export function createValidationProxy<T extends Record<string, unknown>>(
target: T,
validators: ValidatorMap<T>,
): T {
return new Proxy(target, {
set(obj, prop, value, receiver) {
if (!isKeyOf(obj, prop)) {
return Reflect.set(obj, prop, value, receiver);
}
const validate = validators[prop];
if (validate && !validate(value)) {
throw new Error(`Invalid value for property "${String(prop)}"`);
}
return Reflect.set(obj, prop, value, receiver);
},
});
}
const user = createValidationProxy(
{ name: 'Ada', age: 36 },
{
name: (v): v is string => typeof v === 'string' && v.length > 0,
age: (v): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 0,
},
);
user.name = 'Grace'; // ok
// user.age = -1; // throws — caught at the proxy, not deep in UI
Predicate kiểu trên validator cho thu hẹp không cần as.
Log và truy cập lười
Trap log bọc lần gọi method; subject lười hoãn việc nặng tới lần dùng đầu:
function withCallLogging<T extends object>(target: T, namespace: string): T {
return new Proxy(target, {
get(obj, prop, receiver) {
const value = Reflect.get(obj, prop, receiver);
if (typeof value !== 'function') return value;
return function (this: unknown, ...args: unknown[]) {
console.log(`[${namespace}] ${String(prop)}`, args);
return Reflect.apply(value, this, args);
};
},
});
}
interface ImageData {
readonly width: number;
readonly height: number;
readonly pixels: Uint8ClampedArray;
}
class HeavyImage implements ImageData {
readonly width = 800;
readonly height = 600;
readonly pixels: Uint8ClampedArray;
constructor(url: string) {
// Stand-in for decode/network — expensive on first touch.
console.log(`decode ${url}`);
this.pixels = new Uint8ClampedArray(this.width * this.height * 4);
}
}
class LazyImageProxy implements ImageData {
#real: HeavyImage | null = null;
constructor(private readonly url: string) {}
get width(): number {
return this.#load().width;
}
get height(): number {
return this.#load().height;
}
get pixels(): Uint8ClampedArray {
return this.#load().pixels;
}
#load(): HeavyImage {
this.#real ??= new HeavyImage(this.url);
return this.#real;
}
}
const thumb = new LazyImageProxy('/hero.webp');
console.log(thumb.width); // decode runs once, on first property read
Ví dụ lazy là Virtual Proxy GoF bằng class; log là Proxy ngôn ngữ — cả hai kiểm soát truy cập trước/vòng subject thật.
Pattern Proxy vs JS Proxy
| GoF Proxy | JS Proxy | |
|---|---|---|
| What | Design: surrogate + same interface | Language: intercept object ops |
| Examples | Virtual, protection, remote, caching wrapper | Validation, reactivity, logging traps |
| Typing | Explicit class implementing Subject | Often generic wrapper; mind this in traps |
Cả hai đều: caller nói chuyện với người đứng thay; người đó quyết định khi nào/cách nào chuyển tiếp tới subject thật. Dùng class bọc khi API là interface rõ và bạn muốn debug thẳng. Dùng new Proxy khi cần chặn generic (form model, công cụ debug) và chấp nhận stack trace khó đọc hơn.
Protection proxy — chặn thao tác theo vai:
interface Document {
read(): string;
write(text: string): void;
}
class RealDocument implements Document {
constructor(private text = '') {}
read(): string {
return this.text;
}
write(text: string): void {
this.text = text;
}
}
class ReadOnlyDocumentProxy implements Document {
constructor(
private readonly inner: Document,
private readonly canWrite: boolean,
) {}
read(): string {
return this.inner.read();
}
write(text: string): void {
if (!this.canWrite) throw new Error('Forbidden');
this.inner.write(text);
}
}
DI & IoC
Inversion of Control (IoC) đảo ai tạo dependency: không phải consumer, mà caller hoặc bootstrap. Dependency Injection là kiểu IoC thường gặp trong TS: truyền dep qua constructor (ưu tiên), tham số hàm, hoặc đối số factory.
Phụ thuộc interface, không phụ thuộc class cụ thể:
export interface UserRecord {
readonly id: string;
readonly email: string;
}
export interface UserRepository {
findById(id: string): Promise<UserRecord | null>;
}
export class UserService {
// Constructor injection — deps are explicit and required.
constructor(private readonly users: UserRepository) {}
async displayEmail(id: string): Promise<string | null> {
const user = await this.users.findById(id);
return user?.email ?? null;
}
}
new bên trong khó test vs fake inject
// ❌ Hidden dependency — cannot swap in a unit test without network/DB
class BadUserService {
async displayEmail(id: string): Promise<string | null> {
const repo = new HttpUserRepository(fetch, 'https://api.example.com');
const user = await repo.findById(id);
return user?.email ?? null;
}
}
// ✅ Test with an in-memory fake — no I/O, deterministic
const fakeRepo: UserRepository = {
async findById(id) {
return { id, email: 'ada@example.com' };
},
};
const service = new UserService(fakeRepo);
await service.displayEmail('u1'); // 'ada@example.com'
Đây là phần thưởng Phần 1 đã hứa: giữ singleton thật ở rìa, inject interface ở mọi nơi khác. Phần 4 — Strategy inject hành vi; ở đây inject service.
Composition root
Composition root là một chỗ duy nhất (entry app, main.ts, beforeEach test) nơi đồ thị object được nối. Mọi module khác chỉ khai báo dependency.
interface Clock {
now(): Date;
}
interface Logger {
info(msg: string): void;
}
class SystemClock implements Clock {
now(): Date {
return new Date();
}
}
class ConsoleLogger implements Logger {
info(msg: string): void {
console.log(msg);
}
}
class SessionService {
constructor(
private readonly clock: Clock,
private readonly log: Logger,
) {}
isSessionValid(expiresAt: Date): boolean {
const ok = expiresAt > this.clock.now();
this.log.info(ok ? 'session ok' : 'session expired');
return ok;
}
}
// Composition root — only file that knows all concretes.
export function createApp(): { sessions: SessionService } {
const clock = new SystemClock();
const log = new ConsoleLogger();
const sessions = new SessionService(clock, log);
return { sessions };
}
// Test composition root — swap fakes without touching SessionService.
export function createTestApp(now: Date): { sessions: SessionService } {
const clock: Clock = { now: () => now };
const log: Logger = { info: () => {} };
return { sessions: new SessionService(clock, log) };
}
Container thủ công nhỏ (không framework) map token sang factory — vẫn rõ ràng, vẫn một root:
type Factory<T> = () => T;
const TOKENS = {
repo: Symbol('UserRepository'),
users: Symbol('UserService'),
} as const;
export function createContainer() {
const factories = new Map<symbol, Factory<unknown>>();
return {
register<T>(token: symbol, factory: Factory<T>): void {
factories.set(token, factory);
},
resolve(token: typeof TOKENS.repo): UserRepository;
resolve(token: typeof TOKENS.users): UserService;
resolve(token: symbol): unknown {
const factory = factories.get(token);
if (!factory) throw new Error(`No factory for ${String(token.description)}`);
return factory();
},
};
}
function wireUserModule(container: ReturnType<typeof createContainer>) {
container.register(TOKENS.repo, (): UserRepository => ({
async findById(id: string) {
return { id, email: 'prod@example.com' };
},
}));
container.register(
TOKENS.users,
(): UserService => new UserService(container.resolve(TOKENS.repo)),
);
return container;
}
Ưu tiên constructor injection hơn service locator (get('repo') ẩn trong method) — locator nói dối về dependency trên chữ ký:
// ❌ Service locator — dependencies invisible, tests must mutate global registry
const locator = {
get<T>(key: 'repo'): T {
throw new Error('not wired');
},
};
class LocatorUserService {
async displayEmail(id: string): Promise<string | null> {
const repo = locator.get<UserRepository>('repo');
const user = await repo.findById(id);
return user?.email ?? null;
}
}
Use case web thực tế
- Service test được — inject
UserRepository,PaymentGateway,Clock(adapter Phần 7 ở root). - Đổi HTTP / storage — client
fetch,localStoragevsmemorycho test SSR. - State reactive —
Proxytrên object store (reactivity kiểu Vue/Pinia, draft Immer). - Feature flag / config — inject
AppConfigthay vì đọcprocess.envmọi module. - Stack xuyên suốt — decorator Phần 6 trên repo, nối ở composition root.
- Mock trong test — cùng
UserService, repo giả; cùng bus Command, hàng đợi in-memory.
Cạm bẫy
- Hiệu năng
Proxyvàthis— trap mỗi lần truy cập tốn thêm; method tách khỏi object proxy có thể mấtthisnếu không bind hoặc gọi quaReflect.apply. - Debug mờ — stack qua proxy và chuỗi decorator sâu (Phần 6) làm người mới bực.
- Framework DI thừa — app 12 route hiếm khi cần container; hàm
createApp()là đủ. - Service locator —
container.get(TOKEN)trong logic nghiệp vụ che đồ thị và khuyến khích global. - Phụ thuộc vòng —
AcầnB,BcầnA; sửa bằng type thứ ba, event (Phần 5), hoặc tách port đọc/ghi.
Bảng tra nhanh
// JS Proxy + Reflect — validation, logging, reactivity hooks
const guarded = createValidationProxy({ x: 1 }, { x: (v): v is number => typeof v === 'number' });
// GoF Proxy — class wrapper, same interface
class CachedRepo implements UserRepository {
constructor(private readonly inner: UserRepository, private cache = new Map<string, UserRecord | null>()) {}
async findById(id: string) {
if (!this.cache.has(id)) this.cache.set(id, await this.inner.findById(id));
return this.cache.get(id) ?? null;
}
}
// DI — depend on interface, inject via constructor
class Service {
constructor(private readonly dep: Port) {}
}
// Composition root — wire concretes once
export function createApp() {
return new Service(new ConcreteDep());
}
Quyết định: kiểm soát truy cập → Proxy; kiểm soát nối dây → DI ở composition root; không giấu dep trong locator.
Bài tập / Exercises
1. Cài createValidationProxy cho { title: string; tags: string[] } từ chối title rỗng và tags không phải mảng.
Lời giải
type PostDraft = { title: string; tags: string[] };
const draft = createValidationProxy<PostDraft>(
{ title: 'Proxy finale', tags: ['ts', 'patterns'] },
{
title: (v): v is string => typeof v === 'string' && v.trim().length > 0,
tags: (v): v is string[] => Array.isArray(v) && v.every((t) => typeof t === 'string'),
},
);
draft.title = 'Updated';
// draft.title = ' '; // throws2. Refactor service đang new HttpUserRepository() bên trong sang constructor injection, rồi viết test dùng repo giả.
Lời giải
class HttpUserRepository implements UserRepository {
constructor(
private readonly fetchFn: typeof fetch,
private readonly baseUrl: string,
) {}
async findById(id: string): Promise<UserRecord | null> {
const res = await this.fetchFn(`${this.baseUrl}/users/${id}`);
if (!res.ok) return null;
const data: unknown = await res.json();
if (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'email' in data &&
typeof data.id === 'string' &&
typeof data.email === 'string'
) {
return { id: data.id, email: data.email };
}
return null;
}
}
// Before: new HttpUserRepository() inside BadUserService (see above).
// After:
const production = new UserService(new HttpUserRepository(fetch, 'https://api.example.com'));
async function testDisplayEmail() {
const fake: UserRepository = {
async findById(id) {
return id === '42' ? { id: '42', email: 'test@example.com' } : null;
},
};
const svc = new UserService(fake);
const email = await svc.displayEmail('42');
console.assert(email === 'test@example.com');
}3. Viết composition root createApp() nối SessionService với clock/logger thật, và createTestApp(fixedNow) nối bản giả.
Lời giải
const app = createApp();
app.sessions.isSessionValid(new Date('2030-01-01'));
const testApp = createTestApp(new Date('2025-01-01'));
testApp.sessions.isSessionValid(new Date('2024-06-01')); // false — deterministic4. Cài CachingProxy UserRepository ủy quyền cho repo trong và memo findById.
Lời giải
class CachingUserRepository implements UserRepository {
private readonly cache = new Map<string, UserRecord | null>();
constructor(private readonly inner: UserRepository) {}
async findById(id: string): Promise<UserRecord | 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;
}
}5. Giải thích một câu vì sao service locator hại test hơn constructor injection.
Lời giải
Constructor injection làm dependency nhìn thấy và thay được theo instance; locator giấu chúng sau global phải reset giữa các test.
ĐỒ ÁN:Dựng facade checkout nhỏ (Phần 7) nhận Strategy giá (Phần 4) inject, bọc cổng thanh toán bằng Decorator log (Phần 6), nối tất cả ở composition root — không new trong facade.
Lời giải
type PricingStrategy = (subtotalCents: number) => number;
interface PaymentPort {
chargeCents(amount: number, customerId: string): Promise<{ ok: boolean }>;
}
function withPaymentLogging(inner: PaymentPort, log: Logger): PaymentPort {
return {
async chargeCents(amount, customerId) {
log.info(`charge start ${amount} for ${customerId}`);
const result = await inner.chargeCents(amount, customerId);
log.info(`charge end ok=${result.ok}`);
return result;
},
};
}
class CheckoutFacade {
constructor(
private readonly price: PricingStrategy,
private readonly pay: PaymentPort,
) {}
async checkout(subtotalCents: number, customerId: string): Promise<{ ok: boolean }> {
const total = this.price(subtotalCents);
return this.pay.chargeCents(total, customerId);
}
}
const tenPercentOff: PricingStrategy = (cents) => Math.round(cents * 0.9);
const stripeLike: PaymentPort = {
async chargeCents(amount, customerId) {
return { ok: amount > 0 && customerId.length > 0 };
},
};
export function createCheckoutApp(log: Logger) {
const pay = withPaymentLogging(stripeLike, log);
return new CheckoutFacade(tenPercentOff, pay);
}
// test wiring
const silentLog: Logger = { info: () => {} };
const checkout = createCheckoutApp(silentLog);
await checkout.checkout(10_000, 'cust_1'); // charges 9000 after strategyĐiểm chính
- Proxy — cùng interface, kiểm soát truy cập (lazy, cache, auth, remote); JS
Proxy+Reflectcho chặn generic. - DI / IoC — phụ thuộc interface; nhận implementation từ ngoài; constructor injection là mặc định.
- Composition root — một chỗ nối đồ thị; chỗ khác chỉ khai báo nhu cầu.
- Tránh service locator — global ẩn và test phụ thuộc thứ tự.
- Pattern ghép — Strategy + Decorator + Facade + DI là thường gặp production, không phải hư cấu sách.
Tổng kết series
Bạn đã đi hết 10 phần. Dùng checklist này để ôn từng chủ đề — mỗi link là bài sâu độc lập có diagram, TS chạy được, và bài tập.
- Part 1 — Singleton & the Module Pattern
- Part 2 — Factory & Abstract Factory
- Part 3 — Builder & Fluent APIs
- Part 4 — Strategy
- Part 5 — Observer & Pub/Sub
- Part 6 — Decorator & Middleware
- Part 7 — Adapter & Facade
- Part 8 — Command & Memento
- Part 9 — State & State Machines
- Part 10 — Proxy & Dependency Injection (you are here)
Giờ bạn có từ vựng pattern senior cho TypeScript web: không phải rải pattern khắp nơi, mà đặt tên mùi sớm, chọn fix nhỏ nhất, và nối hệ thống test được. Đọc lại phần nào khi PR có cảm giác “thêm một if nữa” hoặc “thêm một global nữa” — đó là lúc series hoạt động như cẩm nang thực địa, không phải kệ trưng bày.