Bốn pattern đầu bài này — Adapter, Facade, Proxy và Decorator — nhìn code rất giống nhau.
Thường đều có dạng:
class Wrapper:
def __init__(self, inner):
self._inner = inner
Sau đó object bên ngoài gọi lại object bên trong.
Vì vậy, nếu học xong mà vẫn hay nhầm bốn pattern này thì cũng bình thường. Điểm khác nhau chủ yếu nằm ở mục đích sử dụng, không phải ở cách viết code.
Đây là cách phân biệt nhanh:
| Pattern | Interface bên ngoài | Mục đích |
|---|---|---|
| Adapter | Khác object bên trong | Chuyển interface này thành interface khác |
| Facade | Đơn giản hơn hệ thống bên dưới | Che bớt sự phức tạp |
| Proxy | Gần như giống object bên trong | Kiểm soát việc gọi object |
| Decorator | Giống object bên trong | Thêm hành vi cho object |
Có thể nhớ bằng hai câu hỏi:
1. Object bên ngoài có giữ nguyên interface của object bên trong không?
- Không → thường là Adapter hoặc Facade
- Có → thường là Proxy hoặc Decorator
2. Nếu giữ nguyên interface, nó đang làm gì?
- Kiểm soát việc gọi → Proxy
- Thêm hành vi và có thể chồng nhiều lớp → Decorator
Ba pattern cuối — Composite, Bridge và Flyweight — không tập trung vào việc bọc object, nên mình tách ra nói riêng.
Adapter
Adapter dùng khi code của bạn cần một interface, nhưng object bên ngoài lại cung cấp interface khác.
Tình huống này rất thường gặp khi tích hợp SDK bên thứ ba hoặc làm việc với code cũ mà bạn không thể sửa.
Ví dụ, code của mình muốn có charge():
class PaymentGateway(Protocol):
def charge(self, amount_vnd: int, token: str) -> str: ...
Nhưng thư viện cũ lại có:
class LegacyBillingClient:
def submit_transaction(
self,
amount: float,
card_ref: str,
currency: str,
) -> dict:
...
Ta viết một adapter ở giữa:
class LegacyBillingAdapter:
def __init__(self, client: LegacyBillingClient):
self._client = client
def charge(self, amount_vnd: int, token: str) -> str:
result = self._client.submit_transaction(
amount=amount_vnd,
card_ref=token,
currency="VND",
)
if result["status"] != "OK":
raise PaymentFailed(result.get("error", "unknown"))
return result["txn_id"]
Adapter nhận cách gọi mà code của mình hiểu, rồi dịch nó sang cách gọi mà thư viện bên ngoài hiểu.
Không chỉ tham số, adapter thường phải dịch cả cách xử lý lỗi.
Ví dụ, thư viện bên ngoài có thể trả:
{"status": "FAILED", "error": "Insufficient balance"}
Trong khi code của mình muốn nhận PaymentFailed.
Nếu để exception hoặc format dữ liệu của nhà cung cấp lọt ra ngoài, các phần còn lại của hệ thống vẫn bị phụ thuộc vào nhà cung cấp đó. Khi đổi sang nhà cung cấp khác, bạn sẽ phải sửa rất nhiều chỗ.
Vì vậy, adapter nên tạo ra ranh giới rõ ràng giữa code của mình và code bên ngoài.
Một nguyên tắc đơn giản:
Nếu bạn kiểm soát được cả hai phía thì thường không cần Adapter. Sửa interface cho phù hợp sẽ đơn giản hơn.
Facade
Facade dùng khi một thao tác đơn giản nhưng phía sau phải làm rất nhiều việc.
Ví dụ, để đặt một đơn hàng có thể phải:
- Giữ hàng trong kho.
- Thanh toán.
- Tạo order.
- Đặt lịch giao hàng.
- Gửi thông báo.
- Ghi audit log.
Nếu chỗ nào cũng phải tự gọi từng bước, code sẽ nhanh chóng trở nên dài và dễ sai.
Facade gom chúng lại thành một API đơn giản:
class OrderPlacement:
def __init__(
self,
inventory,
payments,
shipping,
notifications,
audit,
):
...
def place(self, cart: Cart, customer: Customer) -> Order:
self._inventory.reserve(cart.items)
try:
txn = self._payments.charge(
cart.total,
customer.payment_token,
)
order = Order.create(cart, customer, txn)
self._shipping.schedule(order)
self._notifications.order_confirmed(order)
self._audit.record("order.placed", order.id)
return order
except Exception:
self._inventory.release(cart.items)
raise
Bên ngoài chỉ cần:
order_placement.place(cart, customer)
Không cần biết bên trong phải gọi bao nhiêu service hay phải gọi theo thứ tự nào.
Đây là điểm khác Adapter:
- Adapter: interface cũ không phù hợp → dịch sang interface mình cần.
- Facade: hệ thống có quá nhiều thứ phải gọi → gom lại thành một interface đơn giản.
Adapter thường đứng giữa một interface và một interface khác.
Facade thường đứng trước nhiều thành phần bên dưới.
Cẩn thận với God Object
Facade rất dễ phình to.
Ban đầu nó chỉ điều phối:
reserve → charge → create order → ship
Nhưng sau một thời gian, người ta bắt đầu nhét thêm mọi loại business logic vào đó.
Khi Facade bắt đầu tự tính toán và đưa ra nhiều quyết định nghiệp vụ, nó có thể trở thành một God Object.
Facade nên chủ yếu điều phối, còn business logic nên nằm ở nơi phù hợp hơn.
Proxy
Proxy cũng giữ nguyên interface của object bên trong, nhưng mục đích là kiểm soát việc gọi object đó.
Ví dụ, ta có UserRepo và muốn thêm cache:
class CachingUserRepo:
def __init__(self, inner: UserRepo, ttl: int = 60):
self._inner = inner
self._ttl = ttl
self._cache: dict[int, tuple[float, User]] = {}
def get(self, user_id: int) -> User:
hit = self._cache.get(user_id)
if hit and time.monotonic() - hit[0] < self._ttl:
return hit[1]
user = self._inner.get(user_id)
self._cache[user_id] = (time.monotonic(), user)
return user
Bên ngoài vẫn gọi:
repo.get(user_id)
Không cần biết repo thật hay CachingUserRepo.
Đây là điểm quan trọng của Proxy:
Object bên ngoài trông giống object bên trong, nhưng đứng giữa để kiểm soát việc gọi.
Một số loại Proxy thường gặp:
- Caching Proxy — kiểm tra cache trước khi gọi object thật.
- Lazy Proxy — chỉ tạo hoặc load object khi thực sự cần.
- Remote Proxy — object nằm ở nơi khác, nhưng code bên ngoài gọi như thể nó ở local.
- Protection Proxy — kiểm tra quyền trước khi cho phép gọi.
Proxy để phân quyền
Phân quyền là một trường hợp rất phù hợp với Proxy.
Thay vì rải check quyền ở nhiều nơi:
if not user.can_read:
raise PermissionError()
ta có thể đặt nó vào Proxy:
class AuthorizedDocumentStore:
def __init__(self, inner: DocumentStore, policy: Policy):
self._inner = inner
self._policy = policy
def read(self, actor: Actor, doc_id: str) -> Document:
self._policy.require(actor, "document.read", doc_id)
doc = self._inner.read(actor, doc_id)
audit.log(actor, "read", doc_id)
return doc
def delete(self, actor: Actor, doc_id: str) -> None:
self._policy.require(actor, "document.delete", doc_id)
self._inner.delete(actor, doc_id)
audit.log(actor, "delete", doc_id)
Cách này có hai lợi ích.
Thứ nhất, logic kiểm tra quyền nằm ở một chỗ, dễ kiểm tra và dễ audit.
Thứ hai, nếu kiến trúc được thiết kế đúng, code bên ngoài chỉ được phép lấy AuthorizedDocumentStore, thay vì lấy trực tiếp DocumentStore.
Nếu vẫn có thể dễ dàng lấy object gốc rồi gọi thẳng:
document_store.delete(...)
thì Proxy bảo vệ gần như không có tác dụng.
Cẩn thận
Proxy làm code bên ngoài trông rất đơn giản:
repo.get(user_id)
Nhưng bên trong có thể đang:
- gọi database,
- gọi network,
- chờ lock,
- kiểm tra permission,
- hoặc chạy một thao tác rất tốn thời gian.
Vì vậy, nếu Proxy làm thay đổi đáng kể hiệu năng hoặc cách hoạt động, nên đặt tên rõ ràng ngay từ lúc khởi tạo.
Decorator
Decorator cũng giữ nguyên interface của object bên trong, nhưng mục đích là thêm hành vi cho object.
Điểm mạnh của Decorator là có thể ghép nhiều hành vi lại với nhau.
Ví dụ một HTTP client cần:
- retry,
- logging,
- rate limiting.
Ta có thể viết mỗi hành vi thành một decorator:
class RetryingClient:
def __init__(self, inner: HttpClient, attempts: int = 3):
self._inner = inner
self._attempts = attempts
def get(self, url: str) -> Response:
for i in range(self._attempts):
try:
return self._inner.get(url)
except TransientError:
if i == self._attempts - 1:
raise
time.sleep(2 ** i)
class LoggingClient:
def __init__(self, inner: HttpClient):
self._inner = inner
def get(self, url: str) -> Response:
start = time.monotonic()
resp = self._inner.get(url)
log.info(
"GET %s -> %s in %.0fms",
url,
resp.status,
(time.monotonic() - start) * 1000,
)
return resp
Sau đó ghép chúng lại:
client = LoggingClient(
RetryingClient(
RateLimitedClient(
RealHttpClient()
)
)
)
Mỗi decorator làm một việc riêng, nhưng chúng có cùng interface với HttpClient.
Nhờ vậy có thể thêm hoặc bỏ từng hành vi mà không cần tạo một class mới cho từng tổ hợp.
Thứ tự decorator rất quan trọng
Đây là một chi tiết dễ bỏ qua.
Ví dụ:
LoggingClient(
RetryingClient(
RealHttpClient()
)
)
Logging nằm bên ngoài Retry.
Nếu request lỗi và được retry 3 lần, logging bên ngoài có thể chỉ ghi nhận một lần gọi, trong khi Retry thực hiện ba lần gọi thật.
Nếu đổi thứ tự:
RetryingClient(
LoggingClient(
RealHttpClient()
)
)
thì logging có thể ghi nhận từng lần retry.
Cả hai đều có thể đúng. Quan trọng là bạn phải biết mình muốn hành vi nào.
Trong Python, decorator ở mức function cũng dựa trên ý tưởng tương tự:
@retry(attempts=3)
@log_calls
def fetch_prices(symbol: str) -> dict:
...
Cẩn thận khi chồng quá nhiều decorator
Decorator rất tiện, nhưng nếu chồng quá nhiều lớp thì debug sẽ khó.
Stack trace có thể đi qua rất nhiều lớp wrapper, và khi một request chậm, bạn phải lần từng lớp mới biết vấn đề nằm ở đâu.
Vài lớp thường ổn.
Nhưng nếu phải nhìn qua 7–8 lớp decorator mới hiểu một method đang làm gì, có lẽ thiết kế đã bắt đầu quá phức tạp.
Composite
Composite dùng cho cấu trúc dạng cây, khi cả node lá và node chứa bên trong đều được xử lý qua cùng một interface.
Ví dụ:
- file và thư mục,
- UI component lồng nhau,
- sản phẩm và combo sản phẩm,
- category chứa category con.
Dễ hình dung nhất là tính dung lượng của một thư mục. Một file thì có dung lượng của chính nó. Một thư mục thì không có dung lượng riêng, dung lượng của nó là tổng của mọi thứ nằm bên trong — mà bên trong lại có thể là file, hoặc thư mục khác.
from dataclasses import dataclass, field
from typing import Protocol
class Node(Protocol):
def size(self) -> int: ...
@dataclass
class File:
name: str
bytes: int
def size(self) -> int:
return self.bytes
@dataclass
class Folder:
name: str
children: list[Node] = field(default_factory=list)
def size(self) -> int:
return sum(child.size() for child in self.children)
Bây giờ Folder có thể chứa File hoặc một Folder khác:
root = Folder("project", [
File("README.md", 2_000),
Folder("src", [
File("main.py", 8_000),
File("utils.py", 4_000),
]),
])
Điểm quan trọng là:
File → Node
Folder → Node
Folder chứa Node, nhưng bản thân Folder cũng là một Node.
Vì vậy code bên ngoài chỉ cần:
root.size()
Nó không cần biết bên trong là một file đơn lẻ hay cả một cây thư mục lồng nhau nhiều tầng. Cũng không cần viết if isinstance(x, Folder) ở đâu cả — mỗi loại tự biết cách tính phần của mình.
Đây chính là điểm hay của Composite:
Lá và nhánh có cùng interface, nên code sử dụng chúng không cần biết mình đang làm việc với loại nào.
Không nên dùng Composite nếu cấu trúc của bạn vốn đã phẳng, hoặc node lá và node chứa bên trong có hành vi hoàn toàn khác nhau. Khi đó, cố ép chúng vào cùng một interface có thể khiến interface chứa rất nhiều method mà một số loại không dùng.
Một vấn đề khác là độ sâu của cây. Nếu cây có thể được tạo bởi input của người dùng, hãy giới hạn độ sâu. Cây quá sâu kết hợp với đệ quy có thể dẫn tới stack overflow.
Bridge
Bridge dùng khi một class có hai chiều thay đổi độc lập, và nếu gộp chúng lại sẽ tạo ra quá nhiều class.
Ví dụ có:
- 3 loại notification,
- 4 kênh gửi.
Nếu mỗi loại notification phải biết từng kênh, bạn có thể phải tạo:
3 × 4 = 12 class
Thêm một kênh mới lại phải thêm 3 class.
Bridge giải quyết bằng cách tách hai chiều ra:
class Channel(Protocol):
def send(
self,
to: str,
subject: str,
body: str,
) -> None:
...
class Notification:
def __init__(self, channel: Channel):
self._channel = channel
class PasswordReset(Notification):
def notify(self, user, token):
self._channel.send(
user.contact,
"Đặt lại mật khẩu",
render(token),
)
Bây giờ:
PasswordReset(EmailChannel()).notify(user, token)
PasswordReset(ZaloChannel()).notify(user, token)
Notification và Channel có thể thay đổi độc lập.
Thay vì:
3 notification × 4 channel = 12 class
ta có:
3 notification + 4 channel = 7 class
Nhưng Bridge không phải pattern cần dùng thường xuyên.
Nhiều trường hợp nhìn giống Bridge nhưng thực tế chỉ có một chiều cần thay đổi. Nếu vậy, thêm Bridge chỉ làm code phức tạp hơn.
Một dấu hiệu tốt để dùng Bridge là:
Bạn có thể chỉ ra rõ ràng hai chiều đều sẽ thay đổi độc lập trong tương lai.
Nếu chưa có ví dụ thực tế cho cả hai chiều, đừng vội dùng Bridge chỉ vì thấy số lượng class có thể tăng.
Flyweight
Flyweight dùng khi bạn có rất nhiều object nhưng phần lớn dữ liệu của chúng giống nhau.
Thay vì lưu nhiều bản copy giống nhau, ta giữ phần dùng chung ở một chỗ.
Hãy tưởng tượng bạn load 5 triệu giao dịch từ database để chạy báo cáo. Mỗi giao dịch có một loại tiền tệ, và loại tiền tệ thì mang theo khá nhiều thông tin: mã, tên, ký hiệu, số chữ số thập phân, quy tắc làm tròn.
Nếu viết không chuẩn, mỗi giao dịch sẽ tự tạo ra một object Currency riêng — tức là 5 triệu object, trong khi thực tế trên đời chỉ có khoảng 150 loại tiền tệ. Phần lớn chúng là bản sao y hệt nhau của USD, VND, EUR.
Vậy ta tách làm hai phần. Phần dùng chung, lặp đi lặp lại:
@dataclass(frozen=True)
class Currency:
code: str
name: str
symbol: str
decimals: int
Phần riêng của từng giao dịch:
@dataclass
class Transaction:
id: int
amount: int # lưu theo đơn vị nhỏ nhất
currency: Currency
Rồi tái sử dụng Currency qua một cache:
_currencies: dict[str, Currency] = {}
def currency(code: str) -> Currency:
if code not in _currencies:
_currencies[code] = load_currency(code)
return _currencies[code]
Khi dựng dữ liệu:
transactions = [
Transaction(row.id, row.amount, currency(row.currency_code))
for row in rows
]
5 triệu Transaction, nhưng chỉ có đúng 150 Currency tồn tại trong memory. Mỗi giao dịch chỉ giữ một con trỏ trỏ tới object dùng chung, thay vì mang theo bản copy của riêng nó.
Đây chính là ý tưởng của Flyweight:
Tách dữ liệu dùng chung (intrinsic) ra khỏi dữ liệu riêng của từng instance (extrinsic), rồi chia sẻ phần dùng chung.
Cùng ý tưởng đó áp dụng khi render chữ: một tài liệu 50.000 ký tự không cần 50.000 bản copy dữ liệu font. Ký tự A với cùng một font chỉ cần một Glyph duy nhất chứa outline, còn mỗi lần nó xuất hiện thì chỉ lưu thêm toạ độ x, y.
Flyweight đặc biệt hữu ích trong những hệ thống có rất nhiều object nhỏ như:
- text rendering,
- game engine,
- particle system,
- bản đồ tile,
- xử lý dữ liệu theo batch lớn.
Trong web app CRUD thông thường, Flyweight thường không đáng để dùng. Nếu dữ liệu không đủ lớn để vấn đề memory trở nên đáng kể, pattern này chỉ làm code phức tạp thêm.
Một nguyên tắc quan trọng:
Phần dữ liệu dùng chung nên immutable.
Nếu một object dùng chung bị thay đổi, tất cả object đang dùng nó cũng bị ảnh hưởng. Sửa decimals của một Currency mà 5 triệu giao dịch đổi theo là loại bug rất khó tìm. Đó cũng là lý do Currency ở trên được khai báo frozen=True.