Table of Contents9 sections
Có một tình huống mà gần như kỹ sư nào cũng từng gặp: một thành viên mới gia nhập team, được giao xem "tài liệu kiến trúc" của hệ thống, và nhận lại một sơ đồ vẽ trên Draw.io từ hai năm trước. Trong đó có vài chục hình chữ nhật, vài chục mũi tên, một số đã lỗi thời, một số thì chi tiết đến mức liệt kê cả tên class trong code, còn phần quan trọng nhất (hệ thống này tồn tại để giải quyết vấn đề gì, cho ai) lại không hề xuất hiện.
Vấn đề không nằm ở việc thiếu công cụ vẽ sơ đồ. Vấn đề là hầu hết chúng ta vẽ kiến trúc theo cảm tính: mỗi người chọn một mức độ chi tiết khác nhau, một bộ ký hiệu khác nhau, và không ai thống nhất được sơ đồ này dành cho ai đọc. Đây chính là khoảng trống mà C4 Model được sinh ra để lấp đầy.
C4 là gì: một bản đồ có nhiều mức phóng to
Cách dễ nhất để hiểu C4 là liên tưởng đến Google Maps. Khi mở bản đồ, bạn không nhìn thấy toàn bộ chi tiết cùng lúc: ở mức phóng nhỏ nhất, bạn chỉ thấy quốc gia và biên giới; phóng to hơn, bạn thấy thành phố và các quận; phóng to hơn nữa, bạn thấy từng con đường; và nếu bấm vào một tòa nhà cụ thể, bạn có thể xem sơ đồ mặt bằng bên trong. Mỗi mức phóng phục vụ một mục đích khác nhau, và không mức nào "đúng hơn" mức nào, chúng bổ sung cho nhau.
C4 (viết tắt của Context, Container, Component, Code) áp dụng đúng nguyên lý đó vào việc vẽ kiến trúc phần mềm. Đây là mô hình do Simon Brown đề xuất, gồm bốn cấp độ sơ đồ lồng vào nhau, mỗi cấp trả lời một câu hỏi khác nhau và dành cho một nhóm người đọc khác nhau:
Cấp 1, System Context: hệ thống này tồn tại để làm gì, ai dùng nó, nó nói chuyện với hệ thống bên ngoài nào.
Cấp 2, Container: bên trong hệ thống gồm những khối chạy được độc lập nào (web app, API, database, hàng đợi...) và chúng giao tiếp với nhau ra sao.
Cấp 3, Component: bên trong một container cụ thể, các thành phần logic được tổ chức thế nào.
Cấp 4, Code: chi tiết đến mức class, interface, thường được sinh tự động từ IDE hơn là vẽ tay.
Điểm mấu chốt là bạn không bắt buộc phải vẽ đủ cả bốn cấp cho mọi hệ thống. Cấp 1 và 2 gần như luôn cần thiết; cấp 3 chỉ nên vẽ cho những container thực sự phức tạp; cấp 4 hiếm khi cần vẽ tay trong thực tế.
Để hình dung rõ hơn về cách bốn cấp độ này lồng vào nhau, sau đây là minh họa:

Để bốn cấp độ này không chỉ là lý thuyết, phần tiếp theo sẽ áp dụng trực tiếp vào một hệ thống có thật: nền tảng Builder mà đội ngũ Classmethod Danang đã xây dựng để làm blog kỹ thuật nội bộ.
Áp dụng C4 vào một hệ thống thật: Builder của Classmethod Danang
Builder là nền tảng blog kỹ thuật của Classmethod Danang, được thiết kế theo kiến trúc Hybrid Serverless trên AWS: tách biệt hoàn toàn luồng đọc tĩnh (phục vụ qua S3 và CloudFront) khỏi luồng ghi động (đi qua API Gateway, Lambda và DynamoDB), nhờ đó chi phí vận hành gần như bằng 0 khi không có traffic. Đây là một ví dụ khá lý tưởng để minh họa C4, vì hệ thống đủ nhỏ để trình bày trọn vẹn nhưng vẫn đủ phức tạp để cả bốn cấp độ đều có ý nghĩa thực tế.
Cấp 1: System Context, bức tranh dành cho người không đọc code
Câu hỏi mà cấp này trả lời rất đơn giản: hệ thống nói chuyện với ai. Không có hộp nào biểu diễn công nghệ cụ thể (không có AWS Lambda, không có DynamoDB), chỉ có: người dùng, hệ thống đang xét, và các hệ thống bên ngoài mà nó phụ thuộc vào.
Với Builder, bức tranh ở cấp này khá gọn: có hai nhóm người dùng là Tác giả (viết và xuất bản bài) và Độc giả (đọc bài đã xuất bản), một hệ thống trung tâm là Builder, và một hệ thống bên ngoài là Auth0, đảm nhiệm xác thực danh tính cho tác giả. Một sơ đồ như vậy có thể trình bày cho quản lý, cho khách hàng, hoặc cho một kỹ sư mới ngày đầu tiên đi làm, và tất cả đều hiểu ngay mà không cần biết AWS là gì.

Cấp 2: Container, mở hộp trung tâm ra
Ở cấp 1, "Hệ thống Builder" là một hộp đen. Cấp 2 mở hộp đó ra và hỏi: bên trong gồm những khối nào có thể triển khai, chạy, và mở rộng độc lập với nhau. Lưu ý rằng "container" ở đây không phải là Docker container, mà là bất kỳ đơn vị thực thi độc lập nào: một ứng dụng web, một API, một database, một hàng đợi message.
Với Builder, việc tách container đi theo đúng ranh giới giữa luồng đọc và luồng ghi:
Luồng đọc (read path) gồm hai container: CloudFront đóng vai trò tầng phân phối nội dung, và S3 lưu trữ các trang HTML tĩnh đã build sẵn. Hai container này không hề chạm tới bất kỳ container nào khác khi phục vụ độc giả, đó chính là lý do luồng đọc gần như không có giới hạn về khả năng chịu tải.
Luồng ghi (write path) gồm nhiều container hơn: API Gateway tiếp nhận request từ giao diện soạn thảo, kiểm tra JWT ngay tại tầng này trước khi request được chuyển tiếp; Lambda (chạy trên kiến trúc ARM64) xử lý logic nghiệp vụ; DynamoDB được tách thành hai bảng riêng biệt là Author Workspace (lưu nháp và bài đã đăng) và Reader Store (chỉ lưu bài đã xuất bản), việc tách bảng này ngăn truy vấn của độc giả quét nhầm vào dữ liệu nháp; SQS nhận tín hiệu khi có bài mới được xuất bản; và GitHub Actions (một hệ thống bên ngoài) thực hiện việc build lại trang tĩnh và đồng bộ lên S3.

Nhìn vào sơ đồ này, một kỹ sư mới có thể trả lời ngay câu hỏi "nếu traffic đọc tăng gấp mười lần thì phần nào chịu áp lực": không phần nào cả, vì luồng đọc không chạm vào Lambda hay DynamoDB. Đây chính là giá trị thực tế của cấp Container: nó giúp ra quyết định kỹ thuật (scaling, chi phí, điểm lỗi) mà không cần đọc một dòng code nào.
Cấp 3: Component, khi một container đủ phức tạp để cần mổ xẻ
Không phải container nào cũng cần vẽ tiếp ở cấp 3. Với Builder, CloudFront hay S3 chỉ là dịch vụ quản lý sẵn của AWS, không có gì để "mổ xẻ" thêm. Nhưng container Lambda, nơi chứa toàn bộ logic nghiệp vụ của luồng ghi, lại đáng được zoom sâu hơn, vì nó gộp nhiều trách nhiệm khác nhau bên trong cùng một runtime.
Bên trong Lambda của Builder có thể chia thành các thành phần logic: một Authorizer Component kiểm tra JWT trước khi cho phép request đi tiếp; một Draft Handler xử lý việc lưu nháp; một Publish Handler xử lý việc xuất bản và ghi vào cả hai bảng DynamoDB khi cần; và một Build Trigger Component chịu trách nhiệm gửi tín hiệu vào SQS sau khi xuất bản thành công.

Cấp Component là nơi các cuộc thảo luận trong team về "nên tách handler này thành hai hàm riêng hay không" hoặc "component nào nên có unit test kỹ nhất" thực sự diễn ra. Đây là sơ đồ dành cho người sẽ trực tiếp code trong container đó, không phải để trình bày cho khách hàng.
Cấp 4: Code, cấp độ thường không cần vẽ tay
Cấp cuối cùng đi xuống tới class, interface, method. Trong thực tế, phần lớn team bỏ qua việc vẽ tay cấp này, vì hai lý do: thứ nhất, code thay đổi liên tục nên sơ đồ vẽ tay sẽ lỗi thời chỉ sau vài commit; thứ hai, hầu hết IDE hiện đại (IntelliJ, WebStorm...) đã có tính năng sinh class diagram trực tiếp từ code khi cần, nên không cần duy trì một bản vẽ tay song song. Nếu thật sự cần, chỉ nên sinh cấp này theo yêu cầu, xem xong rồi bỏ, thay vì lưu trữ như tài liệu chính thức.
Chọn đúng cấp độ cho đúng người đọc
Sai lầm phổ biến nhất khi áp dụng C4 không phải là vẽ sai ký hiệu, mà là chọn sai cấp độ cho đúng đối tượng. Đưa một sơ đồ Component đầy chi tiết kỹ thuật cho khách hàng sẽ khiến họ rối, còn đưa một sơ đồ Context quá đơn giản cho một kỹ sư sắp implement tính năng thì lại không đủ thông tin để bắt tay vào việc.
Cách phân bổ hợp lý thường là: cấp Context dùng khi trình bày cho stakeholder không có nền tảng kỹ thuật, cho khách hàng, hoặc khi giới thiệu hệ thống ở buổi onboarding đầu tiên. Cấp Container dùng khi một kỹ sư mới cần nắm bức tranh toàn cảnh trước khi đụng vào bất kỳ phần nào, hoặc khi hai team cần thống nhất ranh giới trách nhiệm giữa các service. Cấp Component dùng trong nội bộ team đang trực tiếp phát triển một container cụ thể, thường xuất hiện trong tài liệu thiết kế trước khi code (design doc), không phải tài liệu công khai. Cấp Code hầu như chỉ cần khi debug một vấn đề rất cụ thể và muốn hình dung nhanh mối quan hệ giữa vài class.
Một lưu ý khác: không phải hệ thống nào cũng cần đủ cả bốn cấp. Với một service nhỏ, chỉ có vài endpoint, dừng lại ở cấp Context và Container là đủ, cố vẽ thêm Component cho một service đơn giản chỉ tạo ra tài liệu thừa mà không ai đọc. Ngược lại, với một hệ thống lớn, không cần vẽ Component cho toàn bộ container, chỉ nên dành công sức đó cho những container thực sự phức tạp hoặc đang có nhiều thay đổi.
Một vài lưu ý khi bắt đầu áp dụng
C4 vốn dĩ là một notation nhẹ, không phải một chuẩn cứng nhắc đòi hỏi phê duyệt hay quy trình phức tạp. Giá trị của nó nằm ở việc mọi người trong tổ chức nói cùng một ngôn ngữ khi bàn về kiến trúc, không nằm ở việc tuân thủ ký hiệu một cách máy móc.
Một rủi ro thường gặp là để sơ đồ trôi dần khỏi thực tế: kiến trúc thay đổi nhưng sơ đồ Container vẽ từ sáu tháng trước vẫn còn nằm trong tài liệu onboarding. Cách giảm thiểu rủi ro này là ưu tiên các công cụ cho phép định nghĩa sơ đồ bằng text thay vì kéo thả, chẳng hạn cú pháp C4 của Mermaid, để sơ đồ có thể lưu trong repository cùng code và review qua pull request như bất kỳ thay đổi nào khác. Một ví dụ ngắn cho sơ đồ Context của Builder viết bằng cú pháp C4 của Mermaid:
C4Context
title Sơ đồ System Context của Builder
Person(author, "Tác giả", "Viết và xuất bản bài viết")
Person(reader, "Độc giả", "Đọc bài viết đã xuất bản")
System(builder, "Hệ thống Builder", "Nền tảng blog kỹ thuật nội bộ")
System_Ext(auth0, "Auth0", "Xác thực danh tính tác giả")
Rel(author, builder, "Soạn thảo, xuất bản")
Rel(reader, builder, "Đọc bài viết")
Rel(builder, auth0, "Xác thực JWT")
FYI: Tại thời điểm viết bài này, Builder chưa render được Mermaid, nên tui render cho mấy bạn coi nè. Xấu lắm, nhưng nhìn vào là hiểu mà, phải vậy không?
Nếu muốn thử trực tiếp trên máy mà không cần cài đặt gì thêm ngoài Node.js, có thể dùng công cụ dòng lệnh của Mermaid thông qua pnpm dlx @mermaid-js/mermaid-cli để render thử sơ đồ trên thành hình ảnh, khá tiện cho việc kiểm tra nhanh trước khi đưa vào tài liệu chính thức.
Kết
C4 không giải quyết vấn đề thiết kế kiến trúc tốt hay tệ, nó chỉ giải quyết vấn đề giao tiếp về kiến trúc. Nhưng trong một đội ngũ kỹ thuật, phần lớn thời gian mất đi vì hiểu lầm không nằm ở việc code sai, mà nằm ở việc hai người tưởng mình đang nói về cùng một thứ trong khi thực ra không phải vậy. Một bộ sơ đồ Context và Container rõ ràng, cập nhật, và đúng đối tượng người đọc, thường mang lại giá trị lớn hơn nhiều so với công sức bỏ ra để vẽ chúng.
Nếu bạn đang có một hệ thống mà chưa từng vẽ qua C4, cách bắt đầu đơn giản nhất là dừng lại ở cấp Context: chỉ cần liệt kê người dùng, hệ thống, và các bên ngoài mà nó phụ thuộc. Sơ đồ đó thường mất chưa tới nửa giờ để vẽ, nhưng lại là thứ mà cả team, từ quản lý tới kỹ sư mới, đều có thể nhìn vào và hiểu ngay hệ thống đang làm gì.
Lê Hữu Tiến là Associate Engineering Manager tại Classmethod Danang.
