← Clean Code: viết code dễ đọc

Bài 6 · Vận dụng · 20 phút

Object vs Data Structure

Biên soạn bởi Nguyễn Anh Tuấn

Bất đối xứng object/data: object giấu dữ liệu lộ hành vi, data structure thì ngược lại; tránh hybrid; Luật Demeter (Law of Demeter) tránh "train wreck".

Có hai cách tổ chức code rất khác nhau mà người mới hay lẫn lộn. Object giấu dữ liệu bên trong, chỉ lộ ra hành vi (method). Data structure ngược lại: lộ dữ liệu ra ngoài và (gần như) không có hành vi.

Xem cùng bài toán hình học viết theo hai cách:

Hình học kiểu OOP - object giấu dữ liệu, lộ hành vi

// Moi hinh biet tinh dien tich cua chinh no
interface Shape {
  area(): number;
}

class Circle implements Shape {
  constructor(private radius: number) {}
  area() { return Math.PI * this.radius * this.radius; }
}

class Rect implements Shape {
  constructor(private width: number, private height: number) {}
  area() { return this.width * this.height; }
}

function printArea(s: Shape) {
  console.log(s.area().toFixed(2));
}
printArea(new Circle(5));  // 78.54
printArea(new Rect(4, 6)); // 24.00

Hình học kiểu data + function - data lộ ra, hàm switch theo loại

// Data structure: field cong khai, khong co method
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'rect';   width: number; height: number };

// Ham xu ly dieu phoi tu ben ngoai
function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.radius * s.radius;
    case 'rect':   return s.width * s.height;
  }
}

console.log(area({ kind: 'circle', radius: 5 }).toFixed(2));  // 78.54
console.log(area({ kind: 'rect', width: 4, height: 6 }).toFixed(2)); // 24.00
  • Object: dữ liệu ẩn, hành vi lộ ra - caller không biết bên trong là gì.
  • Data structure: field công khai, caller tự điều phối logic từ ngoài.
  • Cả hai đều hợp lệ - sự khác biệt nằm ở hướng mở rộng (xem Bước 2).

Hai cách tổ chức trên tạo ra một bất đối xứng thú vị. Mỗi cách dễ mở rộng theo một hướng và khó theo hướng kia - đây là đánh đổi đã chốt, không phải lỗi thiết kế:

  • OOP: thêm kiểu hình mới (Triangle, Polygon) - chỉ tạo class mới, không sửa code cũ. Nhưng thêm method mới (perimeter) - phải sửa mọi class đã có.
  • Data + function: thêm thao tác mới (perimeter, serialize) - chỉ thêm hàm, không sửa type. Nhưng thêm kiểu mới - phải thêm case vào mọi hàm switch.
  • Câu hỏi chọn cách: "Dự án này hay thêm kiểu hay hay thêm thao tác?" - chọn cách dễ theo hướng đó.

Không có đúng tuyệt đối

Đây là đánh đổi đã được giới lập trình quan sát kỹ - không phải lỗi mà ai đó chưa nghĩ ra cách giải. Hiểu đánh đổi này giúp bạn chọn đúng ngay từ đầu thay vì nhận ra sau khi đã viết 50 class rồi. Trong thực tế, codebase thường có cả hai: OOP ở tầng nghiệp vụ, data structure ở ranh giới (API, DB).

Cái tệ nhất không phải là chọn OOP hay data - mà là hybrid: vừa public field vừa có method nghiệp vụ trong cùng một class. Hybrid này tệ cả hai hướng:

❌ Hybrid - tệ cả hai hướng

// DTO hay object? Khong ro - do la hybrid
class Order {
  public customerId: string = '';   // public field - kieu data structure
  public items: string[] = [];      // public field
  public status: string = 'new';    // public field

  // Nhung lai co method nghiep vu - kieu object
  canCancel(): boolean {
    return this.status === 'new' || this.status === 'pending';
  }
  totalItems(): number {
    return this.items.length;
  }
}

// Van de: them kieu moi -> phai dong cham ca field lan method
// Them thao tac moi -> cung phai mo class nay ra
// Khong ro rang nen doi xu nhu du lieu hay object

✅ Tách rõ: data structure cho DTO, object cho nghiệp vụ

// Data structure: chi du lieu, kieu ro rang
interface OrderDto {
  customerId: string;
  items: string[];
  status: 'new' | 'pending' | 'shipped' | 'cancelled';
}

// Object: data an, chi lo method
class Order {
  constructor(private data: OrderDto) {}

  canCancel(): boolean {
    return this.data.status === 'new' || this.data.status === 'pending';
  }
  itemCount(): number {
    return this.data.items.length;
  }
}
  • Hybrid: vừa public field vừa nghiệp vụ - caller không biết đối xử như dữ liệu hay object.
  • Tách rõ: interface/type cho data structure thuần, class cho object có hành vi.
  • Khi thấy mình đang thêm method vào DTO, dừng lại và hỏi: logic này có nên là một hàm độc lập không?

Law of Demeter (Luật Demeter): một method chỉ nên gọi method của những "người quen trực tiếp" - object mình sở hữu, tham số nhận vào, object mình tự tạo ra - không leo qua object trả về để gọi tiếp.

Chuỗi `.getB().getC().doX()` gọi là train wreck (tàu hỏa trật bánh): caller biết quá nhiều về cấu trúc bên trong của từng tầng. Đổi cấu trúc bên trong là vỡ dây chuyền ngay.

❌ Train wreck - leo qua nhiều lớp

// Caller biet: Department co manager, manager co address, address co city
// Doi ten truong? Doi kieu? Vo day chuyen ngay
const city = department.getManager().getAddress().getCity();

// Te hon nua: lai leo het chuoi mot lan nua de quyet dinh
if (city === 'Ha Noi') {
  applyHanoiPolicy(department.getManager().getAddress().getCity());
}

✅ Tell, Don't Ask - bảo object tự xử lý

// Them method vao Department: no tu biet lay city o dau
class Department {
  private manager: Employee;
  constructor(manager: Employee) { this.manager = manager; }

  // "Tell don't ask": dong goi logic, caller khong can biet cau truc ben trong
  managerCity(): string {
    return this.manager.address.city;
  }
}

// Caller chi goi mot method, khong leo qua cac lop
const city = department.managerCity();
  • Train wreck: `a.getB().getC().doX()` - leo qua nhiều lớp, coupling cứng với cấu trúc bên trong.
  • "Tell, Don't Ask": thêm method vào object phù hợp để gói logic, caller chỉ bảo "làm đi".
  • Luật Demeter áp dụng với object (giấu dữ liệu) - không áp dụng cho data pipeline như `.filter().map().join()`.

DTO (Data Transfer Object) là data structure thuần dùng để truyền dữ liệu qua ranh giới - từ API vào, ra DB, giữa các layer. Trong TypeScript, interface hoặc type là lựa chọn chuẩn cho DTO - không cần class khi không có hành vi:

DTO trong TypeScript - interface cho dữ liệu, class cho object nghiệp vụ

// interface / type: compile-time only, khong sinh JS thua - chon cho DTO
interface UserDto {
  id: string;
  name: string;
  email: string;
  createdAt: string; // ISO 8601 string tu API
}

interface OrderDto {
  orderId: string;
  totalAmount: number;
  customerEmail: string;
}

// Dung o bien: API response, tham so truyen qua layer
function processOrder(order: OrderDto): void {
  console.log(order.orderId, order.totalAmount);
  // ORD-001 350000
}

// class: khi can instanceof, constructor validation, hoac default phuc tap
class Money {
  constructor(
    private amount: number,
    private currency: string
  ) {
    if (amount < 0) throw new Error('So tien khong the am');
  }
  // Object co hanh vi: format, cong, tru...
  format(): string { return `${this.amount} ${this.currency}`; }
}
  • DTO = data structure cho ranh giới: interface/type là đủ và thường tốt hơn class.
  • Dùng class khi cần instanceof, constructor validation, hoặc method thực sự cần thiết.
  • Không bao giờ đặt nghiệp vụ vào DTO - đó là dấu hiệu của hybrid.

Bài tiếp theo: Xử lý lỗi sạch

Ở các ranh giới (DTO đi vào, kết quả đi ra) luôn có khả năng xảy ra lỗi. Bài tiếp theo Xử lý lỗi sạch bàn về cách dùng exception thay mã lỗi, đừng trả null, và bọc lỗi bên thứ ba - những kỹ thuật phối hợp trực tiếp với cách tổ chức object/data bài này.

Câu hỏi thường gặp

Câu hỏi đúng cần hỏi là: "hướng mở rộng nào hay xảy ra hơn?" Nếu thường xuyên thêm kiểu mới (Circle, Rect, Triangle...) mà ít thêm thao tác - dùng OOP: mỗi kiểu implement interface, không sửa code cũ. Nếu thường xuyên thêm thao tác (vẽ, serialize, in...) mà ít thêm kiểu - dùng data + function: thêm hàm mới không đụng vào dữ liệu. Không có "đúng tuyệt đối", chỉ có đúng với hướng mở rộng của dự án.

Không hẳn. Luật Demeter áp dụng cho object (giấu dữ liệu): bạn chỉ gọi method của đối tượng bạn sở hữu trực tiếp - không thò tay qua một object trả về để gọi tiếp. Nhưng nếu bạn đang làm việc với data structure (fluent builder, mảng, chuỗi...) thì chuỗi gọi .filter().map().join() là bình thường - đó là data pipeline, không phải train wreck.

Interface (hoặc type alias) là đủ và thường là lựa chọn tốt hơn cho DTO trong TypeScript. Interface là compile-time only, không sinh JS thừa. Dùng class khi bạn cần: (1) runtime instanceof check, (2) constructor validation, (3) default value phức tạp. Trong hầu hết trường hợp truyền dữ liệu API/DB, interface là chuẩn.

Vấn đề là ở ranh giới bị mờ. Khi class vừa có public field vừa có method nghiệp vụ, người đọc không biết nên đối xử với nó như dữ liệu hay như object. Nếu thêm kiểu mới, phải sửa cả field lẫn method. Nếu thêm thao tác mới, lại phải chui vào class đang dùng làm DTO. Cả hai hướng đều tệ hơn nếu bạn chọn đúng một trong hai.

"Tell, Don't Ask" nghĩa là thay vì hỏi object lấy dữ liệu rồi tự ra quyết định bên ngoài, hãy bảo object tự xử lý. Ví dụ: thay vì if (account.getBalance() > amount) account.setBalance(account.getBalance() - amount) - hãy dùng account.withdraw(amount). Nguyên tắc này áp dụng khi bạn thấy mình get dữ liệu từ object, tính toán, rồi set lại - đó là dấu hiệu logic đang ở sai chỗ.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

OOP (mỗi Shape có method area()) dễ mở rộng theo hướng nào?

  1. 1

    Phân loại code hiện tại

    Chọn một class/module trong dự án của mèo con. Phân loại: object (giấu dữ liệu, lộ method) hay data structure (lộ field)? Hay hybrid?

    Hoàn thành khi: Một đoạn phân tích ngắn: class đó thiên về object hay data; nếu là hybrid, chỉ ra đâu là field công khai và đâu là method nghiệp vụ cùng chỗ.

  2. 2

    Hướng mở rộng nào hay xảy ra

    Với module vừa phân loại, hỏi: "Dự án hay thêm kiểu mới hay thêm thao tác mới hơn?" Từ đó đề xuất nên dùng OOP hay data + function.

    Hoàn thành khi: Một lập luận ngắn (3-5 câu) chứng minh lựa chọn bằng lịch sử thay đổi thực tế của dự án.

  3. 3

    Tìm và xoá train wreck

    Tìm một "train wreck" (a.getB().getC().doX()) trong code. Refactor theo "Tell, Don't Ask": thêm một method vào class phù hợp để gói hành vi đó lại.

    Hoàn thành khi: Code trước và sau; phần gọi chỉ còn gọi một method; hành vi không đổi.

  4. 4

    Tạo DTO đúng cách

    Lấy một chỗ đang dùng object thô (plain object hoặc any) để truyền qua ranh giới (API response, tham số hàm lớn). Định nghĩa lại bằng TypeScript interface với tên rõ ràng.

    Hoàn thành khi: Một interface đặt tên rõ, các field có kiểu cụ thể; chỗ dùng compile không lỗi.

  5. 5

    Xây hình học kiểu data

    Viết lại ví dụ hình học theo kiểu data + function: định nghĩa type Shape, viết hàm area(s: Shape) dùng switch, thêm case triangle mà không sửa type nào cũ.

    Hoàn thành khi: Hàm area xử lý ít nhất 3 loại hình; thêm loại mới chỉ sửa type union và thêm case vào hàm.

  6. 6

    So sánh hai hướng mở rộng

    Lấy ví dụ hình học OOP (interface Shape, mỗi class implement area()). Thêm một phương thức perimeter() vào tất cả hình theo cách OOP - phải sửa bao nhiêu file? Rồi thử với cách data + function. Ghi lại quan sát.

    Hoàn thành khi: Mô tả số file cần sửa theo mỗi hướng và kết luận về hướng mở rộng nào dễ hơn trong từng trường hợp.