Pattern xuất hiện khi hợp đồng bên ngoài thay đổi
Câu hỏi: thêm một lớp Adapter bảo vệ phần nào của đơn hàng, và phải trả thêm bao nhiêu bước gọi?
Cần biết trước: hàm Python, type hint và biên module. Lab dùng Python 3.14.4, stdlib và hai SDK giả lập, không gọi dịch vụ vận chuyển thật. Đơn vị tiền là cent nguyên; khối lượng là gram nguyên dương. Không có thuế, tiền tệ khác, network retry hoặc việc tạo shipment.
Bắt đầu bằng một provider
Ban đầu checkout gọi shipping_cost(grams) và cộng phí vào subtotal. Một hàm và
SDK cụ thể đủ dùng. Yêu cầu mới: cùng checkout chọn được provider thứ hai, nhận
kilogram kiểu Decimal và trả dictionary. Việc đổi đơn vị, tên method và dạng kết quả
thuộc biên SDK; nghiệp vụ cộng phí không cần biết những khác biệt này.
Ta đặt hợp đồng quote(grams) -> int, rồi hai Adapter chuyển sang SDK tương ứng.
Đây là composition: Adapter giữ việc chuyển đổi ở một biên rồi gọi SDK, không kế
thừa implementation SDK để thay nghiệp vụ của nó.
Protocol mô tả hợp đồng để type checker kiểm structural typing; nó không tự kiểm
response hoặc bảo đảm đúng đơn vị lúc chạy.
Python Protocol.
Lưu các file trong một thư mục lab trống
from typing import Protocol
class Shipping(Protocol):
def quote(self, grams: int) -> int: ...
def validate(subtotal: int, grams: int) -> None:
if subtotal < 0 or grams <= 0:
raise ValueError("invalid order")
from decimal import Decimal
class SDKv1:
def shipping_cost(self, grams: int) -> int:
return 200 + grams // 10
class SDKv2:
def estimate(self, kilograms: Decimal) -> dict[str, int]:
return {"cost_cents": 200 + int(kilograms * 100)}
class FirstShipping:
def quote(self, grams: int) -> int:
return SDKv1().shipping_cost(grams)
class SecondShipping:
def quote(self, grams: int) -> int:
result = SDKv2().estimate(Decimal(grams) / 1000)
return result["cost_cents"]
from adapter import SDKv1
from contract import validate
def total(subtotal: int, grams: int) -> int:
validate(subtotal, grams)
return subtotal + SDKv1().shipping_cost(grams)
from contract import Shipping, validate
def total(subtotal: int, grams: int, shipping: Shipping) -> int:
validate(subtotal, grams)
return subtotal + shipping.quote(grams)
simple.py là bản trước khi có provider thứ hai; checkout.py là bản sau refactor.
Chỗ lắp ghép chọn FirstShipping() hoặc SecondShipping(). Không thêm factory,
DI container hoặc superclass vì hai lựa chọn chưa cần chúng.
Kiểm cùng hành vi và thay hợp đồng trên file thật
import unittest
import checkout
import simple
from adapter import FirstShipping, SecondShipping
class ContractTests(unittest.TestCase):
def test_prices(self) -> None:
for subtotal, grams, expected in [
(1000, 1000, 1300),
(0, 2500, 450),
(99, 1, 299),
]:
with self.subTest(subtotal=subtotal, grams=grams):
self.assertEqual(simple.total(subtotal, grams), expected)
for shipping in (FirstShipping(), SecondShipping()):
self.assertEqual(
checkout.total(subtotal, grams, shipping), expected
)
def test_invalid(self) -> None:
for subtotal, grams in [(-1, 1000), (1000, 0), (0, -1)]:
with self.subTest(subtotal=subtotal, grams=grams):
with self.assertRaisesRegex(ValueError, "invalid order"):
simple.total(subtotal, grams)
for shipping in (FirstShipping(), SecondShipping()):
with self.assertRaisesRegex(ValueError, "invalid order"):
checkout.total(subtotal, grams, shipping)
if __name__ == "__main__":
unittest.main()
python3.14 check.py
Yêu cầu tiếp theo: SDKv2 đổi field từ cost_cents sang charge_cents, vẫn giữ cùng
phí. Lab đổi cả fake SDK và phần đọc response trong adapter.py, chạy process
mới để không dính module cache, rồi khôi phục file. Hash xác nhận checkout, hợp
đồng và bản đơn giản không đổi; không lấy việc liệt kê file dự kiến thay cho phép thử.
import hashlib
import subprocess
import sys
from pathlib import Path
assert sys.version_info[:3] == (3, 14, 4)
files = [
Path(name) for name in ("adapter.py", "checkout.py", "contract.py", "simple.py")
]
before = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in files}
adapter = Path("adapter.py")
original = adapter.read_text()
assert original.count('"cost_cents"') == 2
try:
adapter.write_text(original.replace('"cost_cents"', '"charge_cents"'))
after = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in files}
changed = sorted(name for name in before if before[name] != after[name])
assert changed == ["adapter.py"]
subprocess.run([sys.executable, "check.py"], check=True)
print("changed=adapter.py; both providers preserved")
finally:
adapter.write_text(original)
assert {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in files} == before
print("restored")
python3.14 change.py
Tính cả giá của abstraction
| Thiết kế | Bước gọi để tính phí | Hợp đồng thêm | Điều đã kiểm |
|---|---|---|---|
| Một SDK trực tiếp | total→SDK | 0 interface | Ba input hợp lệ, ba input sai |
| Hai provider qua Adapter | total→Adapter→SDK | 1 Protocol, 2 lớp Adapter, 1 tham số injected | Hai provider cùng kết quả, đổi field chỉ adapter.py |
Adapter thêm một bước gọi và chỗ lắp ghép. Hợp đồng vẫn phải thay nếu nghiệp vụ đổi từ trả phí sang giữ chỗ hoặc tạo shipment; timeout/idempotency/error mapping chưa có trong lab. Hai SDK cùng nằm trong một module chỉ để ví dụ gọn, không đo mức cô lập dependency ở package hoặc migration SDK thật.
Nếu chỉ có SDKv1 và không có nhu cầu thay hợp đồng, giữ simple.total là hợp lý.
Một helper chuyển đổi dùng chung cũng có thể tạo biên tương tự; tên pattern không
làm code tốt hơn và số lớp không phải bằng chứng lợi ích. Đừng tạo lớp chỉ để chuyển
tiếp khi bạn kiểm soát được cả hai bên và có thể sửa trực tiếp hợp đồng.
Học tiếp: Repository/ORM kiểm một biên DB, ADR ghi lý do chấp nhận chi phí, và debug có giả thuyết kiểm điều kiện khiến quyết định này sai.