Chương 03 · Class Design

Class Design Mastery

Composition over Inheritance, immutability, equals/hashCode, interface tối thiểu, Dependency Injection, Law of Demeter, Rich domain model — kỹ năng "pre-pattern" tách junior với senior.

1. Composition over Inheritance

"Favor object composition over class inheritance." — Gang of Four (1994), Effective Java Item 18 (Bloch)

Inheritance ("is-a") là công cụ mạnh nhưng dễ tạo coupling chặt và hierarchy fragile. Composition ("has-a") — class chứa object khác như field — linh hoạt hơn, dễ thay runtime, không có vấn đề diamond.

1.1. Vấn đề với inheritance — Bird/Penguin

// ❌ Inheritance — hierarchy ép buộc
class Bird {
  fly() {} eat() {} layEgg() {}
}
class Eagle extends Bird {}     // OK
class Sparrow extends Bird {}   // OK
class Penguin extends Bird {
  fly() { throw new Error('Penguins do not fly'); }   // ❌ vi phạm LSP
}
class Kiwi extends Bird {
  fly() { throw new Error('Kiwis do not fly'); }      // ❌ tương tự
}

Cây phân loại sinh học không khớp behavioral classification. Mở rộng: Duck — bay, bơi, đi bộ — phải kế thừa từ đâu?

1.2. Composition — gắn behavior

// ✓ Composition — bird có behavior, không "là" behavior
interface FlyBehavior   { fly(): void; }
interface SwimBehavior  { swim(): void; }
interface QuackBehavior { quack(): void; }

class FlyWithWings  implements FlyBehavior   { fly()   { console.log('flying'); } }
class CannotFly     implements FlyBehavior   { fly()   { /* nothing */ } }
class SwimNormal    implements SwimBehavior  { swim()  { console.log('swimming'); } }
class CannotSwim    implements SwimBehavior  { swim()  { /* nothing */ } }

class Bird {
  constructor(
    private flyBehavior: FlyBehavior,
    private swimBehavior: SwimBehavior
  ) {}

  fly()  { this.flyBehavior.fly(); }
  swim() { this.swimBehavior.swim(); }

  // Có thể đổi behavior runtime:
  setFlyBehavior(b: FlyBehavior) { this.flyBehavior = b; }
}

// Tạo bird đa dạng:
const eagle   = new Bird(new FlyWithWings(), new CannotSwim());
const penguin = new Bird(new CannotFly(),    new SwimNormal());
const duck    = new Bird(new FlyWithWings(), new SwimNormal());

// Penguin gãy cánh? Đổi behavior:
penguin.setFlyBehavior(new CannotFly());   // không cần subclass mới

Đây chính là Strategy Pattern (Ch7) — composition là nền cho rất nhiều pattern.

1.3. Khi NÀO inheritance vẫn đúng

  • Thực sự "is-a" và hành vi phù hợp (không chỉ tên).
  • Subclass mở rộng, không hạn chế hành vi parent.
  • Hierarchy không sâu (≤ 2 cấp thường an toàn).
  • Parent là abstract/interface, không phải concrete với state.

Ví dụ tốt: UnsupportedOperationException extends RuntimeException extends Exception — tăng dần specificity.

1.4. Quy tắc "kế thừa từ class bạn không kiểm soát"

Tránh extend class third-party. Joshua Bloch: "Inheritance is only safe within a package." Class bạn không kiểm soát có thể đổi behavior parent ở version sau, gãy subclass của bạn. Thay vì extend, dùng composition + delegation (Decorator pattern).

2. Immutability — bất biến

Object immutable không thể thay đổi sau khi tạo. Mọi "thay đổi" trả về object MỚI. Lợi ích: thread-safe miễn phí, không cần defensive copy, dễ reasoning, không bug "ai đó sửa state của tôi".

2.1. Cách tạo immutable class

class Money {
  constructor(
    public readonly amount: number,    // readonly = chỉ gán 1 lần
    public readonly currency: string
  ) {
    if (amount < 0) throw new Error('amount must be non-negative');
    Object.freeze(this);   // runtime ngăn mọi sửa đổi
  }

  // Mọi "thay đổi" trả Money mới:
  add(other: Money): Money {
    if (this.currency !== other.currency) throw new Error('currency mismatch');
    return new Money(this.amount + other.amount, this.currency);
  }

  multiply(factor: number): Money {
    return new Money(this.amount * factor, this.currency);
  }

  toString() { return `${this.amount} ${this.currency}`; }
}

const a = new Money(100, 'USD');
const b = a.add(new Money(50, 'USD'));   // b mới, a vẫn là 100 USD
console.log(a.toString());   // "100 USD"
console.log(b.toString());   // "150 USD"

2.2. Defensive copy với mutable input

Nếu constructor nhận mutable object (array, Date), copy để bảo vệ:

class Period {
  private readonly _start: Date;
  private readonly _end: Date;

  constructor(start: Date, end: Date) {
    // Defensive copy — caller có thể sửa Date của họ về sau
    this._start = new Date(start);
    this._end = new Date(end);
    if (this._start > this._end) throw new Error('invalid range');
  }

  // Trả copy, không trả tham chiếu nội bộ
  get start() { return new Date(this._start); }
  get end()   { return new Date(this._end); }
}

2.3. Cost của immutability

  • Tạo object mới mỗi lần "thay đổi" → memory + GC pressure.
  • Hot loop với immutable object có thể chậm.

Trade-off: với value object nhỏ và domain entity, immutability đáng giá. Với buffer/big array, mutable phù hợp hơn.

Persistent data structure (Immutable.js, Immer) giúp "structural sharing" — tạo "phiên bản" mới chỉ copy diff, đỡ overhead.

2.4. readonly không phải immutable

class Foo {
  readonly tags: string[] = [];   // readonly field, KHÔNG immutable
}
const f = new Foo();
f.tags = [];        // ❌ readonly
f.tags.push('a');   // ✓ array bên trong vẫn mutable!

// Sửa: ReadonlyArray + freeze
class Bar {
  readonly tags: ReadonlyArray<string>;
  constructor(tags: string[]) {
    this.tags = Object.freeze([...tags]);
  }
}
Quy tắc thực dụng Mặc định immutable cho Value Object (Money, Address, Email). Mutable cho Entity (User, Order) nhưng chỉ thay đổi qua method, không bao giờ qua field setter trực tiếp.

3. Value vs Reference Semantics

3.1. Phân biệt

Hai object có thể "bằng nhau" theo 2 nghĩa:

Reference equalityValue equality
So sánha === b (cùng object trong RAM)cùng giá trị thuộc tính
Phù hợp vớiEntity (User, Order — định danh qua ID)Value Object (Money, Email)
Mutable?Thường có thểLuôn immutable
const a = new Money(100, 'USD');
const b = new Money(100, 'USD');

a === b;          // false — 2 object khác nhau trong RAM
a.equals(b);      // true — cùng giá trị, nếu định nghĩa equals đúng

3.2. Value Object — quy tắc

  • Immutable.
  • Định danh bởi giá trị thuộc tính (không có ID).
  • Có method equals() + hashCode() dựa trên thuộc tính.
  • toString() hữu ích.
  • Tự validate ở constructor.
class Email {
  private static REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

  constructor(public readonly value: string) {
    if (!Email.REGEX.test(value)) throw new Error('invalid email');
  }

  get domain() { return this.value.split('@')[1]; }

  equals(other: Email): boolean {
    return this.value.toLowerCase() === other.value.toLowerCase();
  }

  toString() { return this.value; }
}

// Dùng:
const e1 = new Email('alice@x.com');
const e2 = new Email('ALICE@X.COM');
e1.equals(e2);   // true (case-insensitive)

3.3. Primitive Obsession — antipattern

Lưu email, money, address dưới dạng string/number raw. Vấn đề:

  • Validate scattered ở mọi nơi dùng.
  • Lẫn lộn: function transfer(from: string, to: string, amount: number) — dễ swap params.
  • Type system không bảo vệ — userIdorderId đều string, hoán đổi nhầm không bị catch.
// ❌ Primitive obsession:
function transfer(fromAccount: string, toAccount: string, amount: number) {}

// ✓ Branded types — Type-safe primitive:
type AccountId = string & { readonly __brand: 'AccountId' };
type UserId    = string & { readonly __brand: 'UserId' };

function transfer(from: AccountId, to: AccountId, amount: Money) {}

const userId = 'u_123' as UserId;
const accId  = 'a_456' as AccountId;
transfer(userId, accId, ...);   // ❌ Compile error — kiểu không khớp

4. equals & hashCode contract

Trong Java, mọi class kế thừa Object.equals()Object.hashCode(). Override phải tuân contract:

4.1. Contract của equals

  1. Reflexive: x.equals(x) = true.
  2. Symmetric: x.equals(y)y.equals(x).
  3. Transitive: x.equals(y) && y.equals(z)x.equals(z).
  4. Consistent: gọi nhiều lần kết quả như nhau (nếu state không đổi).
  5. x.equals(null) = false.

4.2. Contract của hashCode

  • Nếu x.equals(y) = true, BẮT BUỘC x.hashCode() == y.hashCode().
  • Ngược lại: x.hashCode() == y.hashCode() KHÔNG bắt buộc equals.

Vi phạm contract → HashMap, HashSet hoạt động sai (object "biến mất" khỏi set).

4.3. Implement đúng (Java)

public final class Money {
    private final long cents;
    private final String currency;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Money)) return false;
        Money m = (Money) o;
        return cents == m.cents && currency.equals(m.currency);
    }

    @Override
    public int hashCode() {
        return Objects.hash(cents, currency);   // dùng helper
    }
}

4.4. JavaScript / TypeScript

JS không có equals tự động — === chỉ là reference. Tự định nghĩa:

class Money {
  constructor(public readonly cents: number, public readonly currency: string) {}

  equals(other: unknown): boolean {
    if (!(other instanceof Money)) return false;
    return this.cents === other.cents && this.currency === other.currency;
  }
}

// Set/Map dùng reference equality → không tự dùng được:
const set = new Set();
set.add(new Money(100, 'USD'));
set.has(new Money(100, 'USD'));   // false — 2 object khác

// Workaround: serialize key
const map = new Map();
map.set('100|USD', new Money(100, 'USD'));

Hoặc dùng thư viện immutable.js với value-equality built-in.

5. Interface design — minimal API surface

Quy tắc Joshua Bloch:

"When in doubt, leave it out. APIs, like organisms, evolve. It's easy to add a method later — impossible to remove."

5.1. Quy tắc

  • Public ít nhất có thể — private mặc định, public khi cần thiết.
  • Tham số ít — > 4 tham số là code smell, gom thành object.
  • Tên method rõ nghĩaorder.markPaid() rõ hơn order.update(2).
  • Một method một việc — không có flag doSomething(force: boolean) điều khiển 2 hành vi khác nhau.
  • Tránh primitive obsession — dùng VO thay raw string.

5.2. Boolean parameter — code smell

// ❌ Khó đọc, dễ swap
file.copy('a.txt', 'b.txt', true, false);   // true là gì?

// ✓ Tách method
file.copyOverwriting('a.txt', 'b.txt');
file.copyPreservingTimestamp('a.txt', 'b.txt');

// HOẶC named parameter object:
file.copy({ src: 'a.txt', dst: 'b.txt', overwrite: true, preserveTimestamp: false });

5.3. Method chaining (fluent interface)

class QueryBuilder {
  private filters: Filter[] = [];
  private sortBy: string | null = null;
  private limitN = 100;

  where(f: Filter): this { this.filters.push(f); return this; }
  orderBy(col: string): this { this.sortBy = col; return this; }
  limit(n: number): this { this.limitN = n; return this; }

  build(): Query { /* ... */ }
}

const q = new QueryBuilder()
  .where({ col: 'active', op: '=', val: true })
  .orderBy('created_at')
  .limit(20)
  .build();

6. Dependency Injection

Đã đề cập DIP ở Ch2. Đây ta đi sâu cách thực thi.

6.1. Constructor injection (khuyên dùng)

class OrderService {
  constructor(
    private orders: OrderRepository,
    private mailer: NotificationSender,
    private clock: Clock = new SystemClock()   // có default
  ) {}

  async checkout(order: Order) { /* ... */ }
}

// Wire ở composition root:
const service = new OrderService(
  new PostgresOrderRepo(db),
  new SendgridSender(apiKey),
);

Lợi: dependency rõ trong signature, immutable sau khi tạo, dễ test.

6.2. Setter injection

class OrderService {
  private mailer?: NotificationSender;
  setMailer(m: NotificationSender) { this.mailer = m; }
}

Phù hợp khi dependency thực sự optional. Nhược: object có thể "chưa hoàn chỉnh" khi sử dụng.

6.3. Method injection

class ReportGenerator {
  generate(orders: Order[], formatter: Formatter): string {
    return orders.map(o => formatter.format(o)).join('\n');
  }
}

Phù hợp khi dependency dùng 1 lần cho 1 method, không phải state lâu dài.

6.4. DI Container

Khi app lớn, manual wiring trở nên rườm rà. DI container (NestJS, Spring, .NET DI) tự động wire qua decorator/annotation:

// NestJS style:
@Injectable()
class OrderService {
  constructor(
    private readonly orders: OrderRepository,
    private readonly mailer: NotificationSender,
  ) {}
}

@Module({
  providers: [
    OrderService,
    { provide: OrderRepository, useClass: PostgresOrderRepository },
    { provide: NotificationSender, useClass: SendgridSender },
  ],
})
class AppModule {}

6.5. Khi DI quá tay

  • App nhỏ < 10 service: manual wiring đủ rõ.
  • "Inject" stateless utility (Math, Date.now) — thường overkill.
  • Tạo interface chỉ để có thể inject, dù chỉ 1 implementation: trừ khi cho test, là wasted.

7. Law of Demeter — "Don't talk to strangers"

Method M của class C chỉ nên gọi method của:

  1. Chính C.
  2. Object được tạo trong M.
  3. Tham số truyền vào M.
  4. Field của C.

Không nên gọi method của object trả về từ method khác (chuỗi a.b().c().d()).

// ❌ Vi phạm — "train wreck"
const country = order.getCustomer().getAddress().getCountry().getCode();

// Vi phạm vì:
// 1. order biết Customer (OK — field)
// 2. order biết về Address (CHỈ qua Customer — không nên)
// 3. order biết về Country (càng tệ)
// Nếu Customer đổi cấu trúc, order code phải sửa.

// ✓ Encapsulate trên Customer:
class Customer {
  private address: Address;
  getCountryCode(): string { return this.address.getCountryCode(); }
}

class Address {
  private country: Country;
  getCountryCode(): string { return this.country.code; }
}

const country = order.getCustomer().getCountryCode();
// HOẶC tốt hơn:
const country = order.getCustomerCountryCode();

7.1. Khi vi phạm OK

Fluent builder, jQuery, query builder — chuỗi method là chính API:

const q = qb.where(...).orderBy(...).limit(20);   // không vi phạm Demeter — đây là DSL

Demeter cảnh báo về chuỗi navigation qua đối tượng khác, không phải fluent API trên cùng builder.

7.2. Tinh thần

Demeter giúp giảm coupling. Class A biết quá nhiều về cấu trúc của Class B → A vỡ khi B đổi. Encapsulate "navigation path" vào method công khai.

8. Anemic vs Rich Domain Model

8.1. Anemic — "OOP thiếu máu"

// ❌ Class chỉ chứa data, getter/setter
class Order {
  id: string = '';
  items: LineItem[] = [];
  status: string = 'pending';
  total: number = 0;
  // chỉ getter/setter
}

// Logic ở "service" bên ngoài
class OrderService {
  addItem(order: Order, item: LineItem) {
    order.items.push(item);
    order.total += item.price * item.qty;
  }

  cancel(order: Order) {
    if (order.status === 'shipped') throw new Error('cannot cancel shipped');
    order.status = 'cancelled';
  }
}

Vấn đề:

  • Logic scatter ở service bên ngoài.
  • Có thể bypass: order.status = 'cancelled' trực tiếp.
  • Encapsulation = 0. Mọi field public hoặc có setter.
  • Nhiều service operate trên cùng entity → khó trace.

8.2. Rich Domain Model

// ✓ Class có behavior, bảo vệ invariant
class Order {
  private constructor(
    public readonly id: OrderId,
    private _items: LineItem[],
    private _status: OrderStatus
  ) {}

  static create(id: OrderId): Order {
    return new Order(id, [], 'pending');
  }

  // Method bảo vệ invariant
  addItem(item: LineItem): void {
    if (this._status !== 'pending') {
      throw new Error('Cannot add to non-pending order');
    }
    this._items = [...this._items, item];
  }

  cancel(): void {
    if (this._status === 'shipped') {
      throw new Error('Cannot cancel shipped order');
    }
    this._status = 'cancelled';
  }

  total(): Money {
    return this._items
      .map(i => i.subtotal())
      .reduce((s, m) => s.add(m), Money.zero());
  }

  get status() { return this._status; }
  get items(): ReadonlyArray<LineItem> { return this._items; }
}

// Service mỏng — chỉ điều phối:
class OrderService {
  constructor(private orders: OrderRepository) {}

  async cancelOrder(id: OrderId) {
    const order = await this.orders.findById(id);
    if (!order) throw new NotFound();
    order.cancel();   // logic trong Order, không trong service
    await this.orders.save(order);
  }
}

Lợi:

  • Invariant đảm bảo — không thể bypass.
  • Logic tập trung — đọc Order là hiểu Order làm gì.
  • Service mỏng, dễ refactor.

8.3. Khi anemic OK

  • DTO (Data Transfer Object) — class chỉ để serialize/deserialize, không có business logic.
  • Read model trong CQRS — chỉ projection cho hiển thị.
  • Form / view model — chỉ binding với UI.

Nhưng đừng nhầm DTO với Domain Entity — chúng là 2 khái niệm khác nhau.

9. Naming & Cohesion

9.1. Class names — danh từ cụ thể

  • OrderRepository, EmailValidator, PaymentProcessor
  • Manager, Helper, Utility — quá generic, code smell
  • OrderManager — class này thực sự làm gì? Quá rộng. Tên cụ thể hơn: OrderRefundProcessor.

9.2. Method names — verb cụ thể

  • order.cancel(), user.activate(), cart.checkout()
  • process(), handle(), execute() — vague
  • doIt(), run() — vague

9.3. Cohesion — class "đồng nhất"

Class có cohesion cao = mọi method dùng cùng các field. Cohesion thấp → nhiều "subgroup" method, dấu hiệu nên tách class.

// ❌ Cohesion thấp — 2 nhóm method độc lập
class UserStuff {
  // Group 1: dùng email, name
  validateEmail() { /* dùng this.email */ }
  formatDisplayName() { /* dùng this.name */ }

  // Group 2: dùng password, lastLogin
  hashPassword() { /* dùng this.password */ }
  recordLogin() { /* dùng this.lastLogin */ }
}
// → Tách thành Profile và Credential

10. Bài tập

  1. Refactor đoạn code sau từ inheritance sang composition. Bird, Penguin, Duck. Cho phép mỗi loài có hành vi fly/swim/quack tổ hợp tự do.
  2. Tạo Value Object EmailAddress immutable: validate format, equals case-insensitive, toString. Implement đầy đủ.
  3. Tạo Value Object Money với add, subtract, multiply, equals, throw error khi cộng/trừ tiền tệ khác nhau.
  4. Branded type cho TypeScript: tạo UserIdOrderId branded types. Demo type safety: function nhận UserId không nhận OrderId.
  5. Refactor Anemic sang Rich:
    class Cart {
      items: any[] = [];
      total: number = 0;
    }
    class CartService {
      add(cart, item) { cart.items.push(item); cart.total += item.price; }
      remove(cart, idx) { cart.total -= cart.items[idx].price; cart.items.splice(idx, 1); }
      checkout(cart) { if (cart.items.length === 0) throw; }
    }
  6. Đoạn code vi phạm Law of Demeter. Tìm và sửa:
    if (order.getCustomer().getMembership().getTier() === 'GOLD') {
      applyDiscount();
    }
  7. Khi nào constructor injection vs setter injection? Cho 2 ví dụ thực tế của mỗi loại.

11. Quiz

Quiz cuối Chương 3

"Composition over Inheritance" có nghĩa là:

  • Không bao giờ dùng inheritance
  • Khi nghi ngờ, dùng "has-a" (chứa object khác như field) thay vì "is-a" (kế thừa)
  • Composition luôn nhanh hơn
  • Inheritance đã lỗi thời
Inheritance tạo coupling chặt giữa parent-child, fragile với thay đổi parent. Composition linh hoạt hơn — đổi behavior runtime, dễ test, không vấn đề diamond. Vẫn dùng inheritance khi có "is-a" thực sự + behavior phù hợp.

Value Object KHÔNG có đặc điểm:

  • Immutable
  • Định danh qua giá trị thuộc tính
  • Có ID duy nhất phân biệt
  • Có equals dựa trên thuộc tính
Value Object KHÔNG có ID — đó là đặc điểm của Entity. Money 100 USD = Money 100 USD bất kể "instance nào". Money/Email/Address/Coordinate là VO. User/Order/Account là Entity (có ID).

Contract của equals:

  • Reflexive only
  • Symmetric only
  • Transitive only
  • Reflexive + symmetric + transitive + consistent + null-safe
5 luật. Vi phạm bất kỳ luật nào → HashMap/HashSet hoạt động sai. Đặc biệt: nếu equals(y), thì BẮT BUỘC hashCode bằng nhau (contract giữa 2 method).

Anemic Domain Model:

  • Class chỉ chứa data + getter/setter, mọi logic ở service bên ngoài → mất encapsulation
  • Class quá nhỏ
  • Class không có inheritance
  • Class chỉ có method static
Anti-pattern phổ biến với ORM/Spring style. Order chỉ có id/items/total + setters; OrderService.cancel(order) làm logic. Mất encapsulation: ai cũng có thể order.status='shipped' bypass mọi rule. Rich Model: logic trong class, service mỏng.

Law of Demeter cảnh báo về:

  • Inheritance đa cấp
  • Quá nhiều generic
  • Chuỗi method navigation kiểu a.b().c().d() — tạo coupling chặt với cấu trúc nội bộ
  • Chỉ dùng trong Java
"Talk to friends, not strangers". Class A gọi a.b().c() = A biết về cấu trúc của B. B đổi → A gãy. Encapsulate qua method trên A hoặc B. Lưu ý: fluent API (builder) trên cùng object KHÔNG vi phạm.

Constructor Injection so với Setter Injection:

  • Setter dễ dùng hơn
  • Constructor: dependency rõ trong signature, immutable sau tạo, dễ test — khuyến nghị mặc định
  • Setter cần ít code hơn
  • Hai cách giống nhau
Constructor injection là pattern khuyến nghị: object hoàn chỉnh ngay sau new, dependency thấy rõ trong constructor signature, có thể đặt private final. Setter injection dùng khi dependency thực sự optional.

Primitive Obsession là khi:

  • Dùng quá nhiều if/else
  • Code không có constant
  • Class không có method
  • Lưu domain concept (email, money, id) dưới dạng string/number raw thay vì tạo Value Object riêng
Hệ quả: validate scattered, swap params không bị catch, type system không bảo vệ. Sửa: tạo Email/Money/UserId class hoặc branded type. Lợi: validate 1 chỗ, type-safe, behavior gắn liền (Email có domain, Money có add).

readonly field array trong TypeScript:

  • Field tham chiếu không đổi, nhưng array bên trong VẪN có thể push/splice — phải dùng ReadonlyArray + Object.freeze để thực sự immutable
  • Array tự động immutable
  • Cấm dùng push
  • Compile error
readonly tags: string[] = không thể gán tags = [], nhưng tags.push('x') vẫn được. Để immutable thực sự: readonly tags: ReadonlyArray<string> + freeze ở constructor.

Hoàn thành Chương 3. Tiếp theo: Chương 4 — Creational Patterns (5 mẫu) →