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ột cách hiệu quả.
Nghe này, tôi đã chứng kiến không ít chuyện. Tôi từng mở những solution có tới 47 project, mỗi project chỉ chứa đúng một class (tôi đang phóng đại đấy!). Tôi đã phải đi qua những cây thư mục sâu đến mức Visual Studio cần cả la bàn. Tôi đã lần theo những project reference tạo thành đồ thị dependency giống hệt mớ spaghetti ném lên tường.
Và lần nào cũng vậy, ai đó lại nói: “Chúng ta sẽ refactor sau.”
Spoiler: họ đã không bao giờ refactor.
Mục lục
Những Vấn Đề Thường Gặp Trong Cấu Trúc Project .NET
Đây là điều thường xảy ra. Bạn bắt đầu một project. Nó nhỏ. Mọi thứ nằm gọn trong một project vì sao lại không? Sau đó các tính năng phát triển. Bạn thêm thư mục. Thêm nữa. Thư mục lồng trong thư mục.
Sáu tháng sau:
MyApp/
├── Controllers/
├── Services/
├── Repositories/
├── Models/
├── ViewModels/
├── DTOs/
├── Helpers/
├── Utilities/
├── Extensions/
├── Common/
├── Shared/
├── Core/
├── Infrastructure/
└── Misc/ ← Nơi những ước mơ bị chôn vùi
Mọi thứ phụ thuộc vào mọi thứ. “Repository” của bạn gọi “Helper”, cái “Helper” lại gọi “Service”, rồi “Service” lại gọi “Repository” lần nữa. Bạn đã tạo ra một vòng tham chiếu (circular reference), nhưng compiler không phàn nàn gì vì tất cả nằm trong một project.
Rồi một developer mới gia nhập. Họ hỏi: “Tôi nên đặt tính năng mới này vào đâu?”
Bạn nhìn chằm chằm vào màn hình. “Ừm… chắc là Services? Hay là Helpers? Nó làm gì cơ?”
Cấu Trúc Solution .NET Thực Sự Mở Rộng Được
Sau nhiều năm thử và sai (chủ yếu là sai), đây là thứ hiệu quả:
src/
├── MyApp.Domain/ # Không phụ thuộc gì cả. Chỉ business logic của bạn.
├── MyApp.Application/ # Use cases. Chỉ tham chiếu Domain.
├── MyApp.Infrastructure/ # Các vấn đề bên ngoài. Database, API, file.
├── MyApp.Api/ # Web host của bạn. Mỏng. Composition root.
└── MyApp.Shared.Kernel/ # Các primitive xuyên suốt (tùy chọn)
tests/
├── MyApp.Domain.Tests/
├── MyApp.Application.Tests/
├── MyApp.Infrastructure.Tests/
└── MyApp.Api.Tests/
Năm project. Ranh giới rõ ràng. Dependency chỉ chảy một chiều: hướng vào trong.
“Nhưng đó chỉ là Clean Architecture thôi mà!”
Đúng vậy. Và có lý do vì sao ai cũng tái phát minh ra nó. Vì nó hiệu quả.
Quy Tắc Dependency Trong Clean Architecture
Đây là quy tắc duy nhất quan trọng:
Dependency luôn hướng vào trong. Luôn luôn.
Api → Application → Domain
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, là bạn vi phạm nó mãi mãi. Tôi đã chứng kiến các team thêm “chỉ một reference nhỏ” từ Domain sang 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 để ép buộc điều đó:
[Fact]
public void Domain_Should_Not_Reference_Infrastructure()
{
var result = Types.InAssembly(typeof(Order).Assembly)
.ShouldNot()
.HaveReferenceTo("MyApp.Infrastructure")
.GetResult();
result.IsSuccessful.Should().BeTrue();
}
Viết test này ngay từ ngày đầu tiên. Cảm ơn tôi sau này nhé.
Quy Ước Thư Mục Bên Trong Mỗi Project
Cấu Trúc Project Domain
MyApp.Domain/
├── Entities/
│ ├── Order.cs
│ └── Customer.cs
├── ValueObjects/
│ ├── Money.cs
│ └── Address.cs
├── Enums/
│ └── OrderStatus.cs
├── Events/
│ └── OrderPlacedEvent.cs
├── Exceptions/
│ └── InsufficientStockException.cs
└── Interfaces/
└── IOrderRepository.cs # Chỉ interface. Không có implementation.
Để ý thứ không có ở đây: không có thư mục Services. Domain services có tồn tại, nhưng chúng hiếm. Nếu bạn tạo domain service cho mọi entity, có lẽ bạn đang làm anemic domain modeling.
Cấu Trúc Project Application
MyApp.Application/
├── Features/
│ ├── Orders/
│ │ ├── Commands/
│ │ │ ├── PlaceOrder/
│ │ │ │ ├── PlaceOrderCommand.cs
│ │ │ │ ├── PlaceOrderHandler.cs
│ │ │ │ └── PlaceOrderValidator.cs
│ │ │ └── CancelOrder/
│ │ │ └── ...
│ │ └── Queries/
│ │ └── GetOrderById/
│ │ ├── GetOrderByIdQuery.cs
│ │ ├── GetOrderByIdHandler.cs
│ │ └── OrderDto.cs
│ └── Customers/
│ └── ...
├── Common/
│ ├── Behaviors/
│ │ ├── ValidationBehavior.cs
│ │ └── LoggingBehavior.cs
│ └── Interfaces/
│ └── IApplicationDbContext.cs
└── DependencyInjection.cs
Đây là sự kết hợp giữa “Vertical Slice Architecture” và “CQRS lite.” Mỗi tính năng là một khối tự chứa (self-contained). Muốn hiểu cách đặt hàng hoạt động? Chỉ cần nhìn vào một thư mục.
Còn thư mục Misc lúc trước? Nó không tồn tại vì không có chỗ cho code lung tung ẩn náu.
Cấu Trúc Project Infrastructure
MyApp.Infrastructure/
├── Persistence/
│ ├── ApplicationDbContext.cs
│ ├── Configurations/
│ │ └── OrderConfiguration.cs
│ └── Repositories/
│ └── OrderRepository.cs
├── Services/
│ ├── EmailService.cs
│ └── PaymentGateway.cs
├── Identity/
│ └── IdentityService.cs
└── DependencyInjection.cs
Mọi thứ chạm tới 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ỉ cần thay đổi một thư mục. Phần còn lại của ứng dụng không quan tâm.
Quy Ước Đặt Tên Project .NET
Sau nhiều năm nhìn các team tranh cãi về vấn đề này, đây là thứ tôi chốt lại:
Project
{CôngTy}.{SảnPhẩm}.{Tầng}
Ví dụ: Contoso.Ordering.Domain
Đừng cố 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 tập hợp:
Entities/,Services/,Handlers/ - Số ít cho tính năng:
Orders/,Customers/(mỗi cái là một vùng tính năng)
File
- Thêm hậu tố theo vai trò:
OrderService.cs,OrderRepository.cs,OrderController.cs - Command/Query dùng tên đầy đủ:
PlaceOrderCommand.cs,GetOrderByIdQuery.cs
Việc thêm hậu tố gây tranh cãi. Một số người ghét IOrderRepository. Tôi đã thử bỏ tiền tố I. Kết quả tệ hơn. Autocomplete của IDE bạn trở nên vô dụng.
Đặt Tên Và Vị Trí Interface
// Trong Domain hoặc Application (bên định nghĩa contract)
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct);
Task AddAsync(Order order, CancellationToken ct);
}
// Trong Infrastructure (phần triển khai)
public class OrderRepository : IOrderRepository
{
// EF Core, Dapper, tùy bạn
}
Interface sống cùng với code sử dụng nó, không phải code triển khai nó. Đây là Dependency Inversion 101, nhưng tôi vẫn thấy các team đặt IOrderRepository trong project Infrastructure.
Project References Và Đồ Thị Dependency
Đây là thiết lập .csproj giúp ép buộc quy tắc dependency:
Domain.csproj – Không tham chiếu gì (có thể là Shared.Kernel)
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>
Application.csproj – Chỉ tham chiếu Domain
<ItemGroup>
<ProjectReference Include="..\MyApp.Domain\MyApp.Domain.csproj" />
</ItemGroup>
Infrastructure.csproj – Tham chiếu Application (và gián tiếp Domain)
<ItemGroup>
<ProjectReference Include="..\MyApp.Application\MyApp.Application.csproj" />
</ItemGroup>
Api.csproj – Tham chiếu mọi thứ (nó là composition root)
<ItemGroup>
<ProjectReference Include="..\MyApp.Application\MyApp.Application.csproj" />
<ProjectReference Include="..\MyApp.Infrastructure\MyApp.Infrastructure.csproj" />
</ItemGroup>
Project Api kết nối mọi thứ lại trong Program.cs:
builder.Services
.AddApplication() // Từ DependencyInjection.cs của Application
.AddInfrastructure(); // Từ DependencyInjection.cs của Infrastructure
Cuộc 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 là 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/
├── Primitives/
│ ├── Entity.cs
│ ├── ValueObject.cs
│ └── DomainEvent.cs
├── Results/
│ └── Result.cs
└── Extensions/
└── StringExtensions.cs
Hãy giữ nó nhỏ. Khoảnh khắc project này trở thành bãi rác, bạn đã thua.
Quy tắc chung: nếu bạn không thể giải thích vì sao thứ gì đó nằm trong Shared bằng một câu duy nhất, thì nó không thuộc về đó.
Khi Nào Nên Tách Thành Nhiều Solution
“Chúng ta nên dùng một solution hay nhiều solution?”
Đây là heuristic của tôi:
- 1-5 devs, Monolith: Một solution
- 5-15 devs, Monolith: Một solution, ranh giới nghiêm ngặt
- 15+ devs, Services: Nhiều solution, shared packages
Nhiều solution mang lại nỗi đau thực sự: versioning package, độ phức tạp CI/CD, chi phí integration testing. Đừng trả chi phí đó cho tới khi thật sự cần.
Khi bạn tách:
solutions/
├── ordering/
│ └── Contoso.Ordering.sln
├── inventory/
│ └── Contoso.Inventory.sln
└── shared/
└── Contoso.Shared.sln # Được xuất bản dưới dạng NuGet packages
Các Shared packages trở thành NuGet packages nội bộ. Mỗi team sở hữu solution của họ. Việc tích hợp diễn ra qua API hoặc message, không phải qua project references. Về cơ bản đây 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ứ Là Một Service”
// Không. Dừng lại.
public class OrderService
{
public Order GetOrder(int id) { ... }
public void PlaceOrder(Order order) { ... }
public void CancelOrder(int id) { ... }
public void UpdateOrder(Order order) { ... }
public decimal CalculateTotal(Order order) { ... }
public bool ValidateOrder(Order order) { ... }
public void SendOrderEmail(Order order) { ... }
// Thêm 47 method nữa
}
Đây là một God Class đội lốt “Service.” Hãy chia nhỏ nó theo use case.
2. Circular Project References (Được “Chữa” Bằng Shared Project)
Nếu bạn cần một Shared project chỉ để phá vỡ circular dependencies, kiến trúc của bạn đã sai. Hãy lùi lại và vẽ lại các ranh giới.
3. Infrastructure Như Một Ngăn Kéo Vứt Đồ
Infrastructure/
├── Đồ database
├── Đồ email
├── Sinh PDF
├── Xử lý ảnh
├── Rate limiting
├── Background jobs
├── Feature flags
├── Analytics
└── Cái tích hợp kỳ lạ với hệ thống legacy mà chẳng ai hiểu
Khi Infrastructure quá lớn, hãy tách nó ra:
MyApp.Infrastructure.Persistence/
MyApp.Infrastructure.Email/
MyApp.Infrastructure.BackgroundJobs/
4. Đặt DTO Sai Tầng
DTO rời khỏi ranh giới API của bạn? Chúng thuộc về Application hoặc một project Contracts riêng.
DTO dùng cho mapping database? Chúng không phải DTO, chúng là entities. Đặt chúng trong Domain.
DTO dùng chung giữa các service? Hãy xuất bản chúng dưới dạng NuGet package từ một project Contracts.
Checklist Kiến Trúc
Trước khi commit, hãy tự hỏi:
- Developer mới có thể tìm ra nơi thêm một tính năng trong vòng dưới 2 phút không?
- Mỗi project có một trách nhiệm duy nhất và rõ ràng không?
- Dependency có chảy một chiều (hướng vào trong) không?
- Có đúng một nơi dành cho mỗi loại code không?
- Bạn có thể giải thích cấu trúc cho một junior dev trong 5 phút không?
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 đấy.
Refactor Một Codebase Hiện Có
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 ép buộc các quy tắc trước khi có thêm vi phạm
- Trích xuất Domain – Tách entities và business rules trước tiên
- Định nghĩa interfaces – Tạo ranh giới bằng interfaces trước khi chuyển implementations
- Trích xuất Infrastructure – Đưa các dependency bên ngoài ra sau các interface đó
- Tổ chức lại theo tính năng – Chuyển từ folder-theo-kiểu sang folder-theo-tính năng
Đừng cố refactor mọi thứ cùng lúc. Tôi đã xem bộ phim đó rồi. Nó kết thúc với một codebase mới migrate dở dang và một team mất tinh thần.
Câu Hỏi Thường Gặp
Một solution .NET nên có bao nhiêu project?
Một solution Clean Architecture điển hình có 4-5 project: Domain, Application, Infrastructure, API, và tùy chọn Shared.Kernel. Hãy bắt đầu tối giản — bạn luôn có thể tách sau, nhưng việc 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.
Domain có nên tham chiếu Infrastructure trong .NET không?
Không bao giờ. Dependency phải luôn hướng vào trong. Domain nên có zero external references ngoài các thư viện cơ bản của .NET. Nếu project Domain của bạn tham chiếu Infrastructure, bạn đã vi phạm Dependency Rule và tạo ra sự gắn kết 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 ép dependency 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 bổ sung các tầng rõ ràng hơn và khái niệm “Use Cases” trong tầng Application. Trên thực tế, hầu hết các triển khai .NET pha trộn cả hai.
Khi nào tôi nên tách thành nhiều solution .NET?
Khi team của bạn vượt quá 15 developers hoặc bạn đang triển khai dưới dạng các service riêng biệt. Nhiều solution làm tăng độ phức tạp (package versioning, CI/CD, integration testing). Một solution duy nhất với ranh giới project nghiêm ngặt hoạt động tốt cho hầu hết các team.
Interface nên đặt ở đâu trong Clean Architecture?
Interface nằm cù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 (bất cứ nơi nào nó được tiêu thụ). Implementation OrderRepository nằm trong Infrastructure. Đây chính là Dependency Inversion Principle đang hoạt động.
Làm sao để ép buộc các quy tắc kiến trúc trong .NET?
Sử dụng NetArchTest để viết unit tests xác minh các 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. Hãy viết những test này ngay từ ngày đầu tiên — chúng khó bổ sung hơn rất nhiều sau này.
Lời Kết
Cấu trúc tốt không phải là chuyện tuân theo một template cụ thể. Nó là việc khiến con đường dễ dàng trở thành con đường đúng đắn.
Khi việc thêm code mới rõ ràng, developers đưa ra quyết định tốt. Khi nó khó hiểu, họ đưa ra quyết định cho nhanh. Và những quyết định cho nhanh sẽ tích lũy 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” ngay ngày đầu tiên. Mà là có một cấu trúc dẫn dắt những quyết định tốt khi team phát triển.
Bắt đầu đơn giản. Ép buộc ranh giới. Refactor khi nỗi đau đòi hỏi.
Giờ thì xin phép nhé, tôi có một solution legacy với 73 project đang chờ tôi 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 thả 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ả) với team của bạn.
Bài viết gốc: “How to Structure a .NET Solution That Actually Scales: Clean Architecture Guide” bởi Mashrul Haque trên DEV Community.



