Nguyen Le Phong

Nền tảng Kiến trúc Phần mềmPhần 4/6

Cấu trúc Codebase: Theo Lớp, Theo Tính năng, hay Theo Domain?

Thư mục cấp cao nhất nên là controllers/services/models, hay orders/billing/auth? Lựa chọn này âm thầm định hình cách codebase lớn lên. Một chuyến tham quan thực tế qua cấu trúc theo lớp, theo tính năng, và screaming architecture — với những đánh đổi ở mỗi quy mô công ty.

Bạn mở một project mới và việc đầu tiên làm — trước khi viết một dòng logic nào — là tạo một thư mục. Có thể nó tên controllers/. Có thể tên orders/. Dù sao đi nữa, lựa chọn thầm lặng đó đã là một quyết định kiến trúc, và nó sẽ âm thầm định hình mọi thứ tiếp theo: tốc độ đồng đội mới tìm được hướng, số file bạn phải chạm khi một tính năng thay đổi, và liệu sáu tháng sau codebase của bạn đọc giống business hay giống framework của bạn.

Bài này là chuyến tham quan thực tế qua ba cách tiếp cận chính — theo lớp, theo tính năng, và screaming (theo domain) — với những đánh đổi thành thật ở mỗi quy mô công ty. Không có câu trả lời duy nhất thắng mọi tình huống, nhưng bạn sẽ rời đi với một mô hình tâm trí rõ ràng để lựa chọn có chủ đích thay vì theo mặc định.

Tại sao cấu trúc thư mục là quyết định kiến trúc, không phải chuyện vặt

Người ta hay gạt bỏ cấu trúc thư mục là "chỉ là tổ chức". Nó nhiều hơn thế rất nhiều. Cách bạn nhóm code kiểm soát ba thứ hóa ra quan trọng cực kỳ:

  • Coupling. Khi code thay đổi cùng nhau lại sống xa nhau, mỗi cập nhật nhỏ biến thành cuộc thám hiểm qua nhiều thư mục. Khi nó sống cùng nhau, các thay đổi liên quan cụm lại ở một nơi.
  • Khả năng khám phá. Một developer chưa quen codebase sẽ hỏi, "code billing ở đâu?" Câu trả lời cho câu hỏi đó — một thư mục, hay năm thư mục rải rác — quyết định thời gian onboard.
  • Blast radius. Bạn phải mở bao nhiêu file để ship một tính năng mới, hay để xóa an toàn một tính năng cũ? Cấu trúc hoặc hạn chế blast radius đó hoặc để nó lan rộng không kiểm soát.
Mô hình tâm trí

Hãy nghĩ cấu trúc thư mục như một bản đồ. Bản đồ tốt nhóm những thứ dựa trên cách chúng liên quan trong thế giới thực — không phải dựa trên chất liệu chúng được làm bằng. Nhóm file theo kiểu (controller, model) giống như sắp xếp biểu tượng bản đồ theo hình dạng. Nhóm theo tính năng giống như sắp xếp theo khu phố. Một cái giúp bạn điều hướng; cái kia kỹ thuật đúng nhưng thực tế vô dụng.

Định luật Conway — quan sát rằng hệ thống phần mềm phản chiếu cấu trúc giao tiếp của các team xây dựng chúng — cũng áp dụng ở đây. Nếu team bạn được tổ chức xung quanh năng lực nghiệp vụ (squad payments, squad orders), cấu trúc theo tính năng sẽ cảm thấy tự nhiên và thực thi đúng ranh giới. Cấu trúc theo lớp sẽ đấu lại bạn.

Theo lớp: mặc định quen thuộc

Tổ chức theo lớp nhóm file theo vai trò kỹ thuật. Một project Node hoặc Spring Boot điển hình trông như thế này:

src/
├─ controllers/
│  ├─ OrderController.ts
│  ├─ UserController.ts
│  └─ BillingController.ts
├─ services/
│  ├─ OrderService.ts
│  ├─ UserService.ts
│  └─ BillingService.ts
├─ repositories/
│  ├─ OrderRepository.ts
│  ├─ UserRepository.ts
│  └─ BillingRepository.ts
└─ models/
   ├─ Order.ts
   ├─ User.ts
   └─ Invoice.ts
Cấu trúc theo lớp: mỗi lớp kỹ thuật có thư mục cấp cao riêng. Thêm một tính năng đồng nghĩa thêm file vào tất cả bốn thư mục.

Mọi tutorial web framework đều đổ bộ ở đây. Rails làm vậy, starter Spring Boot làm vậy, Express generator làm vậy. Sự quen thuộc đó là tài sản chính của nó.

Khi nào theo lớp tỏa sáng:

  • App nhỏ với hai ba tính năng — cấu trúc phẳng đến mức hầu như không quan trọng.
  • Team nơi mọi người làm trên tất cả tính năng; lớp là đơn vị chuyên biệt hóa.
  • Prototype và công cụ nội bộ nơi tốc độ thiết lập ban đầu quan trọng hơn sự rõ ràng lâu dài.

Khi nào theo lớp gây đau:

  • Một tính năng bị rải rác qua các thư mục. Thêm tính năng "hoàn tiền" có nghĩa chạm vào controllers/, services/, repositories/, và models/. Đó là bốn thư mục cho thứ về mặt khái niệm là một việc.
  • Xóa một tính năng là dự án khảo cổ. Làm sao bạn biết file nào trong services/ thuộc về billing so với orders? Tên thư mục không nói cho bạn biết.
  • Merge conflict nhân lên. Mọi thay đổi tính năng đều chạm vào cùng file theo lớp, gây va chạm khi hai developer làm song song.
Vách đá khi mở rộng

Theo lớp hoạt động ổn đến khoảng 10–15 tính năng. Vượt qua đó, mỗi thư mục cấp cao trở thành ngăn kéo tạp nham: thư mục services/ 40 file, không có quyền sở hữu rõ ràng, và pull request chạm vào file mà reviewer chưa từng thấy.

Theo tính năng: một thay đổi nằm trong một thư mục

Tổ chức theo tính năng nhóm file theo phần sản phẩm chúng phục vụ. Các lớp kỹ thuật (controller, service, model) vẫn tồn tại — chúng chỉ sống bên trong mỗi thư mục tính năng thay vì ở cấp cao nhất:

src/
├─ orders/
│  ├─ OrderController.ts
│  ├─ OrderService.ts
│  ├─ OrderRepository.ts
│  └─ Order.ts
├─ billing/
│  ├─ BillingController.ts
│  ├─ BillingService.ts
│  ├─ BillingRepository.ts
│  └─ Invoice.ts
├─ auth/
│  ├─ AuthController.ts
│  ├─ AuthService.ts
│  └─ User.ts
└─ shared/
   ├─ database.ts
   ├─ logger.ts
   └─ Money.ts
Cấu trúc theo tính năng: mỗi khái niệm sản phẩm sở hữu thư mục riêng. Thêm tính năng hoàn tiền chỉ thêm một thư mục — không có gì khác di chuyển.

Khi nào theo tính năng tỏa sáng:

  • Một thay đổi nằm trong một thư mục. Thêm luồng hoàn tiền có nghĩa thêm file bên trong billing/ — không thư mục nào khác bị chạm. Blast radius nhỏ và gọn.
  • Xóa một tính năng đơn giản. Xóa thư mục. Nếu thứ gì đó bên ngoài vẫn import từ nó, IDE của bạn sẽ báo ngay.
  • Quyền sở hữu rõ ràng. Team payments sở hữu billing/; team identity sở hữu auth/. Monorepo tooling và quy tắc code review có thể thực thi điều này cơ học.
  • Onboard nhanh hơn. Kỹ sư mới làm về orders chỉ cần đọc orders/. Họ không cần hiểu toàn bộ codebase trước.

Khi nào theo tính năng cần cẩn thận:

  • Vị trí code shared lúng túng. Kiểu Money được dùng bởi cả orders/billing/. Nó nằm trong orders/? Cảm giác sai. Nó kết thúc trong shared/ — cái có thể tự lớn thành ngăn kéo tạp nham thứ hai nếu không ai sở hữu nó.
  • Dependency xuyên tính năng cần kỷ luật. Nếu orders/OrderService.ts import trực tiếp từ billing/BillingService.ts, bạn đã tạo một coupling mà cấu trúc thư mục không thể thấy. Các team dùng lint rule (eslint-plugin-import/no-restricted-paths) hay module-boundary tooling (Nx, NestJS module) để làm điều này rõ ràng.

Screaming Architecture: cấp cao nhất của bạn nói gì?

Uncle Bob (Robert C. Martin) đặt ra thuật ngữ Screaming Architecture trong một bài blog nổi tiếng: "Kiến trúc ứng dụng của bạn la hét điều gì? Khi bạn nhìn vào cấu trúc thư mục cấp cao nhất, và các file nguồn trong package cấp cao nhất; chúng có la hét: Hệ thống Y tế, hay Hệ thống Kế toán, hay Hệ thống Quản lý Kho? Hay chúng la hét: Rails, hay Spring/Hibernate, hay ASP.Net?"

Nhận thức này thật thanh lịch: nếu thư mục cấp cao nhất của bạn là controllers/, models/, views/, codebase của bạn đang la hét MVC framework. Framework là công cụ — nó không nên là thứ đầu tiên người đọc gặp.

Screaming Architecture (còn gọi là theo domain) đưa theo tính năng thêm một bước. Không chỉ tính năng ở cấp cao nhất — tên của các tính năng đó được lấy trực tiếp từ ngôn ngữ nghiệp vụ (Ubiquitous Language của Domain-Driven Design), không phải từ vai trò kỹ thuật:

// La hét "nền tảng thương mại điện tử"
src/
├─ catalog/          // khái niệm domain: danh mục sản phẩm
├─ ordering/         // khái niệm domain: đặt và theo dõi đơn hàng
├─ fulfillment/      // khái niệm domain: lấy hàng, đóng gói, giao hàng
├─ payments/         // khái niệm domain: tính phí, hoàn tiền, hóa đơn
└─ identity/         // khái niệm domain: tài khoản, auth, quyền hạn

// So sánh với phương án la hét framework:
src/
├─ controllers/      // la hét "MVC"
├─ models/           // la hét "ORM"
├─ views/            // la hét "templating engine"
└─ services/         // la hét "service layer pattern"

Bên trong mỗi thư mục domain, bố cục nội bộ tùy team — và điều đó ổn. Lời hứa chính là cấp cao nhất giao tiếp nghiệp vụ, không phải infrastructure. Một developer mới (hoặc CTO, hay chuyên gia domain từ phía business) có thể đọc các thư mục cấp cao nhất và hiểu hệ thống làm gì mà không cần biết nó được xây như thế nào.

So sánh đối chiếu

Đây là đánh giá thành thật trên năm chiều thực tế:

Chiều đánh giá Theo lớp Theo tính năng Theo domain (screaming)
Khả năng khám phá Thấp ở quy mô lớn — "code billing ở đâu?" không có câu trả lời nhanh Cao — một thư mục mỗi khái niệm Cao nhất — tên thư mục đến từ chính nghiệp vụ
Tính cục bộ của thay đổi Kém — thay đổi một tính năng rải rác qua tất cả các lớp Tốt — thay đổi cụm trong một thư mục Tốt — như theo tính năng, cộng thêm ngôn ngữ domain rõ ràng
Tốc độ onboard Chậm — người mới phải hiểu toàn bộ lớp trước khi chạm vào gì Nhanh — nghiên cứu một thư mục để làm một tính năng Nhanh nhất — tên domain hướng dẫn hiểu biết mà không cần ngữ cảnh trước
Rủi ro coupling Cao — lớp shared trở thành bề mặt coupling ngầm Vừa — import xuyên tính năng có thể xảy ra nhưng nhìn thấy được Thấp — bounded context rõ ràng và ranh giới được thực thi
Rủi ro "Đống bùn khổng lồ" Cao — các lớp trở thành ngăn kéo tạp nham khi tính năng nhân lên Vừa — thư mục shared/ có thể phình to vô hạn Thấp — ranh giới domain làm bùn hiện ra sớm

Hybrid thực dụng mà hầu hết team giỏi đi đến

Trong thực tế, các team tốt nhất không chọn một cách tiếp cận rồi áp dụng nó mọi nơi với tính nhất quán mang tính tôn giáo. Họ dùng hybrid thực dụng: thư mục tính năng hoặc domain ở cấp cao nhất, các lớp kỹ thuật nhỏ bên trong mỗi cái.

Cấu trúc hybrid: thư mục domain/tính năng ở cấp cao nhất, các lớp kỹ thuật bên trong mỗi cái. HYBRID THỰC DỤNG — domain ở cấp cao nhất, lớp bên trong src/ orders/ billing/ shared/ controller.ts service.ts repository.ts Order.ts controller.ts service.ts repository.ts Invoice.ts Money.ts logger.ts database.ts
Hybrid mà hầu hết team đi đến: domain/tính năng ở cấp cao nhất, rồi các lớp kỹ thuật nhỏ bên trong mỗi thư mục. Các lớp kỹ thuật vẫn ở đây — chúng chỉ không còn là thứ đầu tiên bạn thấy.

Hybrid này mang lại điều tốt nhất của cả hai:

  • Cấp cao nhất la hét nghiệp vụ của bạn — người mới biết ngay đây là app thương mại điện tử, không phải khung MVC chung.
  • Mỗi tính năng tự chứa — thay đổi luồng billing chỉ chạm vào billing/.
  • Lớp kỹ thuật vẫn tồn tại bên trong mỗi thư mục, nên developer nghĩ theo MVC hay Clean Architecture không bị lạc. Họ chỉ nhìn bên trong thư mục tính năng.
  • Thư mục shared/ chứa các mối quan tâm thực sự xuyên suốt (value object Money, logger, kết nối database). Quy tắc chính: nếu thứ gì được thêm vào shared/, nó nên được trích xuất và sở hữu có chủ đích — không phải đổ vào đó như lối thoát.
Đặt tên thư mục shared

Một số team gọi nó là shared/, những team khác là common/, lib/, hoặc core/. Tên ít quan trọng hơn kỷ luật: hãy đối xử với nó như thư viện nội bộ mini. Nếu một file cứ bị sửa cùng với một tính năng cụ thể, nó không thuộc về shared — nó thuộc về bên trong tính năng đó.

Ở quy mô team: ranh giới được thực thi và monorepo

Khi codebase lớn lên và nhiều team đóng góp, quy ước thư mục một mình không đủ — một developer luôn có thể với tay qua ranh giới bằng một import tương đối nhanh. Bước tiếp theo là làm cho ranh giới được thực thi:

  • NestJS module làm dependency giữa các module rõ ràng: bạn chỉ có quyền truy cập những gì module khác exports. Import vượt qua ranh giới module mà không đi qua public API là lỗi compile.
  • Nx boundary rule cho phép bạn khai báo tính năng hay thư viện nào được phép phụ thuộc vào cái nào, rồi thực thi nó như lint error trong CI.
  • Monorepo đưa điều này đi xa nhất: mỗi domain (orders, billing, auth) trở thành package riêng với package.json riêng, test riêng, và export được công bố rõ ràng. Turborepo và Nx là công cụ thống trị trong hệ sinh thái JavaScript để quản lý điều này. Cấu trúc trông ít giống cây thư mục hơn và giống đồ thị package hơn — nhưng ý tưởng cốt lõi giống hệt: nhóm theo năng lực nghiệp vụ, làm ranh giới rõ ràng, thực thi chúng cơ học.

Bước nhảy sang monorepo không phải lúc nào cũng cần thiết. Nhiều team hoạt động vui vẻ với một package duy nhất và cấu trúc hybrid kỷ luật tốt trong nhiều năm. Với tới monorepo tooling khi coupling xuyên team trở thành nỗi đau thực sự — không phải phòng xa.

Khuyến nghị theo quy mô công ty

Cấu trúc phù hợp phụ thuộc vào tổ chức của bạn đang ở đâu hôm nay. Đây là khuyến nghị trực tiếp cho mỗi giai đoạn:

Giai đoạn Cấu trúc nên chọn Tại sao Gợi ý thực tế
Solo / startup trước ra mắt Theo lớp hoặc theo tính năng nông Bạn có 3–5 tính năng. Bất kỳ cấu trúc nào cũng ổn. Tối ưu hóa cho tốc độ và đơn giản. Hầu hết mặc định tutorial Rails/Express đều ổn ở đây. Đừng over-engineer.
Startup đang lớn (5–20 kỹ sư) Theo tính năng với thư mục shared/ Tính năng đang nhân lên. Theo lớp bắt đầu gây đau. Thay đổi payments không nên lan sang orders. Nhiều công ty Series A thành công sống ở đây: một repo, một service, tính năng là thư mục cấp cao nhất.
Scale-up (20–100 kỹ sư) Hybrid (domain ở cấp cao nhất, lớp bên trong) + lint rule ranh giới Các team sở hữu domain. Coupling xuyên domain ngẫu nhiên là rủi ro thực. Thực thi ranh giới trong CI. Đây là điểm ngọt của Nx/NestJS module. Rails app cốt lõi của Shopify nổi tiếng đã áp dụng phân tách domain mạnh ở giai đoạn này.
Enterprise (100+ kỹ sư) Monorepo với domain package, hoặc service độc lập mỗi bounded context Cô lập ở cấp package, deployment độc lập, và public API được phiên bản hóa giữa các domain trở nên cần thiết. Google, Meta, và Airbnb vận hành monorepo khổng lồ với quyền sở hữu nghiêm ngặt. Nhiều enterprise đi theo hướng ngược lại: microservice mỗi domain, là screaming architecture đưa đến kết luận logic của nó.
Sai lầm phổ biến

Sai lầm cấu trúc phổ biến nhất không phải là chọn cách tiếp cận sai — mà là ở lại cách tiếp cận sai quá lâu. Các team bắt đầu với theo lớp, cảm thấy nỗi đau ở 20 tính năng, và vẫn không refactor vì "cấu trúc vẫn hoạt động, về mặt kỹ thuật". Một buổi chiều tổ chức lại thư mục ở 15 tính năng tiết kiệm nhiều tháng nhầm lẫn ở 50.

Nhìn thấy sự khác biệt: những ô nào sáng lên khi bạn ship một tính năng

Cách cụ thể nhất để cảm nhận sự khác biệt giữa theo lớp và theo tính năng là hỏi: những ô nào sáng lên khi tôi thêm một tính năng mới?

Theo lớp: một tính năng mới chạm vào tất cả bốn ô lớp. Theo tính năng: một tính năng mới chỉ chạm vào một ô. THEO LỚP — thêm "hoàn tiền" controllers/ ← chạm services/ ← chạm repositories/ ← chạm models/ ← chạm 4 thư mục bị chạm blast radius cao THEO TÍNH NĂNG — thêm "hoàn tiền" orders/ billing/ ← chạm auth/ shared/ 1 thư mục bị chạm blast radius gọn
Thêm tính năng "hoàn tiền". Bên trái (theo lớp), tất cả bốn lớp kỹ thuật sáng lên — mọi thư mục lớp đều bị chạm. Bên phải (theo tính năng), chỉ có billing/ sáng lên. Phần còn lại của codebase bị cô lập cấu trúc khỏi thay đổi này.

Sơ đồ này là lập luận rõ ràng nhất cho việc nhóm theo tính năng. "Blast radius" không phải là khái niệm trừu tượng — nó là số thư mục và file bạn mở trong một pull request duy nhất. Reviewer, pipeline CI, và git blame đều hoạt động tốt hơn khi thay đổi tính năng là một đoạn gắn kết, không phải phết rộng qua mọi lớp.

Những điều cốt lõi cần nhớ

  • Cấu trúc là kiến trúc. Bố cục thư mục kiểm soát coupling, khả năng khám phá, và blast radius của mọi thay đổi tương lai.
  • Theo lớp quen thuộc và ổn cho app nhỏ, nhưng trở thành ngăn kéo tạp nham sau 10–15 tính năng.
  • Theo tính năng giữ các thay đổi cục bộ. Tính năng mới chạm vào một thư mục. Xóa tính năng là xóa một thư mục.
  • Screaming Architecture nói cấp cao nhất nên mô tả nghiệp vụ của bạn, không phải framework. Nếu stakeholder không kỹ thuật có thể đọc tên thư mục và hiểu hệ thống làm gì, bạn đã thành công.
  • Hybrid thực dụng mà hầu hết team giỏi đi đến: tên domain/tính năng ở cấp cao nhất, các lớp kỹ thuật nhỏ bên trong mỗi thư mục.
  • Thực thi ranh giới khi mở rộng. Quy ước thư mục chỉ là gợi ý; hệ thống module, lint rule, và package monorepo biến chúng thành đảm bảo.
  • Tổ chức lại sớm — một buổi chiều refactor thư mục ở 15 tính năng tránh được nhiều tháng nhầm lẫn ở 50.

Bây giờ bạn đã thấy cách cấu trúc code bên trong một service duy nhất, câu hỏi tự nhiên tiếp theo là phải làm gì khi một service không đủ. Đọc tiếp: Monolith → Microservices.