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.
Part 10 of 10 in the Design Patterns in TypeScript series {Phần 10/10 trong series Design Patterns in TypeScript}. Previous {Trước}: Part 9 — State & State Machines · Finale {Chốt series}.
This is Part 10 — the finale of a 10-part series on the design patterns every senior web engineer should have in their hands — explained with runnable TypeScript, real frontend/back-of-front use cases, and exercises at the end of each part {Đâ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}.
In Part 9 — State & State Machines you modeled behavior that changes with phase {Ở Phần 9 — State & State Machines bạn mô hình hóa hành vi đổi theo giai đoạn}. Two ideas close the series and show up in almost every mature codebase: Proxy — a stand-in that controls how you reach a real object {Proxy — người đứng thay kiểm soát cách bạn chạm object thật}; and Dependency Injection (DI) — collaborators passed in, not constructed inside {và Dependency Injection (DI) — cộng sự được truyền vào, không tự new bên trong}. Together they answer: who may touch this? and who wires the graph? {Cùng nhau trả lời: ai được chạm cái này? và ai nối đồ thị object?}.
The intent {Ý đồ}
Proxy (GoF) provides a surrogate with the same interface as the real subject, so you can add access control, lazy loading, caching, or remote delegation without changing callers {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 inverts construction: a class declares what it needs (interfaces); the composition root supplies concrete implementations {Dependency Injection đảo việc tạo object: class khai báo cần gì (interface); composition root cấp implementation cụ thể}.
You reach for Proxy when creation or access is expensive, sensitive, or remote {Dùng Proxy khi tạo hoặc truy cập đắt, nhạy cảm, hoặc xa}; you reach for DI when hidden new makes code untestable and inflexible {dùng DI khi new ẩn khiến code khó test và cứng}. The win is separation of concerns; the risk is magic (opaque proxies) and framework soup (containers that hide your graph) {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ị)}.
The JS Proxy — built-in Proxy / Reflect {JS Proxy — Proxy / Reflect có sẵn}
JavaScript ships a meta-programming Proxy that intercepts operations on an object (get, set, apply, …) {JavaScript có sẵn Proxy meta-programming chặn thao tác trên object (get, set, apply, …)}. Always pair traps with Reflect so default behavior stays correct (especially for inherited properties and this) {Luôn ghép trap với Reflect để hành vi mặc định đúng (đặc biệt property kế thừa và this)}. Vue 3 reactivity, Immer drafts, and many observable libraries use this mechanism under the hood {Reactivity Vue 3, draft Immer, và nhiều thư viện observable dùng cơ chế này bên dưới}.
Validation proxy {Proxy validate}
Reject invalid writes at the boundary instead of letting bad state spread {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
Type predicates on validators give narrowing without as {Predicate kiểu trên validator cho thu hẹp không cần as}.
Logging and lazy access {Log và truy cập lười}
A logging trap wraps method calls; a lazy subject defers heavy work until first use {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
The lazy example is GoF Virtual Proxy in a class; the logging example is the language Proxy — both control access before/around the real subject {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}.
Proxy pattern vs JS Proxy {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 |
Both mean: callers talk to a stand-in; the stand-in decides when/how to forward to the real subject {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}. Use a class wrapper when the API is a known interface and you want straightforward debugging {Dùng class bọc khi API là interface rõ và bạn muốn debug thẳng}. Use new Proxy when you need generic interception (form models, debug tooling) and accept harder stack traces {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 — gate operations by role {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);
}
}
Dependency Injection & IoC {DI & IoC}
Inversion of Control (IoC) flips who creates dependencies: not the consumer, but the caller or bootstrap {Inversion of Control (IoC) đảo ai tạo dependency: không phải consumer, mà caller hoặc bootstrap}. Dependency Injection is the usual IoC style in TS: pass deps via constructor (preferred), function parameters, or factory arguments {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}.
Depend on interfaces, not concrete classes {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;
}
}
Untestable new inside vs injectable fake {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'
This is the payoff Part 1 promised: keep real singletons at the edge, inject interfaces everywhere else {Đâ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}. Part 4 — Strategy injected behavior; here we inject services {Phần 4 — Strategy inject hành vi; ở đây inject service}.
The composition root {Composition root}
The composition root is the single place (app entry, main.ts, test beforeEach) where the object graph is wired {Composition root là một chỗ duy nhất (entry app, main.ts, beforeEach test) nơi đồ thị object được nối}. Every other module only declares dependencies {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) };
}
A tiny manual container (no framework) maps tokens to factories — still explicit, still one root {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;
}
Prefer constructor injection over service locator (hidden get('repo') inside methods) — locators lie about dependencies in the signature {Ư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;
}
}
Real web use cases {Use case web thực tế}
- Testable services — inject
UserRepository,PaymentGateway,Clock(Part 7 adapters at the root) {Service test được — injectUserRepository,PaymentGateway,Clock(adapter Phần 7 ở root)}. - Swap HTTP / storage —
fetchclient,localStoragevsmemoryfor SSR tests {Đổi HTTP / storage — clientfetch,localStoragevsmemorycho test SSR}. - Reactive state —
Proxyon store objects (Vue/Pinia-style reactivity, Immer drafts) {State reactive —Proxytrên object store (reactivity kiểu Vue/Pinia, draft Immer)}. - Feature flags / config — inject
AppConfiginstead of readingprocess.envin every module {Feature flag / config — injectAppConfigthay vì đọcprocess.envmọi module}. - Cross-cutting stacks — Part 6 decorators on repos, wired at the composition root {Stack xuyên suốt — decorator Phần 6 trên repo, nối ở composition root}.
- Mocks in tests — same
UserService, fake repo; same Command bus, in-memory queue {Mock trong test — cùngUserService, repo giả; cùng bus Command, hàng đợi in-memory}.
Pitfalls {Cạm bẫy}
Proxyperformance andthis— traps on every access add cost; methods extracted from proxied objects can losethisunless bound or called viaReflect.apply{Hiệu năngProxyvà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}.- Opaque debugging — stack traces through proxies and deep decorator chains (Part 6) frustrate newcomers {Debug mờ — stack qua proxy và chuỗi decorator sâu (Phần 6) làm người mới bực}.
- DI framework overkill — a 12-route app rarely needs a container; a
createApp()function is enough {Framework DI thừa — app 12 route hiếm khi cần container; hàmcreateApp()là đủ}. - Service locator —
container.get(TOKEN)inside business logic hides the graph and encourages globals {Service locator —container.get(TOKEN)trong logic nghiệp vụ che đồ thị và khuyến khích global}. - Circular dependencies —
AneedsB,BneedsA; fix by introducing a third type, events (Part 5), or splitting read/write ports {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}.
Cheat sheet {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());
}
Decision: control access → Proxy; control wiring → DI at composition root; never hide deps in a locator {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. Implement createValidationProxy for { title: string; tags: string[] } that rejects empty titles and non-array tags {Cài createValidationProxy cho { title: string; tags: string[] } từ chối title rỗng và tags không phải mảng}.
Solution {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 a service that does new HttpUserRepository() internally to constructor injection, then write a test that uses a fake repo {Refactor service đang new HttpUserRepository() bên trong sang constructor injection, rồi viết test dùng repo giả}.
Solution {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. Write a createApp() composition root that wires SessionService with real clock/logger, and createTestApp(fixedNow) that wires fakes {Viết composition root createApp() nối SessionService với clock/logger thật, và createTestApp(fixedNow) nối bản giả}.
Solution {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. Implement a CachingProxy UserRepository that delegates to an inner repo and memoizes findById {Cài CachingProxy UserRepository ủy quyền cho repo trong và memo findById}.
Solution {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. Explain in one sentence why service locator hurts tests compared to constructor injection {Giải thích một câu vì sao service locator hại test hơn constructor injection}.
Solution {Lời giải}
Constructor injection makes dependencies visible and replaceable per instance; a locator hides them behind globals you must reset between tests {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}.
CAPSTONE {ĐỒ ÁN}: Build a small checkout facade (Part 7) that accepts an injected pricing Strategy (Part 4), wraps the payment port with a logging Decorator (Part 6), and wire everything in a composition root — no new inside the facade {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}.
Solution {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 strategyKey takeaways {Điểm chính}
- Proxy — same interface, control access (lazy, cache, auth, remote); JS
Proxy+Reflectfor generic interception {Proxy — cùng interface, kiểm soát truy cập (lazy, cache, auth, remote); JSProxy+Reflectcho chặn generic}. - DI / IoC — depend on interfaces; receive implementations from outside; constructor injection is the default {DI / IoC — phụ thuộc interface; nhận implementation từ ngoài; constructor injection là mặc định}.
- Composition root — one place wires the graph; everywhere else stays honest about needs {Composition root — một chỗ nối đồ thị; chỗ khác chỉ khai báo nhu cầu}.
- Avoid service locator — hidden globals and order-dependent tests {Tránh service locator — global ẩn và test phụ thuộc thứ tự}.
- Patterns compose — Strategy + Decorator + Facade + DI is normal in production, not textbook fiction {Pattern ghép — Strategy + Decorator + Facade + DI là thường gặp production, không phải hư cấu sách}.
Series recap {Tổng kết series}
You made it through all 10 parts. Use this checklist to revisit any topic — each link is a standalone deep dive with diagrams, runnable TS, and exercises {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)
You now have a senior pattern vocabulary for TypeScript web work: not to sprinkle patterns everywhere, but to name smells early, choose the smallest fix, and wire systems you can test {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}. Re-read any part when a PR feels like “one more if” or “one more global” — that is the series working as a field guide, not a trophy shelf {Đọ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}. ← Part 9 — State & State Machines