jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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?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ể}.

PROXY Client Proxy check · cache · lazy Real subject same API DEPENDENCY INJECTION Composition root wires deps Service(repo) Repo(db) Logger inject from outside → swap for tests
Proxy controls access to a subject; DI wires collaborators from outside for testability

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 ProxyProxy / 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 ProxyJS Proxy
WhatDesign: surrogate + same interfaceLanguage: intercept object ops
ExamplesVirtual, protection, remote, caching wrapperValidation, reactivity, logging traps
TypingExplicit class implementing SubjectOften 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 — inject UserRepository, PaymentGateway, Clock (adapter Phần 7 ở root)}.
  • Swap HTTP / storagefetch client, localStorage vs memory for SSR tests {Đổi HTTP / storage — client fetch, localStorage vs memory cho test SSR}.
  • Reactive stateProxy on store objects (Vue/Pinia-style reactivity, Immer drafts) {State reactiveProxy trên object store (reactivity kiểu Vue/Pinia, draft Immer)}.
  • Feature flags / config — inject AppConfig instead of reading process.env in every module {Feature flag / config — inject AppConfig thay vì đọc process.env mọi module}.
  • Cross-cutting stacksPart 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ùng UserService, repo giả; cùng bus Command, hàng đợi in-memory}.

Pitfalls {Cạm bẫy}

  • Proxy performance and this — traps on every access add cost; methods extracted from proxied objects can lose this unless bound or called via Reflect.apply {Hiệu năng Proxythis — trap mỗi lần truy cập tốn thêm; method tách khỏi object proxy có thể mất this nếu không bind hoặc gọi qua Reflect.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àm createApp() là đủ}.
  • Service locatorcontainer.get(TOKEN) inside business logic hides the graph and encourages globals {Service locatorcontainer.get(TOKEN) trong logic nghiệp vụ che đồ thị và khuyến khích global}.
  • Circular dependenciesA needs B, B needs A; fix by introducing a third type, events (Part 5), or splitting read/write ports {Phụ thuộc vòngA cần B, B cần A; 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 = '   '; // throws

2. 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 — deterministic

4. 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 strategy

Key takeaways {Điểm chính}

  • Proxy — same interface, control access (lazy, cache, auth, remote); JS Proxy + Reflect for generic interception {Proxy — cùng interface, kiểm soát truy cập (lazy, cache, auth, remote); JS Proxy + Reflect cho 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}.

  1. Part 1 — Singleton & the Module Pattern
  2. Part 2 — Factory & Abstract Factory
  3. Part 3 — Builder & Fluent APIs
  4. Part 4 — Strategy
  5. Part 5 — Observer & Pub/Sub
  6. Part 6 — Decorator & Middleware
  7. Part 7 — Adapter & Facade
  8. Part 8 — Command & Memento
  9. Part 9 — State & State Machines
  10. 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