Giao diện
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
dataclassnên là lựa chọn mặc định khi tạo data object - Nắm được những gì
dataclasstự 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:
- Viết class thủ công — copy-paste
__init__,__repr__,__eq__cho mỗi model. 50 dòng boilerplate cho 3 fields. - Dùng
dictcho mọi thứ — "nhanh mà, khỏi tạo class". Rồi 6 tháng sau, không ai biếtuser["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.emailpython
# ✅ dataclass — same behavior, 4 dòng
from dataclasses import dataclass
@dataclass
class User:
id: int
email: str
is_active: bool = TrueDecorator @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')| Method | Khán giả | Mục đích | Ví dụ output |
|---|---|---|---|
__str__ | User / logs | Human-readable | Order #42 — confirmed (1,500,000₫) |
__repr__ | Developer / debugger | Unambiguous, ideally eval-able | Order(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__ và __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: strVớ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
| Feature | dataclass | attrs | Pydantic |
|---|---|---|---|
| Stdlib | ✅ Có sẵn | ❌ pip install | ❌ pip 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) |
| Immutability | frozen=True | frozen=True | model_config = frozen |
| Slots | slots=True (3.10+) | slots=True | Tự động |
| Use case | Domain models, DTOs nội bộ | Power users, cattrs ecosystem | API 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 → Database5. 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
- ❌
KeyErrorlú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.0InitVar — 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 instance7. Checklist ghi nhớ
✅ Checklist triển khai
- [ ] Dùng
@dataclasslà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=Truecho 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:
- Dùng
frozen=True - Thay
dictbằ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_factoryNế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?
- [ ]
@dataclasscho cả hai - [ ]
Pydantic BaseModelcho cả hai - [x]
@dataclasscho domain model,Pydantic BaseModelcho API boundary - [ ]
attrscho domain model,@dataclasscho 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.