ใช้ ASP.NET Core สร้าง API ด้วย Command & CQRS pattern
หลักการของ CQRS(Query Responsibility Segregation) และ Command pattern นั้นเป็นหลักการที่เราสามารถนำมาใช้ในการออกแบบระบบที่มีความซับซ้อน โดยเฉพาะการออกแบบระบบที่มีการอ่านหรือเขียนข้อมูลบน database แยกออกจากกันอย่างเป็นระบบ
แต่ทำไมเราถึงต้องแยกการเขียนออกจากการอ่านหละ ถ้าเราลองนึกภาพการพัฒนาโปรแกรมเล็กๆ หรือโปรแกรมที่ไม่ซับซ้อนเราอาจจะไม่ต้องใช้หลักการนี้ แต่ถ้าเป็นโปรแกรมที่มีการอ่านหรือเขียนข้อมูลจำนวนมาก หรือมีการอ่านหรือเขียนข้อมูลที่ซับซ้อนมันโดยที่เราไม่ต้องกังวลว่าถ้าหากมีการปรับตัว domain object จะมีผลต่อการอ่านข้อมูล ทำให้เราเขียนโปรแกรมได้ง่ายขึ้น ตัวอย่างเช่นเรามีใช้ ในกรณีที่เรามีการสร้าง domain object ให้รองรับกับ businss use case ต่างๆ ไม่ว่าจะเป็นการบันทึกข้อมูล การอ่านข้อมูล หรือการปรับข้อมูล การออกแบบ domain object รวมถึงการใช้ออกแบบ database ให้รองรับกับงานทุกงานก็เป็นเรื่องที่ยากและมันตามมาด้วยเรื่องของ bug หรือแม้แต่ performance ของระบบ
การที่เราแยกการเขียนออกจากการอ่านข้อมูลนั้น สามารถทำให้เราแก้ไขโปรแกรมได้อย่างอิสระหรือขนาดที่เราสามารถปรับโปรแกรมของเราให้เข้ากับระบบขนาดใหญ่ๆ ที่ต้องเชื่อมต่อกับระบบที่เป็น event source ต่างๆ หรือระบบที่ต้องเขียนข้อมูลลง database หลายๆ ตัวพร้อมกัน โดยที่เราไม่ต้องกังวลว่าการเขียนข้อมูลจะมีผลต่อการอ่านข้อมูลหรือไม่
หลักการของ CQRS และ Command pattern
- Command Pattern: เป็นรูปแบบการออกแบบที่เน้นการ encapsulate การทำงานหรือ action ไว้ใน object หนึ่ง ซึ่งจะเป็นตัวแทนของคำสั่งนั้น ๆ เช่น การสร้าง, ลบ, หรือแก้ไขข้อมูล โดย pattern นี้จะช่วยแยก logic การประมวลผลออกจากตัว controller หรือ service หลัก ทำให้โค้ดมีความ modular มากขึ้น
- CQRS เป็น pattern ที่แยกการเขียนข้อมูล (Command) ออกจากการอ่านข้อมูล (Query) ทำให้เราสามารถ optimize แต่ละส่วนได้อย่างเต็มที่ การใช้ CQRS มักจะทำให้ระบบรองรับ scalability ได้ดีขึ้น โดยเฉพาะในระบบที่มีการอ่าน/เขียนข้อมูลจำนวนมาก
ตัวอย่างการใช้งาน CQRS และ Command pattern กับ Single Database
หรือจะพัฒนาต่อยอดไปเป็น Event source ก็ทำได้เช่นกัน
โครงสร้างของโค้ดที่ใช้ CQRS และ Command pattern
- Commands: ใช้สำหรับ action ที่เปลี่ยนแปลง state ของระบบ เช่น การสร้างหรือแก้ไขข้อมูล
- Queries: ใช้สำหรับการดึงข้อมูลออกมาแสดง โดยไม่มีการเปลี่ยนแปลง state ของระบบ
- Handlers: สำหรับการประมวลผลคำสั่งหรือ query ที่ถูกเรียกใช้จาก controller หรือ service
ก่อนเริ่มแนะนำให้รู้จักับ MediatR
หลังจากที่เรารู้พื้นฐานของ CQRS และ Command pattern แล้วเราจะมาดูตัวอย่างการใช้ CQRS และ Command pattern ในการสร้าง API ด้วย ASP.NET Core เรามาดูว่าต้องเตรียมอะไรกันบ้าง
ก่อนที่จะเริ่มเขียนโปรแกรมผมขอแนะนำ library ตัวหนึ่งที่ชื่อ MediatR ซึ่งเป็น library ที่ช่วยในการ implement CQRS และ Command pattern ให้ง่ายขึ้น โดย MediatR จะช่วยในการ dispatch command หรือ query ไปยัง handler ที่เราสร้างขึ้นมา โดยไม่ต้องเขียนโค้ดที่ซ้ำซ้อน MediatR คือ library ที่ถูกสร้างขึ้นโดย Jimmy Bogard ซึ่งเป็นคนที่สร้าง AutoMapper และเป็นคนที่มีชื่อเสียงในวงการ .NET อย่างมาก โดย MediatR นั้นมีการใช้งานง่ายและมีความยืดหยุ่นในการใช้งานมากๆ ทำให้เราสามารถปรับแต่งได้ตามความต้องการของโปรเจค
ตัวอย่างโค้ด
เราจะมาดูตัวอย่างการใช้ CQRS และ Command pattern ในการสร้าง API ด้วย ASP.NET Core โดยเราจะสร้าง API จะใช้ dotnet core 8.0 เป็นหลัก และการวางโครง structure จะอ้างอิงตาม Clean Architecture แต่ไม่ได้ทำให้มันดูซับซ้อนเกินไป ไม่ได้นำพวก event driven มาใช้แต่เน้นความเข้าใจสำหรับการใช้งาน CQRS และ Command pattern ให้เข้าใจมากขึ้น
Folder structure ของโปรเจค
ปกติการวางโครงสร้างของโปรเจคก็มีอยู่หลายแบบขึ้นอยู่กับความถนัดของแต่ละคน แต่โดยส่วนมากแล้วเรายึดตามหลัก Clean Architect ซึ่งเราจะแบ่งเป็นโครงสร้างย่อยๆ ได้ดังนี้
Domain
เป็นเป็นตัวหลักสำคัญในการพัฒนาระบบที่ประกอบไปด้วย
- Entity
- Value object
- Aggregate root
Application
เป็น layer ที่ใช้สำหรับจัดการพวก use case หรือ business logic ที่เราต้องการในระบบ
- Application Interfaces
- View Models/DTOs
- Mapper
- Application Exception
- Validation
- Commands / Queries
Infrastructure
เป็น layer ที่ใช้สำหรับจัดการพวกกับระบบที่อยู่นอก domain หรือ application ที่เราสร้างขึ้นมา โดยส่วนใหญ๋จะนิยมสร้าง interface ใน application layer และสร้าง implementation ใน infrastructure layer
- Database
- Web Services
- Files
- Message Bus
- Logging
- Notification
- Identity
- Configuration
Presentation
เป็น layer ที่ใช้สำหรับจัดการ presentation หรือหรือเชื่อมต่อกับการแสดงผลของระบบ โดยส่วนใหญ่จะใช้สำหรับการสร้าง API และเป็นที่รวม DependencyInjection ที่เราใช้่ในระบบมาอยู่ใน layer นี้
- API
- Authentication / Authorization
สร้าง Domain Object
ก่อนที่จะสร้าง command หรือ query เรามาเตรียมฐานข้อมูลกับ model ที่เราจะใช้ในการ insert ข้อมูลลง database ก่อน ซึ่งในบทความนี้เราจะใช้ Entity Framework ในการจัดการ database โดยตัวแรกเราจะลองสร้าง model สำหรับสินค้า โดยเราจะสร้าง class Product ที่มี property ต่างๆ ที่เราต้องการในการ
public class Product
{
public int Id { get; private set; }
public string Code { get; private set; } = null!;
public string Name { get; private set; } = null!;
public string? Description { get; private set; }
public decimal Price { get; private set; }
public int Stock { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? UpdatedAt { get; private set; }
public static Product Create(string code, string name, string? description, decimal price, int stock)
{
return new Product
{
Code = code,
Name = name,
Description = description,
Price = price,
Stock = stock,
CreatedAt = DateTime.UtcNow
};
}
}
หลังจากนั้นเราจะมีการสร้าง Mapping สำหรับ mapping ข้อมูลจาก entity ไปยัง database โดยเราจะสร้าง class ProductConfiguration ที่ implement IEntityTypeConfiguration ของ Entity Framework
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.ToTable("product");
builder.HasKey(p => p.Id);
builder.HasIndex(p => p.Code).IsUnique();
builder.Property(p => p.Code).HasColumnName("code").IsRequired().HasMaxLength(10);
builder.Property(p => p.Id).HasColumnName("id").IsRequired().ValueGeneratedOnAdd();
builder.Property(p => p.Name).HasColumnName("name").IsRequired().HasMaxLength(100);
builder.Property(p => p.Description).HasColumnName("description").HasMaxLength(500);
builder.Property(p => p.Price).HasColumnName("price").IsRequired().HasColumnType("decimal(18,2)");
builder.Property(p => p.Stock).HasColumnName("stock").IsRequired();
builder.Property(p => p.CreatedAt).HasColumnName("created_at").IsRequired();
builder.Property(p => p.UpdatedAt).HasColumnName("updated_at");
}
}
หลังจากนั้นเราจะมีการสร้าง DbContext ที่ใช้ในการจัดการ database โดยเราจะสร้าง class ProductDbContext ที่ implement DbContext ของ Entity Framework
public interface IApplicationDbContext
{
DbSet<Product> Products { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
public sealed class ProductDbContext : DbContext, IApplicationDbContext
{
public ProductDbContext(DbContextOptions<ProductDbContext> options) : base(options)
{
// this for development only, in production you should use migrations
Database.EnsureCreated();
}
public DbSet<Product> Products { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ProductDbContext).Assembly);
}
}
สร้าง Command สำหรับ insert ข้อมูล
เริ่มต้นเราจะทำการสร้าง interface ICommand ที่ใช้สำหรับการรับข้อมูลที่จะบันทึกลง database โดย interface ICommand จะเป็น interface ที่ระบุ type ของ request ที่เราต้องการรับจาก api รวมถึง type ของ response ที่เราต้องการส่งกลับไป ซึ่ง interface ICommand จะ implement IRequest ของ MediatR
using MediatR;
public interface ICommand<out TResponse> : IRequest<TResponse>
{
}
หลังจากนั้นเราจะมาสร้าง interface ICommandHandler ที่จะรับ request จาก ICommand และ return response กลับไป โดย interface ICommandHandler จะ implement IRequestHandler ของ MediatR เช่นเดียวกับ ICommand
using MediatR;
public interface ICommandHandler<in TCommand, TResponse> : IRequestHandler<TCommand, TResponse>
where TCommand : ICommand<TResponse>
{
}
สาเหตุที่เราต้องสร้าง interface ICommand และ ICommandHandler นี้เพราะเราจะสร้าง class ที่จะ implement interface นี้เพื่อทำการ insert ข้อมูลลง database โดยที่ไม่ต้องการให้ class ที่เราใช้งานผูกกับ MediatR โดยตรง ทำให้เราสามารถเปลี่ยน library ที่ใช้ในอนาคตได้โดยไม่ต้องแก้โค้ดที่เราเขียนไปแล้ว
หลังจากนั้นเราจะมาสร้าง class สำหรับ insert ข้อมูลลง database โดยเราจะสร้าง class CreateProductCommand ที่ implement ICommand ที่เราสร้างไว้ก่อนหน้านี้ โดย class CreateProductCommand จะมี property ที่เราต้องการ insert ลง database ซึ่งในที่นี้เราจะ insert ข้อมูลสินค้าลง database และมีการระบุ response เป็นค่า int ซึ่งเป็น id ของสินค้าที่เรา insert ลง database
สังเกตุว่ามีการระบุ type เป็น record ซึ่ง record นั้นเป็น feature ใหม่ของ C# 9.0 ที่ช่วยให้เราสร้าง immutable object
immutable object คือ object ที่ไม่สามารถเปลี่ยนแปลงค่าหลังจากสร้าง object ได้ ทำให้เราสามารถใช้ object นี้ในการรับข้อมูลจาก request ได้โดยไม่ต้องกังวลเรื่องการเปลี่ยนแปลงค่าของ object นี้
public record CreateProductCommand : ICommand<int>
{
public string Name { get; set; } = null!;
public string? Description { get; set; }
public decimal Price { get; set; }
public int UnitInStock { get; set; }
}
หลังจากนั้นเราจะมาสร้าง class สำหรับ insert ข้อมูลลง database โดยเราจะสร้าง class CreateProductCommandHandler ที่ implement ICommandHandler ที่เราสร้างไว้ก่อนหน้านี้ ถ้าหากเราใช้ Entity Framework ในการ insert ข้อมูลลง database ก็สามารถทำได้เลยหรือถ้าหากมี message broker ที่ต้องการ publish message ไปก็สามารถทำได้เช่นกัน
public class CreateProductCommandHandler(IApplicationDbContext applicationDbContext) : ICommandHandler<CreateProductCommand, int>
{
public async Task<int> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
var product = Product.Create(request.Code, request.Name, request.Description, request.Price, request.UnitInStock);
applicationDbContext.Products.Add(product);
return await applicationDbContext.SaveChangesAsync(cancellationToken);
}
}
สร้าง Query สำหรับดึงข้อมูล
ในการดึงข้อมูลเราจะมีการสร้าง DTO (Data Transfer Object) ที่ใช้สำหรับการรับข้อมูลที่เราจะดึงจาก database และส่งกลับไปให้ client ผ่านตัว API สาเหตุที่ต้องสร้าง DTO นี้เพราะเราไม่ควรส่ง entity ที่เราใช้ในการ map ข้อมูลจาก database ออกไปข้างนอกเนื่องจาก entity อาจจะมีข้อมูลที่เราไม่ต้องการส่งหรืออาจจะมีข้อมูลที่เป็น sensitive ที่ไม่ควรส่งออกไป
public record ProductDto
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public string? Description { get; set; }
public decimal Price { get; set; }
public int UnitInStock { get; set; }
}
เราจะมาดูตัวอย่างการดึงข้อมูลจาก database โดยเราจะสร้าง Query สำหรับดึงข้อมูลเบื้องต้น โดยเราจะสร้าง interface IQuery ที่ใช้สำหรับการรับข้อมูลที่จะดึงข้อมูลจาก database โดย interface IQuery จะ implement IRequest ของ MediatR เช่นเดียวกับ ICommand ที่เราสร้างไว้ก่อนหน้านี้
using MediatR;
public interface IQuery<out TResponse> : IRequest<TResponse>
{
}
แล้วเราก็จะมีสร้าง interface IQueryHandler ที่ใช้สำหรับการรับ request จาก IQuery และ return response กลับไป โดย interface IQueryHandler จะ implement IRequestHandler ของ MediatR เช่นเดียวกับ ICommandHandler ที่เราสร้างไว้ก่อนหน้านี้เหมือนกัน
using MediatR;
public interface IQueryHandler<in TQuery, TResponse> : IRequestHandler<TQuery, TResponse>
where TQuery : IQuery<TResponse>
{
}
ทีนี้เราก็จะมาสร้าง Query สำหรับดึงข้อมูลสินค้าโดยเราจะสร้าง record GetAllProductsQuery ที่ implement IQuery และมีการระบุ type ของ response ซึ่งเป็น list ของ ProductDto
public record GetAllProductsQuery : IQuery<IReadOnlyList<ProductDto>>
{
}
เราสามารถเลือกวิธีการดึงข้อมูลจาก database ได้หลายวิธีปรับให้เหมาะกับงานที่เราต้องการโดยที่ไม่ต้องกังวลว่ามันจะมีผลต่อ domain ที่เราเตรียมไว้สำหรับ insert หรือ update ข้อมูล ในตัวอย่างด้านล่างเราจะใช้ Entity Framework ในการดึงข้อมูลจาก database โดยเราจะสร้าง class GetAllProductsQueryHandler ที่ implement IQueryHandler ที่เราสร้างไว้ก่อนหน้านี้ แต่ถ้าหากใครต้องการใช้ Dapper ในการดึงข้อมูลจาก database ก็สามารถทำได้เช่นกัน
public sealed class GetAllProductsQueryHandler(
IMapper mapper,
ProductDbContext dbContext) : IQueryHandler<GetAllProductsQuery, IReadOnlyList<ProductDto>>
{
public async Task<IReadOnlyList<ProductDto>> Handle(GetAllProductsQuery request, CancellationToken cancellationToken)
{
return await dbContext.Products
.AsNoTracking()
.ProjectTo<ProductDto>(mapper.ConfigurationProvider)
.ToListAsync(cancellationToken);
}
}
ส่วนตัวอย่างด้านล่างจะเป็นการสร้าง method ที่เอาไว้สำหรับ Mapping ข้อมูลจาก entity ไปยัง DTO โดยใช้ AutoMapper โดยเราจะสร้าง class GetAllProductsMapping ที่ implement Profile ของ AutoMapper และใช้ method CreateMap ในการ map ข้อมูลจาก entity ไปยัง DTO
public class GetAllProductsMapping : Profile
{
public GetAllProductMapping()
{
CreateMap<Product, ProductDto>();
}
}
เชื่อมต่อ command กับ API
เรามาลองสร้าง API endpoints โดยเราจะใช้ Minimal API (Minital API คือ feature ใหม่ของ ASP.NET Core ที่ช่วยให้เราสร้าง API ได้โดยที่ไม่ต้องสนเรื่องการสร้าง controller หรือ service และเราสามารถเขียน API ได้ง่ายขึ้น) ใน Minimal API เราสามารถสร้าง endpoint ได้โดยใช้ method MapGet, MapPost, MapPut, MapDelete หรือ method อื่นๆ ที่เราต้องการ โดยเราสามารถสร้าง endpoint ได้โดยใช้ lambda expression หรือ method ที่เราสร้างขึ้นเอง จากตัวอย่างด้านล่างเราจะสร้าง endpoint สำหรับ insert ข้อมูลสินค้าลง database โดยเราจะใช้ method MapPost ในการสร้าง endpoint นี้ และเราจะสร้าง method ที่รับ request จาก body และส่ง request ไปยัง handler ที่เราสร้างไว้ก่อนหน้านี้ ตัว request body ที่เราส่งไปจะถูก map กับ class CreateProductCommand ที่เราสร้างไว้ก่อนหน้านี้ และเราจะส่ง request ไปยัง handler ที่เราสร้างไว้ก่อนหน้านี้ โดยใช้ ISender ของ MediatR ในการส่ง request ไปยัง handler
public static class ProductEndPoints
{
public static void MapProductEndPoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPost("/api/products", CreateProductAsync);
endpoints.MapGet("/api/products", GetAllProductsAsync);
}
private static async Task<Results<Ok<int>, BadRequest<string>>> CreateProductAsync(
[FromBody] CreateProductCommand command,
ISender sender)
{
var result = await sender.Send(command);
return TypedResults.Ok(result);
}
private static async Task<Results<Ok<ProductDto[]>, BadRequest<string>>> GetAllProductsAsync(ISender sender)
{
var result = await sender.Send(new GetAllProductsQuery());
return TypedResults.Ok(result.ToArray());
}
}
ถึงตรงนี้เราก็สามารถสร้าง API สำหรับ insert พร้อมกับ query ข้อมูลได้แล้ว จุดสุดท้ายคือการสิ่งที่เราเขียนมาก่อนหน้า มาร้อยเข้าไปด้วยกันในโปรเจค โดยเราจะมี class Program ที่ใช้สำหรับสร้าง web application และสร้าง dependency injection ที่เราใช้ในระบบ ตามตัวอย่างด้านล่าง
- register database context ในที่นี้เราจะใช้ Entity Framework กับ PostgreSql
- register MediatR ที่ใช้ในการส่ง command หรือ query ไปยัง handler
- register AutoMapper ที่ใช้ในการ map entity ไปยัง DTO
- register API endpoints ที่เราสร้างไว้ก่อนหน้านี้
using Api.Practice.Endpoints;
using Api.Practice.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using System.Reflection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var applicationAssembly = Assembly.GetExecutingAssembly();
// 1. register database context
builder.Services.AddDbContext<ProductDbContext>(options =>
{
options.UseNpgsql(builder.Configuration.GetConnectionString("DatabaseConnection"), builder =>
{
builder.MigrationsAssembly(applicationAssembly.FullName);
builder.EnableRetryOnFailure();
});
});
// 2. register MediatR that will be used to handle commands and queries
builder.Services.AddMediatR(config => config.RegisterServicesFromAssembly(applicationAssembly));
// 3. register AutoMapper that will be used to map domain entities to DTOs
builder.Services.AddAutoMapper(applicationAssembly);
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// 4. register api endpoints
app.MapProductEndPoints();
app.Run();