Architecture Patterns with Python: cuốn sách giúp code tích hợp sống sót sau khi bạn rời khách hàng
Harry Percival và Bob Gregory viết cuốn này cho những lập trình viên Python từng khổ vì một ứng dụng rối. FDE nào đã phải bàn giao code cho đội của khách đều từng rơi vào đúng tình cảnh ấy.

Tóm tắt nhanh
- Sách đọc miễn phí trên cosmicpython.com và không đòi hỏi bạn biết DDD từ trước
- Ba ý nên giữ: web chỉ là một cửa vào, Unit of Work gom thao tác thành một khối, lời gọi ra ngoài phải đi qua outbox
- Bài tập khó nhưng đáng làm, nhất là khi đọc kèm một dự án thật
Toàn bộ một cuốn sách kiến trúc phần mềm đang nằm sẵn trên mạng, miễn phí. Trang cosmicpython.com cho đọc trọn Architecture Patterns with Python theo giấy phép Creative Commons, kèm liên kết sang O’Reilly Learning.
Hai tác giả là Harry Percival và Bob Gregory. Họ viết cuốn sách khi cùng làm ở MADE.com, một công ty thương mại điện tử bán nội thất trực tuyến ở châu Âu.
Người đọc mà họ nhắm tới là lập trình viên Python từng khổ vì code ngày càng rối, và lời tựa nói rõ bạn không cần biết gì về DDD hay các pattern kiến trúc kinh điển.
Với một FDE, sách chạm đúng chỗ đau nhất của nghề. Viết code tích hợp cho chạy được thì không khó; cái khó là code ấy vẫn sửa được khi bạn đã rời dự án. Các tác giả gọi tên vấn đề ngay từ lời tựa: dự án càng lớn, cách làm hiển nhiên nhất càng ít giúp bạn kiểm soát độ phức tạp.
Bãi bùn bắt đầu từ endpoint đầu tiên
Phần Introduction đặt vấn đề bằng một nhận xét thẳng: hỗn loạn trong phần mềm phổ biến đến mức dân kỹ sư có hẳn một cái tên cho nó, antipattern “Big Ball of Mud”.
Thử hình dung một deployment điển hình. Bạn viết một route Flask nhận request, đọc database, đẩy dữ liệu sang CRM của khách rồi commit. Demo chạy ngon.
# app.py — trước: mọi thứ nằm trong route
@app.route("/orders/<order_id>/sync", methods=["POST"])
def sync(order_id):
order = db.session.get(Order, order_id)
crm.push(order.to_dict())
order.synced = True
db.session.commit()
return {"ok": True}
Sang tuần thứ ba, khách muốn thêm một job chạy hằng đêm để đồng bộ lại toàn bộ dữ liệu, và bạn chép nguyên bốn dòng giữa sang một script. Thế là đã có hai bản logic, và chúng sẽ lệch nhau từ lần sửa đầu tiên.
Ý thứ nhất: web chỉ là một cửa vào
Chương 4 trả lời đúng tình huống đó bằng service layer. Theo các tác giả, toàn bộ logic điều phối nằm ở tầng use case, còn logic nghiệp vụ ở lại trong domain. Điều phối ở đây gồm lấy dữ liệu từ repository, kiểm tra đầu vào với trạng thái database, xử lý lỗi và commit khi mọi thứ suôn sẻ.
Viết lại ví dụ trên theo cách đó, logic chuyển sang một hàm riêng và route chỉ còn vài dòng:
# services.py — một use case, mọi cửa vào cùng gọi
def sync_order(order_id, session, crm):
order = session.get(Order, order_id)
crm.push(order.to_dict())
order.synced = True
session.commit()
# app.py — route chỉ còn việc web
@app.route("/orders/<order_id>/sync", methods=["POST"])
def sync(order_id):
services.sync_order(order_id, db.session, crm_client)
return {"ok": True}
Job hằng đêm lặp qua danh sách đơn và gọi sync_order, test thì gọi thẳng với một session giả và một CRM giả. Logic chỉ nằm ở một chỗ, và người nhận bàn giao chỉ cần đọc đúng chỗ đó.
Việc đầu tiên nên làm ở khách: trước khi viết thêm route thứ hai, hãy kéo logic của route đầu tiên ra thành use case.
Ý thứ hai: Unit of Work gom thao tác thành một khối
Chương 6 giới thiệu Unit of Work, cách sách đóng gói ý tưởng thao tác nguyên tử: gom các thao tác phải thành công hoặc thất bại cùng nhau vào một khối. Không có gì được lưu nếu bạn không gọi commit một cách rõ ràng. Nhờ context manager của Python, phần việc chưa commit tự rollback khi request kết thúc.
Nhưng hãy nhìn lại sync_order: CRM nhận dữ liệu trước khi commit. Nếu commit hỏng, CRM đã có đơn còn database vẫn ghi là chưa đồng bộ, vì rollback chỉ kéo lại được phần nằm trong database, không kéo lại được lời gọi đã ra ngoài.
Đảo thứ tự, commit trước rồi mới gọi CRM, cũng không cứu được. Tài liệu AWS Prescriptive Guidance cảnh báo rằng nếu service hỏng ngay sau khi commit transaction, thông báo sang hệ thống kia có thể không bao giờ được gửi.
Confluent gọi đây là dual-write problem: database cập nhật thành công nhưng lần ghi ra ngoài thất bại, và hệ thống rơi vào trạng thái không nhất quán.
Ý thứ ba: lời gọi ra ngoài phải đi qua outbox
Cách sửa mà cả AWS lẫn Confluent mô tả là transactional outbox. Bạn ghi ý định gọi CRM vào một bảng outbox trong cùng transaction với đơn hàng, rồi để một worker riêng thực hiện lời gọi, thử lại khi lỗi và đánh dấu xong sau khi gửi thành công. Đặt vào Unit of Work của sách, sync_order trở thành:
# services.py — sau: đơn hàng và outbox cùng một khối
def sync_order(order_id, uow):
with uow:
order = uow.orders.get(order_id)
order.sync_status = "pending"
uow.outbox.add(event="order_sync", order_id=order_id,
idempotency_key=f"order-{order_id}")
uow.commit() # cả hai cùng lưu, hoặc không gì được lưu
# worker.py — chạy riêng, gọi CRM và thử lại
def flush_outbox(uow, crm):
with uow:
for msg in uow.outbox.pending():
order = uow.orders.get(msg.order_id)
crm.push(order.to_dict(), idempotency_key=msg.idempotency_key)
order.sync_status = "synced"
msg.mark_done()
uow.commit()
Đơn hàng và bản ghi outbox cùng commit hoặc cùng rollback, nên không còn chuyện đơn đã lưu mà ý định gọi CRM biến mất. Nếu CRM lỗi, message vẫn nằm trong outbox để lần chạy sau của worker thử lại.
Cái giá phải trả là message được gửi ít nhất một lần, nghĩa là CRM có thể nhận trùng. AWS khuyên làm bên nhận idempotent bằng cách theo dõi các message đã xử lý, và đó là việc của idempotency_key. Nếu API của khách không nhận key, worker phải tự kiểm tra trước khi gửi lại.
Bài tập nhỏ để tự kiểm tra: trong flush_outbox, giả sử crm.push thành công nhưng worker sập ngay trước uow.commit(). Đơn hàng và message đang ở trạng thái nào, và lần chạy kế tiếp sẽ làm gì? Hãy tự trả lời trước khi đọc tiếp.
Đáp án: vì chưa commit, Unit of Work rollback, message vẫn là pending và đơn vẫn chưa đồng bộ. Lần chạy sau, worker gửi lại cùng một idempotency_key, và chính key này giúp CRM không tạo ra một đơn thứ hai. Nếu bạn trả lời đúng cả hai vế, bạn đã hiểu vì sao outbox và idempotency luôn phải đi cùng nhau.
Việc đầu tiên nên làm ở khách: liệt kê mọi chỗ code vừa ghi database vừa gọi hệ thống ngoài, rồi hỏi điều gì xảy ra nếu bước thứ hai hỏng.
Sai lầm hay gặp: xếp lớp trước khi thấy đau
Đọc xong một cuốn sách pattern, cám dỗ lớn nhất là dựng đủ mọi lớp ngay từ endpoint đầu tiên. Với FDE, mỗi lớp thêm vào là thêm một thứ đội của khách phải hiểu sau khi bạn đi.
Một hàm commit đơn giản vẫn đủ nếu tích hợp chỉ chạm database. Hãy thêm một lớp khi có một nỗi đau cụ thể, như logic bị chép sang job thứ hai hay một lời gọi ra ngoài có thể mất, chứ không phải vì sách có chương về nó.
Nên đọc thế nào cho đỡ đuối?
Giuliano Pertile, trong bài review năm 2022, vẫn khuyên đọc cuốn này, nhưng cảnh báo bài tập cho người đọc khá khó. Lời cảnh báo này đúng, và cũng là lý do bạn nên đọc kèm một dự án thật.
Thứ tự hợp lý: bắt đầu với Introduction để hiểu vì sao code trôi thành bãi bùn, sang chương service layer, rồi đến Unit of Work. Phần còn lại để dành khi dự án thật sự cần.
Về nghề nghiệp, đây là kỹ năng dễ chứng minh. Một repo cá nhân có route mỏng, service layer rõ ràng, outbox có idempotency key và bộ test chạy không cần database thật nói nhiều hơn một dòng “clean architecture” trong CV. Khi phỏng vấn FDE, nếu được hỏi về một tích hợp từng làm, hãy kể bạn xử lý thế nào khi hệ thống ngoài hỏng giữa chừng.
Phần lớn khách hàng sẽ không bao giờ đọc code của bạn. Nhưng kỹ sư nhận lại nó sáu tháng sau thì có, và cuốn sách này viết cho đúng người đó.
Bài này có hữu ích không?
Cảm ơn bạn đã góp ý!
8 nguồn
- Cosmic Python
- Preface – Architecture Patterns with Python
- Introduction – Architecture Patterns with Python
- Unit of Work Pattern
- Our First Use Case: Flask API and Service Layer
- Transactional outbox pattern – AWS Prescriptive Guidance
- Dual write problem – Confluent
- Book Review: Architecture Patterns with Python · 2022-06-01