Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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ếptotal→SDK0 interfaceBa input hợp lệ, ba input sai
Hai provider qua Adaptertotal→Adapter→SDK1 Protocol, 2 lớp Adapter, 1 tham số injectedHai 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.