Bạn đã bao giờ mở một giải pháp .NET với 47 project, mỗi project chứa đúng một class chưa? Hay điều hướng qua các cấp thư mục sâu đến nỗi Visual Studio phải cần đến la bàn? Tôi đã từng trải qua tất cả những điều đó. Và lần nào cũng có người nói: “Chúng ta sẽ tái cấu trúc sau.”
Đoán xem — họ đã không làm.
Bài viết này là hướng dẫn thực tế về cấu trúc thư mục Clean Architecture, tổ chức project và quản lý dependency trong .NET — những pattern đã được kiểm chứng giúp team duy trì codebase lớn.
Mục lục
Những Vấn Đề Phổ Biến Với Cấu Trúc Project .NET
Mọi chuyện thường bắt đầu như thế này. Bạn khởi tạo một dự án nhỏ. Mọi thứ sống trong một project duy nhất vì sao không? Rồi tính năng phát triển. Bạn thêm thư mục. Thêm thư mục nữa. Thư mục lồng trong thư mục.
Sáu tháng sau:
MyApp/<br>├── Controllers/<br>├── Services/<br>├── Repositories/<br>├── Models/<br>├── ViewModels/<br>├── DTOs/<br>├── Helpers/<br>├── Utilities/<br>├── Extensions/<br>├── Common/<br>├── Shared/<br>├── Core/<br>├── Infrastructure/<br>└── Misc/ ← Nơi những ước mơ chết đi
Mọi thứ phụ thuộc lẫn nhau. “Repository” gọi “Helper”, “Helper” gọi “Service”, rồi “Service” lại gọi “Repository”. Bạn đã tạo ra một vòng tham chiếu tròn, nhưng compiler không phàn nàn vì tất cả nằm trong một project.
Rồi một lập trình viên mới gia nhập. Họ hỏi: “Tôi nên đặt tính năng mới này ở đâu?” Bạn nhìn chằm chằm vào màn hình. “Ừm… chắc là Services? Hay Helpers? Nó làm gì cơ?”
Cấu Trúc Giải Pháp .NET Mở Rộng Thực Sự
Sau nhiều năm thử-sai (chủ yếu là sai), đây là thứ thực sự hiệu quả:
src/<br>├── MyApp.Domain/ # Zero dependencies. Chỉ chứa business logic.<br>├── MyApp.Application/ # Use cases. Chỉ tham chiếu Domain.<br>├── MyApp.Infrastructure/ # External concerns. Database, API, file.<br>├── MyApp.Api/ # Web host. Mỏng. Composition root.<br>└── MyApp.Shared.Kernel/ # Cross-cutting primitives (tùy chọn)<br><br>tests/<br>├── MyApp.Domain.Tests/<br>├── MyApp.Application.Tests/<br>├── MyApp.Infrastructure.Tests/<br>└── MyApp.Api.Tests/
Năm project. Ranh giới rõ ràng. Dependencies chảy theo một hướng: hướng vào trong.
“Nhưng đó chỉ là Clean Architecture thôi mà!”
Đúng vậy. Và có lý do tại sao mọi người cứ phát minh lại nó. Nó hiệu quả.
Nguyên Tắc Dependency Trong Clean Architecture
Đây là quy tắc duy nhất thực sự quan trọng:
Dependencies luôn hướng vào trong. Luôn luôn.
Api → Application → Domain<br>Infrastructure → Application → Domain
Domain không biết gì về database. Application không biết gì về HTTP. Infrastructure triển khai các interface mà Application định nghĩa.
Vi phạm quy tắc này một lần, bạn đã phá vỡ nó mãi mãi. Tôi đã chứng kiến các team thêm “chỉ một tham chiếu nhỏ” từ Domain vào Infrastructure. Ba tháng sau, business logic của bạn import System.Data.SqlClient.
Compiler sẽ không cứu bạn ở đây. Bạn cần kỷ luật. Hoặc một công cụ như NetArchTest để thực thi:
[Fact]<br>public void Domain_Should_Not_Reference_Infrastructure()<br>{<br> var result = Types.InAssembly(typeof(Order).Assembly)<br> .ShouldNot()<br> .HaveReferenceTo("MyApp.Infrastructure")<br> .GetResult();<br><br> result.IsSuccessful.Should().BeTrue();<br>}
Hãy viết test này từ ngày đầu tiên. Sau này nhớ cảm ơn tôi.
Quy Ước Thư Mục Trong Mỗi Project
Cấu Trúc Project Domain
MyApp.Domain/<br>├── Entities/<br>│ ├── Order.cs<br>│ └── Customer.cs<br>├── ValueObjects/<br>│ ├── Money.cs<br>│ └── Address.cs<br>├── Enums/<br>│ └── OrderStatus.cs<br>├── Events/<br>│ └── OrderPlacedEvent.cs<br>├── Exceptions/<br>│ └── InsufficientStockException.cs<br>└── Interfaces/<br> └── IOrderRepository.cs # Chỉ interface. Không implementation.
Để ý không có thư mục Services ở đây. Domain services tồn tại, nhưng rất hiếm. Nếu bạn tạo domain services cho mọi entity, có lẽ bạn đang làm anemic domain modeling.
Cấu Trúc Project Application
MyApp.Application/<br>├── Features/<br>│ ├── Orders/<br>│ │ ├── Commands/<br>│ │ │ ├── PlaceOrder/<br>│ │ │ │ ├── PlaceOrderCommand.cs<br>│ │ │ │ ├── PlaceOrderHandler.cs<br>│ │ │ │ └── PlaceOrderValidator.cs<br>│ │ │ └── CancelOrder/<br>│ │ │ └── ...<br>│ │ └── Queries/<br>│ │ └── GetOrderById/<br>│ │ ├── GetOrderByIdQuery.cs<br>│ │ ├── GetOrderByIdHandler.cs<br>│ │ └── OrderDto.cs<br>│ └── Customers/<br>│ └── ...<br>├── Common/<br>│ ├── Behaviors/<br>│ │ ├── ValidationBehavior.cs<br>│ │ └── LoggingBehavior.cs<br>│ └── Interfaces/<br>│ └── IApplicationDbContext.cs<br>└── DependencyInjection.cs
Đây là sự kết hợp giữa “Vertical Slice Architecture” và “CQRS lite.” Mỗi tính năng độc lập. Muốn hiểu cách đặt hàng hoạt động? Chỉ cần nhìn vào một thư mục.
Thư mục Misc từ phần trước? Nó không tồn tại vì không còn chỗ cho code lung tung ẩn náu.
Cấu Trúc Project Infrastructure
MyApp.Infrastructure/<br>├── Persistence/<br>│ ├── ApplicationDbContext.cs<br>│ ├── Configurations/<br>│ │ └── OrderConfiguration.cs<br>│ └── Repositories/<br>│ └── OrderRepository.cs<br>├── Services/<br>│ ├── EmailService.cs<br>│ └── PaymentGateway.cs<br>├── Identity/<br>│ └── IdentityService.cs<br>└── DependencyInjection.cs
Mọi thứ chạm đến thế giới bên ngoài đều sống ở đây. Database? Ở đây. Email? Ở đây. API bên thứ ba? Ở đây.
Khi bạn cần đổi nhà cung cấp thanh toán, bạn chỉ thay đổi một thư mục. Phần còn lại của ứng dụng không quan tâm.
Quy Tắc Đặt Tên Project .NET
Projects
{CôngTy}.{SảnPhẩm}.{Layer}
Ví dụ: Contoso.Ordering.Domain
Đừng tỏ ra thông minh. Contoso.Ordering.SuperCore.Base.Abstractions.V2 chẳng giúp ích được ai.
Thư Mục
- Số nhiều cho collections:
Entities/,Services/,Handlers/ - Số ít cho features:
Orders/,Customers/(mỗi cái là một vùng tính năng)
Files
- Thêm hậu tố với role:
OrderService.cs,OrderRepository.cs,OrderController.cs - Commands/Queries có tên đầy đủ:
PlaceOrderCommand.cs,GetOrderByIdQuery.cs
Đặt Tên và Vị Trí Interface
// Trong Domain hoặc Application (bên nào định nghĩa contract)<br>public interface IOrderRepository<br>{<br> Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct);<br> Task AddAsync(Order order, CancellationToken ct);<br>}<br><br>// Trong Infrastructure (implementation)<br>public class OrderRepository : IOrderRepository<br>{<br> // EF Core, Dapper, v.v.<br>}
Interface sống với code sử dụng nó, không phải code triển khai nó. Đây là Dependency Inversion cơ bản, nhưng tôi vẫn thấy team đặt IOrderRepository trong project Infrastructure.
Project References và Dependency Graph
Đây là thiết lập .csproj để thực thi quy tắc dependency:
Domain.csproj – Không tham chiếu gì (có thể Shared.Kernel)
<Project Sdk="Microsoft.NET.Sdk"><br> <PropertyGroup><br> <TargetFramework>net8.0</TargetFramework><br> </PropertyGroup><br></Project>
Application.csproj – Chỉ tham chiếu Domain
<ItemGroup><br> <ProjectReference Include="..\MyApp.Domain\MyApp.Domain.csproj" /><br></ItemGroup>
Infrastructure.csproj – Tham chiếu Application (và gián tiếp Domain)
<ItemGroup><br> <ProjectReference Include="..\MyApp.Application\MyApp.Application.csproj" /><br></ItemGroup>
Api.csproj – Tham chiếu mọi thứ (composition root)
<ItemGroup><br> <ProjectReference Include="..\MyApp.Application\MyApp.Application.csproj" /><br> <ProjectReference Include="..\MyApp.Infrastructure\MyApp.Infrastructure.csproj" /><br></ItemGroup>
Project Api kết nối mọi thứ trong Program.cs:
builder.Services<br> .AddApplication() // Từ DependencyInjection.cs của Application<br> .AddInfrastructure(); // Từ DependencyInjection.cs của Infrastructure
Tranh Luận Về Shared Project
Cuối cùng team nào cũng muốn có một project “Shared” hoặc “Common.” Thường dùng cho:
- Extension methods
- Base classes
- Cross-cutting attributes
- Result types
Lời khuyên của tôi: hãy cưỡng lại càng lâu càng tốt. Khi không thể cưỡng lại được nữa:
MyApp.Shared.Kernel/<br>├── Primitives/<br>│ ├── Entity.cs<br>│ ├── ValueObject.cs<br>│ └── DomainEvent.cs<br>├── Results/<br>│ └── Result.cs<br>└── Extensions/<br> └── StringExtensions.cs
Hãy giữ nó nhỏ. Khoảnh khắc project này trở thành bãi rác, bạn đã thua.
Nguyên tắc: nếu bạn không thể giải thích tại sao thứ gì đó ở trong Shared trong một câu, thì nó không thuộc về nơi đó.
Khi Nào Nên Tách Thành Nhiều Solution
| Quy mô Team | Deployment | Khuyến nghị |
|---|---|---|
| 1-5 dev | Monolith | Một solution |
| 5-15 dev | Monolith | Một solution, ranh giới chặt chẽ |
| 15+ dev | Services | Nhiều solution, shared packages |
Nhiều solution mang đến nỗi đau thực sự: phiên bản package, độ phức tạp CI/CD, chi phí kiểm thử tích hợp. Đừng trả chi phí đó cho đến khi bạn thực sự cần.
Khi bạn tách:
solutions/<br>├── ordering/<br>│ └── Contoso.Ordering.sln<br>├── inventory/<br>│ └── Contoso.Inventory.sln<br>└── shared/<br> └── Contoso.Shared.sln # Được xuất bản dưới dạng NuGet packages
Các Shared packages trở thành internal NuGet packages. Mỗi team sở hữu solution của riêng họ. Tích hợp xảy ra thông qua API hoặc messages, không phải project references. Đây cơ bản là cách tiếp cận modular monolith.
Những Sai Lầm Kiến Trúc Thường Gặp Cần Tránh
1. Anti-Pattern “Mọi Thứ Đều Là Service”
// Không. Dừng lại ngay.<br>public class OrderService<br>{<br> public Order GetOrder(int id) { ... }<br> public void PlaceOrder(Order order) { ... }<br> public void CancelOrder(int id) { ... }<br> public void UpdateOrder(Order order) { ... }<br> public decimal CalculateTotal(Order order) { ... }<br> public bool ValidateOrder(Order order) { ... }<br> public void SendOrderEmail(Order order) { ... }<br> // 47 methods nữa<br>}
Đây là một God Class đội lốt “Service.” Hãy chia nhỏ theo use case.
2. Tham Chiếu Vòng Giữa Các Project
Nếu bạn cần một project Shared chỉ để phá vỡ vòng tham chiếu, kiến trúc của bạn đã sai. Hãy lùi lại và vẽ lại ranh giới.
3. Infrastructure Trở Thành Ngăn Kéo Rác
Infrastructure/<br>├── Database stuff<br>├── Email stuff<br>├── PDF generation<br>├── Image processing<br>├── Rate limiting<br>├── Background jobs<br>├── Feature flags<br>├── Analytics<br>└── Một tích hợp kỳ lạ với hệ thống cũ không ai hiểu
Khi Infrastructure quá lớn, hãy tách nó ra:
MyApp.Infrastructure.Persistence/<br>MyApp.Infrastructure.Email/<br>MyApp.Infrastructure.BackgroundJobs/
4. Đặt DTO Sai Layer
DTO rời khỏi ranh giới API? Chúng đi trong Application hoặc một project Contracts riêng.
DTO cho mapping database? Chúng không phải DTO, chúng là entities. Đặt chúng trong Domain.
DTO chia sẻ giữa các services? Xuất bản chúng dưới dạng NuGet package từ project Contracts.
Checklist Kiến Trúc
Trước khi commit, hãy tự hỏi:
- ✅ Một dev mới có thể tìm nơi thêm tính năng trong vòng dưới 2 phút?
- ✅ Mỗi project có một trách nhiệm duy nhất, rõ ràng?
- ✅ Dependencies chảy theo một hướng (hướng vào trong)?
- ✅ Có chính xác một nơi cho mỗi loại code?
- ✅ Bạn có thể giải thích cấu trúc cho junior dev trong 5 phút?
Nếu bất kỳ câu trả lời nào là “không,” bạn còn việc phải làm.
Tái Cấu Trúc Codebase Hiện Tại
Không thể bắt đầu lại từ đầu? Đây là cách tiếp cận từng bước:
- Thêm architecture tests – Bắt đầu thực thi quy tắc trước khi thêm nhiều vi phạm hơn
- Trích xuất Domain – Kéo entities và business rules ra trước
- Định nghĩa interfaces – Tạo ranh giới với interfaces trước khi di chuyển implementations
- Trích xuất Infrastructure – Di chuyển external dependencies đằng sau các interface đó
- Tái tổ chức theo features – Chuyển từ thư mục-theo-loại sang thư mục-theo-tính-năng
Đừng cố tái cấu trúc mọi thứ cùng một lúc. Tôi đã xem bộ phim đó. Kết thúc với một codebase nửa vời và một team mất tinh thần.
Câu Hỏi Thường Gặp
Một giải pháp .NET nên có bao nhiêu project?
Một giải pháp Clean Architecture điển hình có 4-5 project: Domain, Application, Infrastructure, API, và tùy chọn Shared.Kernel. Bắt đầu tối thiểu — bạn luôn có thể tách ra sau, nhưng gộp project lại rất đau đớn. Mỗi project nên có một trách nhiệm duy nhất, rõ ràng.
Có nên để Domain tham chiếu Infrastructure trong .NET?
Không bao giờ. Dependencies luôn phải hướng vào trong. Domain không được có tham chiếu bên ngoài nào ngoài các thư viện .NET cơ bản. Nếu project Domain của bạn tham chiếu Infrastructure, bạn đã vi phạm Dependency Rule và tạo ra sự kết nối chặt chẽ giữa business logic và chi tiết triển khai.
Sự khác biệt giữa Clean Architecture và Onion Architecture là gì?
Chúng rất giống nhau. Cả hai đều thực thi dependencies hướng vào trong với Domain ở trung tâm. Onion Architecture (Jeffrey Palermo, 2008) ra đời trước Clean Architecture (Robert C. Martin, 2012). Clean Architecture thêm nhiều layer rõ ràng hơn và khái niệm “Use Cases” trong Application layer. Trong thực tế, hầu hết các triển khai .NET đều pha trộn cả hai.
Khi nào nên tách thành nhiều giải pháp .NET?
Khi team của bạn vượt quá 15 lập trình viên hoặc bạn đang triển khai dưới dạng các dịch vụ riêng biệt. Nhiều solution làm tăng độ phức tạp (phiên bản package, CI/CD, kiểm thử tích hợp). Một solution duy nhất với ranh giới project chặt chẽ hoạt động tốt cho hầu hết các team.
Interfaces nên sống ở đâu trong Clean Architecture?
Interfaces sống với code sử dụng chúng, không phải code triển khai chúng. IOrderRepository thuộc về Domain hoặc Application (nơi nó được tiêu thụ). Implementation OrderRepository sống trong Infrastructure. Đây là Dependency Inversion Principle trong hành động.
Làm thế nào để thực thi các quy tắc kiến trúc trong .NET?
Sử dụng NetArchTest để viết unit tests xác minh quy tắc dependency của bạn. Chạy các test này trong CI để phát hiện vi phạm sớm. Ví dụ: test rằng Domain không tham chiếu Infrastructure. Viết những test này từ ngày đầu tiên — chúng khó thêm vào sau này hơn nhiều.
Lời Kết
Cấu trúc tốt không phải là tuân theo một template cụ thể. Đó là làm cho con đường dễ dàng trở thành con đường đúng đắn.
Khi việc thêm code mới là hiển nhiên, lập trình viên đưa ra quyết định tốt. Khi nó gây nhầm lẫn, họ đưa ra quyết định nhanh chóng. Và những quyết định nhanh chóng tích tụ thành nợ kiến trúc.
Mục tiêu không phải là có cấu trúc “hoàn hảo” từ ngày đầu tiên. Đó là có một cấu trúc hướng dẫn các quyết định tốt khi team phát triển.
Bắt đầu đơn giản. Thực thi ranh giới. Tái cấu trúc khi nỗi đau đòi hỏi.
Bây giờ nếu bạn cho phép, tôi có một giải pháp legacy với 73 project cần gỡ rối. Chúc tôi may mắn.
Cấu trúc solution của bạn trông như thế nào? Hãy để lại cây thư mục của bạn trong phần bình luận — tôi rất muốn xem điều gì đang hiệu quả (hoặc không hiệu quả) cho team của bạn.
Bài viết được dịch và chuyển thể từ bài gốc của Mashrul Haque trên DEV Community.



