Skip to content

Dataclass & Practical OOP — OOP cho Backend Thực Tế

Bạn đã viết class trong Python hàng trăm lần. Nhưng hãy tự hỏi: bao nhiêu lần bạn copy-paste cùng một __init__, __repr__, __eq__ cho mỗi model mới? Bao nhiêu lần bạn dùng dict vì "nhanh hơn" rồi trả giá bằng KeyError lúc 3 giờ sáng?

Bài này không dạy OOP lý thuyết. Bài này dạy bạn viết OOP như một backend engineer — nơi dataclass là default, magic methods phục vụ mục đích cụ thể, và domain model tách biệt rõ ràng với DTO.


1. Tại sao dataclass là Default?

🎯 Mục tiêu

  • Hiểu vì sao dataclass nên là lựa chọn mặc định khi tạo data object
  • Nắm được những gì dataclass tự sinh: __init__, __repr__, __eq__
  • Chuyển đổi từ verbose manual class sang dataclass trong 4 dòng

Vấn đề: Verbose boilerplate hoặc raw dict

Trong thực tế, engineer thường rơi vào hai thái cực:

  1. Viết class thủ công — copy-paste __init__, __repr__, __eq__ cho mỗi model. 50 dòng boilerplate cho 3 fields.
  2. Dùng dict cho mọi thứ — "nhanh mà, khỏi tạo class". Rồi 6 tháng sau, không ai biết user["eml"] là typo hay field thật.

Cả hai đều sai. dataclass là giải pháp đúng — stdlib, zero dependency, zero boilerplate.

Before / After: Manual class → dataclass

python
# ❌ Verbose manual class — 15 dòng cho 3 fields
class User:
    def __init__(self, id: int, email: str, is_active: bool = True):
        self.id = id
        self.email = email
        self.is_active = is_active

    def __repr__(self):
        return f"User(id={self.id}, email={self.email})"

    def __eq__(self, other):
        if not isinstance(other, User):
            return NotImplemented
        return self.id == other.id and self.email == other.email
python
# ✅ dataclass — same behavior, 4 dòng
from dataclasses import dataclass

@dataclass
class User:
    id: int
    email: str
    is_active: bool = True

Decorator @dataclass tự sinh cho bạn:

  • __init__ — gán tất cả fields theo thứ tự khai báo
  • __repr__User(id=1, email='tom@example.com', is_active=True)
  • __eq__ — so sánh theo tất cả fields

Bạn viết ít hơn 75% code, nhận đúng 100% behavior.

💡 Khi nào KHÔNG dùng dataclass?

  • Class có behavior phức tạp (nhiều method hơn data) → plain class
  • Cần validation tự động từ input bên ngoài → Pydantic BaseModel
  • Cần kế thừa sâu nhiều tầng → xem lại thiết kế, có thể cần composition

2. Magic Methods Thực Chiến

Đừng học magic methods qua bảng liệt kê 50 dunder methods. Hãy học 5 cái bạn dùng hàng ngày trong backend.

__str__ vs __repr__ — Hai khán giả khác nhau

python
@dataclass
class Order:
    id: int
    total: float
    status: str

    def __str__(self) -> str:
        return f"Order #{self.id}{self.status} ({self.total:,.0f}₫)"

    # __repr__ được dataclass tự sinh:
    # Order(id=42, total=1500000.0, status='confirmed')
MethodKhán giảMục đíchVí dụ output
__str__User / logsHuman-readableOrder #42 — confirmed (1,500,000₫)
__repr__Developer / debuggerUnambiguous, ideally eval-ableOrder(id=42, total=1500000.0, status='confirmed')

Quy tắc: __repr__ luôn có sẵn nhờ dataclass. Chỉ override __str__ khi bạn cần format đẹp cho log hoặc hiển thị.

python
order = Order(id=42, total=1_500_000, status="confirmed")

print(order)       # → Order #42 — confirmed (1,500,000₫)   ← gọi __str__
print(repr(order)) # → Order(id=42, total=1500000, status='confirmed')  ← gọi __repr__

# Trong f-string
logger.info(f"Processing {order}")       # gọi __str__
logger.debug(f"Debug payload: {order!r}") # gọi __repr__

__eq____hash__ — Khi object là dict key

Mặc định, @dataclass sinh __eq__ nhưng set __hash__ thành None — nghĩa là object không thể làm dict key hay nằm trong set.

Tại sao? Vì mutable object + custom __eq__ + hash = bugs khó debug.

Giải pháp: frozen=True — biến dataclass thành immutable, tự động có __hash__.

python
@dataclass(frozen=True)
class NotificationKey:
    user_id: int
    event_type: str

# ✅ Dedup notifications bằng set
seen: set[NotificationKey] = set()

key = NotificationKey(user_id=42, event_type="order_shipped")
seen.add(key)

# Cùng user + event → không gửi nữa
duplicate = NotificationKey(user_id=42, event_type="order_shipped")
print(duplicate in seen)  # True — dedup thành công!

⚠️ Đừng tự viết __hash__ khi có mutable fields

Nếu bạn override __hash__ trên mutable object, hash value có thể thay đổi sau khi object đã nằm trong set hoặc dict. Kết quả: object "biến mất" khỏi collection — không thể tìm lại.

Nguyên tắc: Cần hash → dùng frozen=True. Không có ngoại lệ.

__lt__ — Sorting tự nhiên

Khi bạn cần sorted() hoạt động trên custom objects, chỉ cần __lt__:

python
@dataclass
class Task:
    priority: int  # 1 = cao nhất
    title: str

    def __lt__(self, other: "Task") -> bool:
        return self.priority < other.priority

# sorted() dùng __lt__ để so sánh
pending_tasks = [
    Task(priority=3, title="Update docs"),
    Task(priority=1, title="Fix critical bug"),
    Task(priority=2, title="Code review"),
]

for task in sorted(pending_tasks):
    print(f"[P{task.priority}] {task.title}")
# [P1] Fix critical bug
# [P2] Code review
# [P3] Update docs

💡 order=True — tự sinh tất cả comparison methods

python
@dataclass(order=True)
class Task:
    priority: int
    title: str

Với order=True, dataclass tự sinh __lt__, __le__, __gt__, __ge__ — so sánh theo thứ tự fields khai báo (priority trước, rồi title). Tiện nhưng hãy chắc chắn thứ tự fields đúng ý bạn.


3. dataclass vs attrs vs Pydantic

Ba lựa chọn, ba bài toán khác nhau. Đừng dùng búa tạ đóng đinh.

Bảng so sánh

FeaturedataclassattrsPydantic
Stdlib✅ Có sẵnpip installpip install
Validation❌ Không có@validator✅ Built-in mạnh
Serialization❌ Manual❌ Manual (dùng cattrs)✅ JSON/dict tự động
Performance⚡ Nhanh⚡ Nhanh🐢 Chậm hơn (do validation)
Immutabilityfrozen=Truefrozen=Truemodel_config = frozen
Slotsslots=True (3.10+)slots=TrueTự động
Use caseDomain models, DTOs nội bộPower users, cattrs ecosystemAPI I/O boundary

Rule of thumb — Chọn đúng tool

Bạn cần gì?

├── Chỉ cần data container cho logic nội bộ?
│   └── ✅ @dataclass — zero dependency, đủ dùng

├── Validate input từ API/user?
│   └── ✅ Pydantic BaseModel — validation là core feature

├── Complex validation + muốn tránh Pydantic overhead?
│   └── ✅ attrs + cattrs — flexible, performant

└── Không chắc?
    └── ✅ Bắt đầu với @dataclass — migrate sau nếu cần

💡 Thực tế trong production

80% trường hợp bạn chỉ cần @dataclass. Pydantic cho API boundary. attrs cho khi bạn đã dùng và hiểu ecosystem của nó. Đừng over-engineer lựa chọn này.


4. Domain Models & DTOs cho Backend

Trong backend thực tế, domain model (logic nội bộ) và DTO (data transfer object — giao tiếp với bên ngoài) nên tách biệt. Đây là pattern bạn sẽ gặp ở mọi hệ thống production.

Tại sao tách?

  • Domain model dùng kiểu dữ liệu tối ưu cho logic: price_cents: int (tránh float rounding)
  • DTO dùng kiểu dữ liệu tối ưu cho API consumer: price: float (dễ đọc)
  • Khi database schema thay đổi, API response không bị ảnh hưởng

Pattern thực tế

python
from dataclasses import dataclass
from pydantic import BaseModel

# === Domain Model (internal logic) — dùng dataclass ===
@dataclass
class Product:
    id: int
    name: str
    price_cents: int  # Lưu bằng cents để tránh float rounding issues

    @property
    def price_display(self) -> str:
        return f"{self.price_cents / 100:,.2f}₫"

    def apply_discount(self, percent: int) -> "Product":
        """Return new Product với giá sau giảm."""
        discounted = self.price_cents * (100 - percent) // 100
        return Product(id=self.id, name=self.name, price_cents=discounted)


# === DTO cho API response — dùng Pydantic ===
class ProductResponse(BaseModel):
    id: int
    name: str
    price: float  # Convert sang float cho API consumers

    @classmethod
    def from_domain(cls, product: Product) -> "ProductResponse":
        return cls(
            id=product.id,
            name=product.name,
            price=product.price_cents / 100,
        )


# === Sử dụng ===
product = Product(id=1, name="Cà phê sữa đá", price_cents=45000)
print(product.price_display)  # 450.00₫

response = ProductResponse.from_domain(product)
print(response.model_dump_json())
# {"id": 1, "name": "Cà phê sữa đá", "price": 450.0}

Data flow rõ ràng:

Database → Domain Model (dataclass) → Business Logic → DTO (Pydantic) → API Response

API Request → DTO (Pydantic, validated) → Domain Model → Database

5. Sai lầm điển hình

🔴 Code Smell: Mutable Default Arguments

Đây là trap kinh điển trong Python — không riêng gì dataclass, nhưng dataclass khiến nó dễ mắc hơn.

python
# ❌ CLASSIC TRAP — shared mutable default
@dataclass
class Config:
    name: str
    tags: list[str] = []  # 💀 All instances share this list!

c1 = Config("api")
c1.tags.append("production")
c2 = Config("worker")
print(c2.tags)  # ["production"] — SURPRISE!

Python sẽ raise ValueError nếu bạn dùng mutable default trong dataclass — nhưng chỉ khi bạn dùng đúng @dataclass. Nếu bạn viết class thủ công, trap này im lặng chờ bạn.

Fix: Luôn dùng field(default_factory=...)

python
from dataclasses import dataclass, field

@dataclass
class Config:
    name: str
    tags: list[str] = field(default_factory=list)  # ✅ New list per instance

c1 = Config("api")
c1.tags.append("production")
c2 = Config("worker")
print(c2.tags)  # [] — Đúng! Mỗi instance có list riêng

⚡ Performance: slots=True

@dataclass(slots=True) (Python 3.10+) giảm ~15-20% memory usage và tăng tốc attribute access. Luôn dùng cho domain models.

python
@dataclass(slots=True)
class User:
    id: int
    email: str
    is_active: bool = True

# Kết quả: không có __dict__, attribute access nhanh hơn
# Trade-off: không thể thêm attribute động (user.new_field = "x" → AttributeError)

⚠️ Cạm bẫy

Anti-pattern: Dùng user["email"] thay vì user.email trải khắp 50 files.

python
# ❌ Dict-driven — không autocomplete, không refactor safety
def send_welcome_email(user: dict) -> None:
    email = user["email"]       # KeyError nếu typo → crash lúc 3 AM
    name = user.get("name", "") # Không biết field nào tồn tại
    # ...

# ✅ Model-driven — IDE hỗ trợ, type checker bắt lỗi
@dataclass
class User:
    id: int
    email: str
    name: str

def send_welcome_email(user: User) -> None:
    email = user.email  # Autocomplete ✅, rename refactor ✅
    name = user.name    # Type checker biết field tồn tại ✅

Hậu quả của dict-driven:

  • ❌ Không autocomplete — gõ mò, sai tên field không ai biết
  • ❌ Không refactoring safety — rename một key phải grep toàn project
  • KeyError lúc runtime — không phải lúc compile/lint
  • ❌ Không documentation — field nào optional? Kiểu gì? Ai biết?

Fix: Model domain bằng dataclass / Pydantic, chỉ dùng dict tại boundary (JSON parse, database row).


6. Under the Hood

__post_init__ — Logic sau khi init

Khi bạn cần tính toán derived fields hoặc validate đơn giản:

python
@dataclass
class Rectangle:
    width: float
    height: float
    area: float = field(init=False)  # Không nhận từ __init__

    def __post_init__(self):
        if self.width <= 0 or self.height <= 0:
            raise ValueError("Dimensions must be positive")
        self.area = self.width * self.height

r = Rectangle(width=5, height=3)
print(r.area)  # 15.0

InitVar — Tham số chỉ dùng lúc init

python
from dataclasses import dataclass, field, InitVar

@dataclass
class DatabaseConnection:
    host: str
    port: int
    connection_string: str = field(init=False)
    password: InitVar[str] = ""  # Không lưu vào instance

    def __post_init__(self, password: str):
        self.connection_string = f"postgresql://{self.host}:{self.port}?password={password}"

conn = DatabaseConnection(host="localhost", port=5432, password="secret")
print(conn.connection_string)  # postgresql://localhost:5432?password=secret
print(hasattr(conn, "password"))  # False — password không tồn tại trên instance

7. Checklist ghi nhớ

✅ Checklist triển khai

  • [ ] Dùng @dataclass làm default cho mọi data object — không viết __init__ thủ công
  • [ ] __str__ cho human-readable output, __repr__ để dataclass tự sinh
  • [ ] Cần hash (dict key, set member) → frozen=True, không tự viết __hash__
  • [ ] Mutable default → luôn dùng field(default_factory=...)
  • [ ] Domain model = dataclass, API boundary = Pydantic BaseModel
  • [ ] slots=True cho production domain models (Python 3.10+)
  • [ ] Tránh dict-driven development — model domain trước, convert tại boundary

8. Bài tập luyện tập

Bài 1: Fix broken __hash__

Đoạn code dưới đây bị lỗi. Tìm và sửa:

python
@dataclass
class CacheKey:
    endpoint: str
    params: dict  # 🤔 Có vấn đề gì?

keys = set()
keys.add(CacheKey(endpoint="/api/users", params={"page": 1}))
💡 Gợi ý

dict là mutable → không thể hash. dataclass mặc định không có __hash__. Bạn cần:

  1. Dùng frozen=True
  2. Thay dict bằng kiểu immutable
✅ Lời giải
python
from dataclasses import dataclass

@dataclass(frozen=True)
class CacheKey:
    endpoint: str
    params: tuple[tuple[str, str], ...]  # Immutable thay cho dict

# Hoặc convert dict → frozenset tại boundary
def make_cache_key(endpoint: str, params: dict) -> CacheKey:
    return CacheKey(
        endpoint=endpoint,
        params=tuple(sorted(params.items())),
    )

keys = set()
keys.add(make_cache_key("/api/users", {"page": "1"}))

Giải thích: frozen=True đảm bảo immutability, tuple thay dict để hashable. Hàm make_cache_key convert tại boundary.

Bài 2: Dict → Dataclass

Refactor đoạn code dùng dict thành proper dataclass:

python
# ❌ Code cần refactor
def create_order(items: list[dict]) -> dict:
    total = sum(item["price"] * item["qty"] for item in items)
    return {
        "id": generate_id(),
        "items": items,
        "total": total,
        "status": "pending",
    }

order = create_order([{"name": "Coffee", "price": 45000, "qty": 2}])
print(order["total"])  # 90000
✅ Lời giải
python
from dataclasses import dataclass, field

@dataclass
class OrderItem:
    name: str
    price: int
    qty: int

    @property
    def subtotal(self) -> int:
        return self.price * self.qty

@dataclass
class Order:
    id: str
    items: list[OrderItem]
    status: str = "pending"

    @property
    def total(self) -> int:
        return sum(item.subtotal for item in self.items)

def create_order(items: list[OrderItem]) -> Order:
    return Order(id=generate_id(), items=items)

order = create_order([OrderItem(name="Coffee", price=45000, qty=2)])
print(order.total)  # 90000 — autocomplete ✅, type safe ✅

Lợi ích: IDE autocomplete, type checker bắt lỗi, không KeyError, dễ refactor.


9. Spot the Bug 🐛

🔍 Tìm bug trong đoạn code sau

python
from dataclasses import dataclass

@dataclass
class ApiConfig:
    base_url: str
    timeout: int = 30
    headers: dict = {"Content-Type": "application/json"}

config1 = ApiConfig(base_url="https://api.example.com")
config1.headers["Authorization"] = "Bearer token123"

config2 = ApiConfig(base_url="https://other-api.com")
print(config2.headers)
# Bạn mong đợi gì? Thực tế in ra gì?
🐛 Bug ở đâu?

headers: dict = {"Content-Type": "application/json"} — mutable default! Thực tế, Python dataclass sẽ raise ValueError ngay khi define class:

ValueError: mutable default <class 'dict'> for field headers is not allowed: use default_factory

Nếu đây là plain class (không có @dataclass), bug sẽ im lặng — tất cả instances share cùng dict.

Fix:

python
from dataclasses import dataclass, field

@dataclass
class ApiConfig:
    base_url: str
    timeout: int = 30
    headers: dict = field(
        default_factory=lambda: {"Content-Type": "application/json"}
    )

10. Quiz

🧠 Quiz

Câu 1: @dataclass tự động sinh những method nào?

  • [ ] __init__, __str__, __hash__
  • [x] __init__, __repr__, __eq__
  • [ ] __init__, __repr__, __hash__
  • [ ] __init__, __eq__, __lt__

Giải thích: @dataclass sinh __init__, __repr__, __eq__. Không sinh __str__ (dùng __repr__ nếu không có __str__), không sinh __hash__ (set thành None khi có custom __eq__), không sinh __lt__ (cần order=True).

🧠 Quiz

Câu 2: Khi nào nên dùng frozen=True?

  • [ ] Khi cần thay đổi attribute sau khi tạo object
  • [x] Khi cần dùng object làm dict key hoặc set member
  • [ ] Khi muốn tăng tốc __init__
  • [ ] Khi cần serialization tự động

Giải thích: frozen=True khiến object immutable → có __hash__ → dùng được trong set và làm dict key. Trade-off: không thể thay đổi attribute sau khi tạo.

🧠 Quiz

Câu 3: Trong pattern Domain Model + DTO, nên chọn gì cho API boundary?

  • [ ] @dataclass cho cả hai
  • [ ] Pydantic BaseModel cho cả hai
  • [x] @dataclass cho domain model, Pydantic BaseModel cho API boundary
  • [ ] attrs cho domain model, @dataclass cho API boundary

Giải thích: @dataclass nhẹ và đủ cho logic nội bộ. Pydantic mạnh ở validation và serialization — đúng chỗ tại API boundary nơi cần validate input từ bên ngoài.


Liên kết học tiếp