Chương 7. Modular Monolith: Tách Trong Đầu Trước Khi Tách Server
Ở Chương 6, ta đã nói: monolith không xấu, monolith rối mới xấu.
Vậy làm sao để một monolith không trở thành cục rối?
Câu trả lời thực dụng nhất là modular monolith.
Modular monolith là cách tổ chức một ứng dụng monolith thành các module rõ ràng. Hệ thống vẫn deploy như một khối, vẫn có thể dùng chung database, vẫn dễ chạy local và dễ debug hơn microservices. Nhưng bên trong, code được chia theo ranh giới nghiệp vụ, có trách nhiệm rõ, không chọc lung tung vào nhau.
Nói ngắn gọn:
> Modular monolith là tách trong code trước khi tách thành server riêng.
---
7.1. Ví dụ quán bánh bắt đầu có nhiều bộ phận
Ban đầu quán bánh chỉ có vài người, ai cũng làm mọi thứ:
- Nhận đơn.
- Làm bánh.
- Gọi shipper.
- Thu tiền.
- Ghi sổ.
- Trả lời khách.
Khi quán nhỏ, như vậy vẫn ổn. Nhưng khi quán đông hơn, nếu ai cũng đụng vào mọi việc, sẽ rối:
- Người nhận đơn sửa giá lung tung.
- Người làm bánh tự ý đổi trạng thái thanh toán.
- Người giao hàng sửa thông tin khách.
- Kế toán không biết số liệu nào là thật.
Quán chưa cần tách thành nhiều công ty riêng. Nhưng cần chia bộ phận:
- Bộ phận nhận đơn.
- Bộ phận bếp.
- Bộ phận giao hàng.
- Bộ phận thu tiền.
- Bộ phận chăm sóc khách.
- Bộ phận kế toán.
Tất cả vẫn trong một cửa hàng, một quản lý chung, một hệ thống vận hành chung. Nhưng mỗi bộ phận có trách nhiệm rõ.
Đây là modular monolith.
Không tách thành nhiều service vật lý, nhưng tách trách nhiệm trong đầu và trong tổ chức code.
---
7.2. Modular monolith là gì?
Modular monolith là một ứng dụng:
- Deploy chung.
- Chạy chung runtime.
- Thường dùng chung database.
- Nhưng code chia thành module rõ ràng.
- Mỗi module đại diện cho một vùng nghiệp vụ.
- Module giao tiếp với nhau qua interface/service rõ ràng.
Ví dụ:
Application
|-- users
|-- catalog
|-- ordering
|-- payment
|-- notification
|-- reporting
Vẫn là một app:
Browser -> App -> Database
Nhưng bên trong app không phải một đống code trộn lẫn.
Module ordering lo đơn hàng. Module payment lo thanh toán. Module notification lo thông báo. Module catalog lo sản phẩm.
Mỗi module có một "vùng đất" riêng.
---
7.3. Modular monolith khác monolith thường ở đâu?
Monolith thường chỉ nói rằng hệ thống deploy như một khối.
Modular monolith nói thêm rằng: khối đó phải có cấu trúc bên trong.
So sánh:
Monolith rối
app/
views.py
models.py
utils.py
helpers.py
services.py
Mọi logic nằm lẫn nhau. File nào cũng có thể gọi mọi thứ. Không biết phần nào chịu trách nhiệm chính.
Modular monolith
app/
users/
catalog/
ordering/
payment/
notification/
reporting/
Mỗi module có:
- Models hoặc schema liên quan.
- Service/use case.
- API/controller riêng.
- Test riêng.
- Interface rõ cho module khác gọi.
Điểm khác biệt không nằm ở số folder, mà nằm ở kỷ luật:
> Module không được tùy tiện chọc vào nội bộ của module khác.
---
7.4. Vì sao modular monolith thực dụng hơn microservices quá sớm?
Microservices tách hệ thống thành nhiều service độc lập:
User Service
Order Service
Payment Service
Notification Service
Nghe gọn, nhưng kéo theo nhiều thứ:
- Network call.
- API contract.
- Service discovery.
- Distributed tracing.
- Distributed transaction.
- Database ownership.
- Deploy nhiều pipeline.
- Security giữa services.
- Versioning.
Nếu sản phẩm còn nhỏ hoặc domain còn thay đổi, cái giá này có thể quá đắt.
Modular monolith cho ta một bước trung gian:
- Vẫn code có ranh giới.
- Vẫn deploy đơn giản.
- Vẫn transaction dễ hơn.
- Vẫn debug dễ hơn.
- Vẫn có đường tách service sau này.
Nó giống như chia phòng trong một căn nhà, thay vì xây ngay nhiều tòa nhà khác nhau.
Khi một phòng thật sự cần tách thành tòa nhà riêng, lúc đó mới tách.
---
7.5. Module nên chia theo gì?
Đừng chia module theo kiểu kỹ thuật trước:
controllers/
models/
services/
utils/
Cách này chỉ chia theo loại file, không chia theo nghiệp vụ. Khi hệ thống lớn, logic của một nghiệp vụ bị rải khắp nơi.
Nên chia theo vùng nghiệp vụ:
users/
courses/
orders/
payments/
bookings/
notifications/
reports/
Mỗi module trả lời:
- Nó quản lý khái niệm nghiệp vụ nào?
- Dữ liệu nào thuộc về nó?
- Use case chính của nó là gì?
- Module khác được phép gọi nó qua đâu?
- Những chi tiết nào là nội bộ?
Ví dụ trong hệ bán bánh:
catalog/
quản lý bánh, giá, mô tả, hình ảnh
ordering/
tạo đơn, trạng thái đơn, lịch sử đơn
payment/
thanh toán, refund, transaction id
delivery/
địa chỉ giao, shipper, trạng thái giao
notification/
email, SMS, push
Chia theo domain giúp code gần với cách con người hiểu nghiệp vụ.
---
7.6. Ranh giới module là gì?
Ranh giới module trả lời câu hỏi:
> Module khác được phép biết gì và không được phép biết gì?
Ví dụ module payment.
Module khác có thể cần:
- Tạo yêu cầu thanh toán.
- Kiểm tra trạng thái thanh toán.
- Hoàn tiền.
Nhưng module khác không nên tự ý:
- Sửa trực tiếp bảng transaction.
- Đổi trạng thái payment bằng query riêng.
- Biết chi tiết provider nào đang dùng.
- Biết format raw response của cổng thanh toán.
Module payment nên phơi bày một interface rõ:
create_payment(order_id, amount)
mark_payment_succeeded(payment_id, provider_ref)
refund_payment(payment_id, amount)
get_payment_status(payment_id)
Các chi tiết còn lại nằm bên trong module.
Ranh giới tốt giúp thay đổi nội bộ mà ít ảnh hưởng bên ngoài.
---
7.7. Public API nội bộ của module
Trong modular monolith, "API" không nhất thiết là HTTP API. Nó có thể chỉ là hàm/class/service mà module khác được phép gọi.
Ví dụ:
ordering module gọi payment.create_payment(...)
Đây là API nội bộ.
Điều quan trọng là:
- Module khác gọi qua public interface.
- Không import lung tung file nội bộ.
- Không tự query/sửa dữ liệu thuộc module khác nếu muốn giữ ranh giới.
Ví dụ xấu:
ordering tự query bảng payment_transaction
ordering tự sửa status payment
ordering tự tính refund
Ví dụ tốt hơn:
ordering gọi payment_service.refund_payment(...)
Tách bằng hàm/service trước. Sau này nếu payment thành service riêng, interface đó có thể đổi từ gọi hàm sang gọi HTTP/message mà phần còn lại ít thay đổi hơn.
---
7.8. Service layer trong modular monolith
Service layer là nơi đặt use case/application logic.
Ví dụ use case:
PlaceOrder
CancelOrder
RefundPayment
BookSlot
SendNotification
Controller/API không nên chứa toàn bộ logic.
Controller nên làm:
- Nhận request.
- Parse input.
- Gọi use case/service.
- Trả response.
Service/use case làm:
- Kiểm tra rule nghiệp vụ.
- Gọi module liên quan qua interface.
- Quản lý transaction.
- Phát event nếu cần.
- Đẩy job vào queue nếu cần.
Ví dụ:
Order API
-> place_order_service
-> kiểm tra sản phẩm
-> tạo order
-> gọi payment module
-> phát event OrderCreated
-> đẩy job gửi email
Service layer giúp code không dính chặt vào HTTP framework. Sau này use case có thể được gọi từ API, admin, worker hoặc command line.
---
7.9. Module và database chung
Modular monolith thường dùng chung database. Đây là lợi thế lớn, nhưng cũng dễ bị lạm dụng.
Lợi thế:
- Transaction dễ.
- Query đơn giản.
- Constraint rõ.
- Không cần đồng bộ dữ liệu giữa service.
Rủi ro:
- Module nào cũng join bảng của module khác.
- Dữ liệu không có chủ sở hữu.
- Logic bị bỏ qua service layer.
- Sau này tách service rất đau.
Cách làm thực dụng:
Bên trong module
Dùng foreign key, constraint, transaction bình thường.
Ví dụ trong ordering:
Order
OrderItem
OrderStatusHistory
Các bảng này thuộc cùng module, liên kết chặt là hợp lý.
Giữa các module
Cẩn thận hơn.
Ví dụ payment cần biết order_id, nhưng không nhất thiết phải join sâu vào mọi bảng order.
Có thể lưu:
payment.order_id
Và khi cần nghiệp vụ, gọi interface của ordering.
Không phải lúc nào cũng cấm foreign key chéo. Nhưng phải hiểu rằng mỗi liên kết chéo là một sợi dây coupling.
Tư duy:
> Dùng database chung để đi nhanh, nhưng đừng để database chung phá ranh giới module.
---
7.10. Domain event trong modular monolith
Khi một việc xảy ra, nhiều module có thể quan tâm.
Ví dụ:
OrderCreated
PaymentSucceeded
BookingCancelled
UserRegistered
Nếu module ordering sau khi tạo đơn gọi trực tiếp:
- Gửi email.
- Cập nhật analytics.
- Cập nhật coupon.
- Gọi delivery.
- Gửi notification.
Thì ordering biết quá nhiều.
Cách tốt hơn:
ordering phát event OrderCreated
notification nghe event để gửi email
analytics nghe event để ghi dữ liệu
delivery nghe event để chuẩn bị giao hàng
Trong monolith, event này có thể là event nội bộ, signal, message bus trong memory, hoặc job queue tùy độ quan trọng.
Event giúp module bớt dính nhau. Nhưng dùng quá nhiều event cũng làm luồng xử lý khó lần theo.
Nguyên tắc:
- Dùng event cho side effect phụ.
- Với nghiệp vụ cần đúng ngay, vẫn nên rõ transaction/use case.
- Event quan trọng cần lưu bền hoặc dùng outbox pattern.
---
7.11. Tách module không có nghĩa là cấm giao tiếp
Một hiểu lầm: đã chia module thì module không được gọi nhau.
Không đúng.
Module vẫn phải giao tiếp. Vấn đề là giao tiếp qua ranh giới rõ.
Ví dụ:
ordering -> catalog: kiểm tra sản phẩm còn bán không
ordering -> payment: tạo thanh toán
ordering -> notification: gửi thông báo sau khi đặt hàng
Câu hỏi không phải "có gọi nhau không", mà là:
- Gọi qua interface nào?
- Có cần kết quả ngay không?
- Có nằm trong transaction không?
- Nếu module kia lỗi thì sao?
- Có thể chuyển sang async/event không?
Trong modular monolith, gọi hàm trực tiếp vẫn ổn nếu đó là use case cần kết quả ngay. Đừng biến mọi thứ thành event chỉ để trông decoupled.
---
7.12. Chuẩn bị đường tách service sau này
Modular monolith tốt giúp tách service sau này dễ hơn, nhưng không miễn phí.
Muốn tách được, module nên có:
- Trách nhiệm rõ.
- Public interface rõ.
- Dữ liệu có chủ sở hữu rõ.
- Ít import nội bộ từ module khác.
- Side effect đi qua event/job rõ.
- Test cho use case chính.
- Observability đủ để biết module đang làm gì.
Ví dụ module notification.
Ban đầu:
ordering gọi notification_service.send_order_email(...)
Sau này nếu notification thành service riêng:
ordering publish event OrderCreated
notification service consume event và gửi email
Nếu từ đầu code đã gom logic email trong module riêng, việc tách dễ hơn nhiều.
Nếu từ đầu email logic nằm rải khắp 20 nơi, tách sẽ rất đau.
---
7.13. Modular monolith không phải microservices giả
Modular monolith không cần giả vờ rằng mọi module là service riêng.
Không cần:
- Gọi HTTP nội bộ giữa các module trong cùng process.
- Mỗi module tự có database riêng ngay từ đầu.
- Mỗi module deploy riêng.
- Tự tạo độ phức tạp network khi chưa cần.
Nếu đã ở cùng process, gọi hàm là bình thường. Sức mạnh của modular monolith là giữ sự đơn giản runtime nhưng có kỷ luật trong code.
Đừng biến modular monolith thành microservices nửa mùa:
- Vẫn deploy chung.
- Nhưng lại tự gọi HTTP qua localhost.
- Vẫn database chung.
- Nhưng lại tự thêm nhiều layer network vô ích.
Tách logic không đồng nghĩa phải tách network.
---
7.14. Cấu trúc module gợi ý
Một module có thể gồm:
orders/
api/
services/
models/
events/
tasks/
tests/
Hoặc tùy framework:
orders/
views.py
serializers.py
models.py
services.py
events.py
tasks.py
tests.py
Điều quan trọng không phải tên folder. Điều quan trọng là trách nhiệm.
API/controller
Nhận request và trả response.
Services/use cases
Xử lý nghiệp vụ.
Models/schema
Định nghĩa dữ liệu thuộc module.
Events
Định nghĩa event module phát ra hoặc lắng nghe.
Tasks
Job nền liên quan đến module.
Tests
Kiểm tra use case quan trọng.
Nếu module lớn, có thể chia sâu hơn. Nếu module nhỏ, đừng chia quá nhiều file chỉ để đẹp.
---
7.15. Ví dụ flow đặt hàng trong modular monolith
Giả sử người dùng đặt bánh.
Luồng:
Order API
-> ordering.place_order()
-> catalog.check_product_available()
-> ordering.create_order()
-> payment.create_payment()
-> ordering.mark_waiting_payment()
-> event.publish(OrderCreated)
-> queue.push(send_order_email)
Các module:
catalogbiết sản phẩm còn bán hay không.orderingbiết đơn hàng và trạng thái đơn.paymentbiết thanh toán.notificationbiết gửi email/SMS.
Điểm hay:
- API không chứa logic dài.
- Ordering không tự sửa bảng payment.
- Payment không cần biết toàn bộ logic order.
- Notification có thể xử lý sau qua queue.
- Nếu sau này notification tách service, luồng chính ít thay đổi.
---
7.16. Khi modular monolith bắt đầu không đủ
Modular monolith rất mạnh, nhưng không phải mãi mãi đủ.
Dấu hiệu cần tách một phần:
- Module có workload rất khác phần còn lại.
- Module cần scale riêng.
- Module lỗi làm ảnh hưởng toàn app.
- Module cần deploy độc lập.
- Module cần dữ liệu hoặc bảo mật cách ly hơn.
- Team riêng cần ownership rõ.
Ví dụ:
- Video processing cần worker/GPU riêng.
- AI inference cần autoscale riêng.
- Realtime chat cần nhiều connection.
- Search cần Elasticsearch cluster.
- Analytics cần warehouse/pipeline riêng.
Tách service nên là bước tiếp theo tự nhiên, không phải bước đầu tiên.
---
7.17. Những lỗi tư duy phổ biến
Lỗi 1: Chia folder nhưng không chia trách nhiệm
Nếu folder đẹp nhưng module vẫn import chéo lung tung, đó không phải modular monolith tốt.
Lỗi 2: Service layer thành god service
Service layer không phải nơi gom mọi logic vào một file khổng lồ. Mỗi use case nên có trách nhiệm rõ.
Lỗi 3: Cấm mọi foreign key chéo một cách máy móc
Không phải mọi liên kết chéo đều xấu. Vấn đề là hiểu coupling và boundary. Trong monolith, thực dụng vẫn quan trọng.
Lỗi 4: Dùng event cho mọi thứ
Event giúp decouple, nhưng lạm dụng event làm luồng nghiệp vụ khó đọc. Việc cần kết quả ngay có thể gọi trực tiếp.
Lỗi 5: Giả microservices trong cùng process
Gọi HTTP nội bộ giữa module cùng app thường chỉ thêm overhead. Nếu chưa tách runtime, không cần tự tạo network boundary giả.
Lỗi 6: Không có test cho boundary
Nếu module có interface quan trọng, nên có test để đảm bảo module khác dùng không bị phá.
---
7.18. Checklist modular monolith khỏe
Một modular monolith khỏe thường có:
- Module chia theo nghiệp vụ.
- Mỗi module có trách nhiệm rõ.
- Public interface rõ.
- Logic chính nằm trong service/use case.
- Controller/API mỏng.
- Việc dài đi qua queue/worker.
- Event dùng cho side effect phù hợp.
- Database chung nhưng có ownership tương đối rõ.
- Không import nội bộ lung tung.
- Có test cho use case quan trọng.
- Có logs/metrics cho luồng quan trọng.
Nếu thiếu những điều này, hệ thống có thể vẫn là monolith, nhưng chưa thật sự modular.
---
7.19. Kết luận của chương
Modular monolith là một trong những kiến trúc thực dụng nhất cho nhiều sản phẩm.
Nó giữ được lợi thế của monolith:
- Dễ deploy.
- Dễ debug.
- Dễ transaction.
- Ít chi phí vận hành.
Nhưng giảm rủi ro monolith rối bằng cách:
- Chia module theo nghiệp vụ.
- Đặt ranh giới rõ.
- Giao tiếp qua interface.
- Tách logic vào service/use case.
- Dùng event/job đúng chỗ.
- Chuẩn bị khả năng tách service sau này.
Thông điệp quan trọng:
> Đừng bắt đầu bằng microservices chỉ để có vẻ hiện đại. Hãy bắt đầu bằng một modular monolith tốt. Khi áp lực thật xuất hiện, bạn sẽ biết tách phần nào và vì sao.