Blog của Đào Anh Thành

Kiến trúc 'Ports and Adapters' trong .NET Core

9 phút đọc

Gần đây tôi có tiếp xúc với C#, cảm thấy ngôn ngữ này khá thú vị và mạnh mẽ. Điều đầu tiên tôi muốn làm khi học C# là tìm hiểu về thiết kế DDD và cách triển khai nó trong .NET Core. Trong bài viết này, tôi sẽ chia sẻ về kiến trúc Ports & Adapters - một cách implement DDD trong .NET Core.

Trước khi bắt đầu​

Bạn nên có kiến thức nền về c# và dotnet: cách làm việc với dotnet cli (hoặc có thể dùng Visual Studio), hiểu được các thuật ngữ (subproject, dependencies, reference) và trên hết là sự tò mò.

Một kiến trúc nữa ư? Chẳng phải MVC đã là đủ rồi sao?​

Trước hết hãy cùng nói về MVC. Chắc hẳn đây là kiến trúc đầu tiên mà mọi lập trình viên được tiếp xúc khi làm việc với web. Nó một kiến trúc phần mềm chia ứng dụng thành 3 phần độc lập với mục đích khác nhau: Model, View, Controler.

MVC

Mô tả luồng đi của dữ liệu trong mô hình MVC

Luồng đi của ứng dụng sẽ như sau:

  1. Dựa vào URL, request sẽ được gửi đến Controller tương ứng để xử lý. Controller là nơi nhận và validate meta data của request (như quyền của người gửi, validate các trường trong request,..)
  2. Controller gửi yêu cầu xử lý data đến Model. Model là nơi xử lý mọi logic liên quan đến request bao gồm cả việc kết nối với DB. Controller không bao giờ nên kết nối trực tiếp với DB hay xử lý logic. Trong trường hợp xảy ra lỗi, Controller nên là thành phần xử lý lỗi đó để trả cho người dùng. Hay nói cách khác, tất cả logic liên quan đến tương tác người dùng đều được xử lý bởi Controller.
  3. Sau khi nhận lại dữ liệu từ Model, Controller sẽ kết nối đến View để render data trả cho người dùng. View chỉ có tác dụng render giao diện dựa trên data được trả bởi controller và không có xử lý gì thêm.

Không thể phủ nhận MVC là một kiến trúc hiệu quả. Nhiệm vụ của MVC là phân tách View - Controller - Model, như tên gọi của nó, trong một ứng dụng web, khiến chúng độc lập và dễ bảo trì. Tuy nhiên đây là kiến trúc áp dụng khi source code bao gồm luôn 2 phần: back-end (do Controller và Model đảm nhiệm) và front-end (do View đảm nhiệm).

Hơn nữa với những dự án lớn, việc logic xử lý chỉ được chia thành Controller và Model chắc chắn không thể đủ để đảm bảo dự án có thể dễ mở rộng và bảo trì.

Do đó, trong trường hợp source code tách làm 2 phần BE và FE, ta cần một kiến trúc khác áp dụng cho từng phần. Trong giới hạn của bài viết này, tôi sẽ giới thiệu một kiến trúc thường được dùng cho phía BE: Ports & Adapters

Ports & Adapters là gì?​

Ports & Adapters - P&A - (hay còn được gọi là Hexagonal Architecture - thú thực tôi không thích cái tên này lắm) là một kiến trúc phần mềm tuân theo nguyên lý thiết kế tập trung vào chủ thể. Có thể bạn đã nghe qua cái tên DDD (Domain-Driven Design). Hiểu đơn giản, DDD là tập hợp các nguyên tắc, mẫu thiết kế hệ thống giúp team bạn phát triển phần mềm một cách dễ dàng và hiệu quả. Ports & Adapters là một trong những cách triển khai code base để tuân thủ những ý tưởng của DDD.

DDD is an idea, P&A is an implementation

DDD là ý tưởng/nguyên tắc, còn Ports & Adapters là một cách triển khai

Triển khai kiến trúc Port & Adapter trong .NET Core​

Thế cụ thể, ý tưởng của Ports & Adapters là gì?​

Trước tiên hãy nói một chút về cách tiếp cận vấn đề của MVC. Một góc nào đó, kiến trúc này vẫn mang theo ý tưởng của DDD: bạn có model là những class mô tả tất cả những chủ thể mà ứng dụng làm việc với (vd: config, người dùng, hàng hoá, xe cộ,...). Bạn có service để xử lý mọi nghiệp vụ liên quan đến domain. Ta thường tách Service ra thành - tạm gọi - Service_Interface và Service_Implementation để tối đa trạng thái loosely coupling.

Tuy nhiên, mô hình của MVC có một vấn đề: nó không có cơ chế hạn chế việc gọi lẫn nhau giữa bất kỳ thành phần nào. Hay nói cách khác, các thành phần không được hoàn toàn tách biệt do vậy một developer có thể thoải mái truy cập database từ controller. Điều này rất tệ trong dự án lớn vì nó có thể tạo tiền lệ và phá vỡ kiến trúc tổng thể. Ai mà biết được liệu anh ta có validate và serialize response chuẩn không cơ chứ.

Do vậy một kiến trúc tốt cần có cơ chế tách biệt hẳn các thành phần trong luồng đi của dữ liệu đồng thời bắt buộc chúng phải nằm trong luồng đi đó. Nói một cách đơn giản: kiến trúc tốt phải khiến dev dù có muốn, họ cũng không thể nào:

  1. Truy cập trực tiếp Service_Implementation từ Controller mà không thông qua Service_Interface
  2. Gọi thẳng Database từ Controller.

Lazy dev

Người duy nhất ngăn điều này xảy ra là code reviewer

Đây là lý do mà những kiến trúc như P&A tồn tại. Thứ nhất nó phân tách các thành phần trong source code để đảm bảo chúng loosely coupled. Thứ hai nó đảm bảo luồng đi của dữ liệu theo một cấu trúc đồng nhất, dễ đọc dễ bảo trì và dễ mở rộng.

PowerPlant

Hãy hình dung P&A như một mô hình sản xuất điện. Bên yêu cầu là cái quạt, gửi yêu cầu bằng cách cắm phích vào ổ điện. Nhà máy điện là nơi cung cấp dịch vụ điện cho bạn và họ có thể lấy điện từ nhiều nguồn: thuỷ điện hoặc nhiệt điện chẳng hạn.

Tôi sẽ sử dụng Driving Adapters, Driving Ports để ám chỉ phía yêu cầu. Driven Adapter, Driven Ports để nói về bên thực hiện yêu cầu.

PowerPlant2

Với việc phân chia như trên, giờ luồng đi của dữ liệu đã rõ ràng hơn. Phích cắm không thể lấy điện bằng cách chọc thẳng vào motor phát điện - cũng giống như bạn không thể nào truy cập thẳng vào DB mà không thông qua Service. Đồng thời ta không thể bỏ qua bất kỳ interfaces nào do chúng đều là một mắt xích trong mạng lưới, do đó cấu trúc tổng thể của chương trình được đảm bảo. Hãy cùng xem ta có thể implement kiến trúc thú vị này như thế nào với .NET Core.

Đó là lý thuyết, giờ hãy implement nó trong code​

Chúng ta sẽ triển khai P&A qua một dự án CRM nhỏ. Tính năng cần có:

  • Sử dụng cả Restful API và GraphQL
  • Có cơ chế reset admin account trong trường hợp họ quên mật khẩu
  • Sử dụng Postgres để lưu dữ liệu và Redis để lưu cache

Tôi sẽ không tập trung vào logic xử lý trong bài viết này để tập trung nhấn mạnh vào kiến trúc. Hãy tham khảo source code ở phần cuối bài viết.

Cấu trúc thư mục là cách trực quan nhất để trình bày kiến trúc, do vậy hãy bắt đầu với nó.

Tham khảo cấu trúc thư mục dưới đây:

.
├── Adapter
│ ├── In
│ │ ├── APIs
│ │ │ ├── GraphQL
│ │ │ └── RestfulAPI
│ │ └── CLI
│ └── Out
│ ├── Databases
│ │ ├── Postgres
│ │ └── Redis
│ └── ThirdPartyLibs
└── Application
├── Domain
│ ├── Domain.Core
│ ├── Domain.Services
│ └── Domain.Services.Tests
└── Ports
├── In
└── Out

Hãy bắt đầu với Adapter, ta có 2 thư mục In và Out tương ứng với Driving Adapter và Driven Adapter (bên gửi - bên xử lý yêu cầu). Trong In ta nhận 2 dạng yêu cầu: qua command line, và qua http gồm hai giao thức: GraphQL và RestfulAPI. Tương tự như vậy với Out ta cần giao tiếp với 2 dạng hệ thống bên ngoài: Databases và các loại khác.

Phần còn lại của kiến trúc được gộp trong thư mục Application. Đây là nơi chứa Domain và Ports - thứ chịu trách nhiệm giao tiếp với thế giới bên ngoài nên cũng cần 2 thư mục In/Out tương ứng.

Khi bạn đã hiểu cấu trúc thư mục, sử dụng lệnh dưới đây để khởi tạo dự án:

# Create solution
dotnet new sln -o Ports-and-Adapters-Demo && cd Ports-and-Adapters-Demo

# Create projects
dotnet new classlib -n Adapter.GraphQL -o Adapter/In/APIs/GraphQL
dotnet new classlib -n Adapter.RestfulAPI -o Adapter/In/APIs/RestfulAPI
dotnet new classlib -n Adapter.CLI -o Adapter/In/CLI

dotnet new classlib -n Adapter.Redis -o Adapter/Out/Databases/Redis
dotnet new classlib -n Adapter.Postgres -o Adapter/Out/Databases/Postgres
dotnet new classlib -n Adapter.ThirdPartyLibs -o Adapter/Out/ThirdPartyLibs

dotnet new classlib -n Domain.Core -o Application/Domain/Domain.Core
dotnet new classlib -n Domain.Services -o Application/Domain/Domain.Services
dotnet new xunit -n Domain.Services.Tests -o Application/Domain/Domain.Services.Tests

dotnet new classlib -n Ports.In -o Application/Ports/In
dotnet new classlib -n Ports.Out -o Application/Ports/Out

# Add all projects to solution
dotnet sln add **/*.csproj

Để đảm ràng buộc phụ thuộc giữa các subproject với nhau cần thêm reference cho các project, sử dụng lệnh sau:

# services depends on domain
dotnet add **/Domain.Services.csproj reference **/Domain.Core.csproj **/Ports.Out.csproj **/Ports.In.csproj
dotnet add **/Domain.Services.Tests.csproj reference **/Domain.Core.csproj **/Domain.Services.csproj

# adapters depends on domain and ports.
dotnet add **/Adapter.GraphQL.csproj reference **/Domain.Core.csproj **/Ports.In.csproj
dotnet add **/Adapter.RestfulAPI.csproj reference **/Domain.Core.csproj **/Ports.In.csproj
dotnet add **/Adapter.CLI.csproj reference **/Domain.Core.csproj **/Ports.In.csproj

dotnet add **/Adapter.Redis.csproj reference **/Domain.Core.csproj **/Ports.Out.csproj
dotnet add **/Adapter.Postgres.csproj reference **/Domain.Core.csproj **/Ports.Out.csproj
dotnet add **/Adapter.ThirdPartyLibs.csproj reference **/Domain.Core.csproj **/Ports.Out.csproj

#ports depend on domain
dotnet add **/Ports.In.csproj reference **/Domain.Core.csproj
dotnet add **/Ports.Out.csproj reference **/Domain.Core.csproj

Có một lưu ý nhỏ khi bạn đặt thư mục với tên Out (viết hoa chữ đầu) như ví dụ trên: nó khiến git nhầm lẫn tên với thư mục out. Khi bạn ignore thư mục out (đây là nơi chứa những file được runtime c# tạo ra trên máy bạn, và bạn nên ignore nó), git có thể lẫn với thư mục Out (viết hoa chữ đầu) dẫn đến code không được commit. Khắc phục như sau: bật case-sensitive trong config của git với lệnh sau git config core.ignorecase false và định nghĩa thư mục cần ignore với case sentitive. Ví dụ: **/[o]ut sẽ ignore tất cả các thư mục out (viết thường) và giữ lại Out.

Kinh nghiệm của tôi cho thấy rằng nếu sử dụng DDD nói chung và kiến trúc P&A nói riêng, ta nên có thêm một thành phần nữa để kiểm soát tất cả các thành phần còn lại. Đây sẽ là project chứa file main (hoặc Project.cs trong ngữ cảnh của .NET) - điểm khởi tạo của ứng dụng và là nơi chứa config cho toàn bộ dự án.

Tôi sẽ gọi project đó là Src, bạn tham khảo lệnh dưới để tạo Src:

dotnet new web -n Src
dotnet sln add Src/Src.csproj
dotnet add Src/Src.csproj reference **/*.csproj
dotnet remove Src/Src.csproj reference Src/Src.csproj # prevent circle loop

Sau cùng ta sẽ có cây phụ thuộc như hình dưới:

dev-tree

Cách các thành phần liên kết với nhau​

Để đơn giản, tôi sẽ demo một logic mà hệ thống nào cũng cần có: Đăng nhập. Cách triển khai chi tiết xin hãy xem repo đặt ở cuối bài.

Luồng đi sẽ là:

Người dùng => Gửi request => Controler (Adapter) => Port In => Domain Service => Port Out => Database (Adapter)

Đầu tiên cần có controller:

using Ports.In;
namespace Adapter.RestfulAPI;

[Route("api/v{version:apiVersion}")]
public class LoginController(
IAuthenticator authenticator,
) : RestfulControllerBase
{
[HttpPost("login")]
public Task<IActionResult> Login([FromBody] LoginForm form)
{
// Handle login request
}
}

Kế tiếp cần tạo 1 driving port tương ứng cho controller này. Theo quy ước đặt tên của .Net, ta sẽ gọi đó là IAuthenticator:

namespace Ports.In;

public interface IAuthenticator
{
public Task<SessionUser> Login(string phoneOrEmail, string password);
}

Ta cần service cho interface đó, tôi tạm gọi Authenticator. Class này sẽ nằm trong Domain.Services:

using Ports.In;
namespace Domain.Services;

public class Authenticator(
IUserRepository repo,
IPassword pwdService,
ICache cache
) : IAuthenticator
{
public async Task<SessionUser> Login(string phoneOrEmail, string password)
{
// Thực hiện logic login
}
}

Ta đang thiếu các domain class đại diện cho đối tượng tham gia quá trình login. Hãy thêm nó trong Domain.Core

namespace Domain.Core;

// SessionUser.cs
public class SessionUser
{
// Định nghĩa Entity cần trả cho client khi đăng nhập thành công
}

// User.cs
[Table("Users")]
public class User : EntityAudit
{
// Định nghĩa entity user - thứ sẽ được lưu trong Db
}

Ta cần một port out tham gia vào quá trình này. Service sẽ cần một bên chịu trách nhiệm lấy thông tin từ DB. Hãy tạo nó trong project Ports.Out:

namespace Ports.Out;

public interface IUserRepository
{
Task<User?> FindByPhoneOrEmail(string phoneOrEmail, bool includePassword = false);
}

Component cuối cùng tham gia quá trình này là driven adapter. Trong luồng đi của dữ liệu, đây sẽ thành phần chịu trách nhiệm gửi yêu cầu ra hệ thống bên ngoài, trong trường hợp này là triển khai giao tiếp với DB.

using Ports.Out;
namespace Adapter.Database.Postgres;

public class UserRepository(
PostgresRootDbContext context
) : Repository<DomainModel.User>(context), IUserRepository
{
public Task<DomainModel.User?> FindByPhoneOrEmail(string phoneOrEmail, bool includePassword)
{
// Kết nối với Db để query dữ liệu
}
}

Giản lược các phần không liên quan, đây là cây thư mục sau khi implement:

.
├── Adapter
│   ├── In
│   │   ├── APIs
│   │      ├── GraphQL
│   │      └── RestfulAPI
│   │      └── LoginController.cs
│   └── Out
│   ├── Databases
│      ├── Postgres
│         └── UserRepository.cs
│
├── Application
   ├── Domain
   │   ├── Domain.Core
   │   │   ├── SessionUser.cs
   │   │   └── User.cs
   │   ├── Domain.Services
   │      └── Authenticator.cs
   │  
   └── Ports
   ├── In
   │   └── IAuthenticator.cs
   └── Out
   └── IUserRepository.cs

Bạn đạt được 4 thứ khi áp dụng kiến trúc trên vào dự án:

  1. Dễ đọc, dễ hình dung luồng đi của dữ liệu
  2. Có sự ràng buộc nhất định giữa các thành phần với nhau hình thành một luồng đi thống nhất. Bạn không thể bỏ qua bất kì bước nào để đi tắt và do đó, kiến trúc code sẽ đồng nhất. Lại thêm một lý do nữa cho việc dễ đọc.
  3. Dễ kiểm thử, bảo trì. Các thành phần được chia nhỏ theo chức vụ do vậy hạn chế tối đa side effects. Việc unit test giờ đây rất dễ dàng vì sự liên kết lỏng lẻo (Loose coupling) giữa các phần với nhau.
  4. DDD được thiết kế cho việc dễ mở rộng, điều này cũng đúng với kiến trúc Ports & Adapters. Như một lẽ tất nhiên, khi bạn dễ đọc code, dễ bảo trì thì code sẽ rất dễ dàng để mở rộng.

Đây là dự án mẫu tôi đã tạo ra để giúp bạn hiểu hơn về cách implement P&A trong .NET. Chúc bạn code vui 🥰🥰

Loading Comments...