# Clean Architecture บน .NET: คู่มือเชิงปฏิบัติ > เชี่ยวชาญ Clean Architecture บน .NET ด้วย C# เรียนรู้หลักการ SOLID การแยกชั้น และรูปแบบการนำไปใช้สำหรับแอปพลิเคชันที่บำรุงรักษาง่าย - Published: 2026-02-13 - Updated: 2026-04-28 - Author: SharpSkill - Tags: dotnet, clean architecture, csharp, aspnet core, design patterns - Reading time: 16 min --- Clean Architecture ที่ Robert C. Martin (Uncle Bob) ทำให้เป็นที่นิยม จัดระเบียบโค้ดโดยให้ตรรกะทางธุรกิจอยู่ที่ศูนย์กลางของแอปพลิเคชัน เป็นอิสระจากเฟรมเวิร์กและรายละเอียดการนำไปใช้งาน แนวทางสถาปัตยกรรมนี้รับประกันความสามารถในการทดสอบ การบำรุงรักษา และการขยายตัวสำหรับแอปพลิเคชัน .NET คู่มือนี้นำเสนอการนำไปใช้งานเชิงปฏิบัติด้วย ASP.NET Core > **ทำไมต้อง Clean Architecture?** > > แอปพลิเคชันที่ผสมตรรกะทางธุรกิจกับโค้ดของชั้นโครงสร้างพื้นฐานจะกลายเป็นเรื่องบำรุงรักษายากอย่างรวดเร็ว Clean Architecture บังคับให้มีการแยกอย่างเข้มงวด ซึ่งเปิดโอกาสให้ปรับเปลี่ยนรายละเอียดทางเทคนิคได้โดยไม่กระทบต่อแกนทางธุรกิจ ## หลักการพื้นฐานของ Clean Architecture Clean Architecture ตั้งอยู่บนหลักการกลับด้านการพึ่งพา (dependency inversion): ชั้นภายในจะไม่รู้จักชั้นภายนอก โดเมนทางธุรกิจถูกแยกออกและสามารถพัฒนาได้โดยอิสระจากตัวเลือกทางเทคนิค เช่น เฟรมเวิร์กเว็บหรือฐานข้อมูล ``` ┌─────────────────────────────────────────────────────────┐ │ Presentation │ │ (Controllers, Razor Pages, Blazor, API Endpoints) │ ├─────────────────────────────────────────────────────────┤ │ Infrastructure │ │ (EF Core, External APIs, File System, Email) │ ├─────────────────────────────────────────────────────────┤ │ Application │ │ (Use Cases, Commands, Queries, DTOs) │ ├─────────────────────────────────────────────────────────┤ │ Domain │ │ (Entities, Value Objects, Domain Services) │ └─────────────────────────────────────────────────────────┘ ``` การพึ่งพาจะชี้เข้าด้านในเสมอ: Presentation → Infrastructure → Application → Domain โดย Domain จะไม่อ้างอิงโปรเจกต์อื่นเลย ## โครงสร้างโปรเจกต์ .NET ใน Clean Architecture การจัดระเบียบโปรเจกต์สะท้อนชั้นต่าง ๆ แต่ละชั้นจะตรงกับโปรเจกต์ที่แยกต่างหากในโซลูชัน Visual Studio เพื่อให้แยกความรับผิดชอบในเชิงกายภาพ ```bash # terminal # สร้างโครงสร้างของโซลูชัน dotnet new sln -n CleanArchitecture # สร้างโปรเจกต์สำหรับแต่ละชั้น dotnet new classlib -n CleanArchitecture.Domain -o src/CleanArchitecture.Domain dotnet new classlib -n CleanArchitecture.Application -o src/CleanArchitecture.Application dotnet new classlib -n CleanArchitecture.Infrastructure -o src/CleanArchitecture.Infrastructure dotnet new webapi -n CleanArchitecture.Api -o src/CleanArchitecture.Api # เพิ่มโปรเจกต์เข้าโซลูชัน dotnet sln add src/CleanArchitecture.Domain dotnet sln add src/CleanArchitecture.Application dotnet sln add src/CleanArchitecture.Infrastructure dotnet sln add src/CleanArchitecture.Api # กำหนดค่าการอ้างอิงระหว่างโปรเจกต์ cd src/CleanArchitecture.Application dotnet add reference ../CleanArchitecture.Domain cd ../CleanArchitecture.Infrastructure dotnet add reference ../CleanArchitecture.Application cd ../CleanArchitecture.Api dotnet add reference ../CleanArchitecture.Infrastructure ``` โครงสร้างนี้รับประกันว่า Domain ยังคงเป็นอิสระและการพึ่งพาดำเนินตาม Dependency Rule ของ Clean Architecture ## ชั้น Domain: แกนทางธุรกิจ ชั้น Domain ประกอบด้วย entity ทางธุรกิจ Value Object และ interface ของ repository ไม่อนุญาตให้มีการพึ่งพาจากภายนอกในชั้นนี้ ```csharp // Domain/Entities/Order.cs namespace CleanArchitecture.Domain.Entities; // Entity ที่มีตัวตนและวงจรชีวิตทางธุรกิจ public class Order { // ตัวระบุเฉพาะของคำสั่งซื้อ public Guid Id { get; private set; } // การอ้างอิงลูกค้า (Value Object สำหรับอีเมล) public string CustomerEmail { get; private set; } // คอลเลกชันของรายการ (ความสัมพันธ์แบบหนึ่งต่อหลาย) private readonly List _items = new(); public IReadOnlyCollection Items => _items.AsReadOnly(); // สถานะคำสั่งซื้อ (enum ทางธุรกิจ) public OrderStatus Status { get; private set; } // ยอดรวมที่คำนวณได้ public decimal TotalAmount => _items.Sum(i => i.TotalPrice); // วันที่ใช้ติดตาม public DateTime CreatedAt { get; private set; } public DateTime? ShippedAt { get; private set; } // คอนสตรัคเตอร์ส่วนตัวบังคับให้ใช้ factory method private Order() { } // Factory method สำหรับสร้างคำสั่งซื้อที่ถูกต้อง public static Order Create(string customerEmail) { // ตรวจสอบกฎทางธุรกิจขณะสร้าง if (string.IsNullOrWhiteSpace(customerEmail)) throw new DomainException("จำเป็นต้องระบุอีเมลของลูกค้า"); if (!IsValidEmail(customerEmail)) throw new DomainException("รูปแบบอีเมลไม่ถูกต้อง"); return new Order { Id = Guid.NewGuid(), CustomerEmail = customerEmail, Status = OrderStatus.Pending, CreatedAt = DateTime.UtcNow }; } // เมธอดทางธุรกิจ: เพิ่มรายการ public void AddItem(Product product, int quantity) { // กฎทางธุรกิจ: ห้ามแก้ไขคำสั่งซื้อที่จัดส่งแล้ว if (Status == OrderStatus.Shipped) throw new DomainException("ไม่สามารถแก้ไขคำสั่งซื้อที่จัดส่งแล้ว"); if (quantity <= 0) throw new DomainException("จำนวนต้องเป็นค่าบวก"); // ตรวจสอบว่าผลิตภัณฑ์มีอยู่แล้วหรือไม่ var existingItem = _items.FirstOrDefault(i => i.ProductId == product.Id); if (existingItem != null) { existingItem.IncreaseQuantity(quantity); } else { _items.Add(OrderItem.Create(this, product, quantity)); } } // เมธอดทางธุรกิจ: ยืนยันคำสั่งซื้อ public void Confirm() { if (Status != OrderStatus.Pending) throw new DomainException("ยืนยันได้เฉพาะคำสั่งซื้อที่อยู่ระหว่างรอ"); if (!_items.Any()) throw new DomainException("คำสั่งซื้อต้องมีอย่างน้อยหนึ่งรายการ"); Status = OrderStatus.Confirmed; } // เมธอดทางธุรกิจ: จัดส่งคำสั่งซื้อ public void Ship() { if (Status != OrderStatus.Confirmed) throw new DomainException("คำสั่งซื้อต้องได้รับการยืนยันก่อนจัดส่ง"); Status = OrderStatus.Shipped; ShippedAt = DateTime.UtcNow; } private static bool IsValidEmail(string email) => email.Contains('@') && email.Contains('.'); } ``` Entity Order ห่อหุ้มกฎทางธุรกิจและคุ้มครองสถานะภายในของตน การเปลี่ยนแปลงต้องผ่านเมธอดทางธุรกิจที่ตรวจสอบ invariants ```csharp // Domain/Entities/OrderItem.cs namespace CleanArchitecture.Domain.Entities; // Entity ลูกที่มีตัวตนของตนเอง public class OrderItem { public Guid Id { get; private set; } public Guid OrderId { get; private set; } public Guid ProductId { get; private set; } public string ProductName { get; private set; } public decimal UnitPrice { get; private set; } public int Quantity { get; private set; } // คำนวณราคารวมของรายการ public decimal TotalPrice => UnitPrice * Quantity; private OrderItem() { } // Factory method พร้อมการตรวจสอบ public static OrderItem Create(Order order, Product product, int quantity) { return new OrderItem { Id = Guid.NewGuid(), OrderId = order.Id, ProductId = product.Id, ProductName = product.Name, UnitPrice = product.Price, Quantity = quantity }; } // เมธอดเพิ่มจำนวน public void IncreaseQuantity(int additionalQuantity) { if (additionalQuantity <= 0) throw new DomainException("จำนวนที่เพิ่มต้องเป็นค่าบวก"); Quantity += additionalQuantity; } } ``` ```csharp // Domain/Enums/OrderStatus.cs namespace CleanArchitecture.Domain.Enums; // การแจกแจงสถานะของคำสั่งซื้อ public enum OrderStatus { Pending = 0, // รอการยืนยัน Confirmed = 1, // ยืนยันแล้ว พร้อมจัดส่ง Shipped = 2, // จัดส่งแล้ว Delivered = 3, // ส่งมอบแล้ว Cancelled = 4 // ยกเลิก } ``` > **Value Objects** > > Value Object เช่น Money, Address หรือ Email ห่อหุ้มแนวคิดทางธุรกิจที่ไม่มีตัวตนของตน ความเท่ากันของพวกมันอยู่ที่ค่า ไม่ใช่การอ้างอิง แนวทางนี้ช่วยเสริมความสามารถในการสื่อความหมายของโดเมน ## Interface ของ Repository ใน Domain Interface ของ repository ถูกประกาศใน Domain ส่วนการนำไปใช้งานจริงอยู่ใน Infrastructure รูปแบบนี้สอดคล้องกับหลักการกลับด้านการพึ่งพา ```csharp // Domain/Interfaces/IOrderRepository.cs namespace CleanArchitecture.Domain.Interfaces; // Interface ของ repository Order public interface IOrderRepository { // ดึงข้อมูลตามตัวระบุ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); // ดึงข้อมูลพร้อมรายการ Task GetByIdWithItemsAsync(Guid id, CancellationToken cancellationToken = default); // ดึงข้อมูลตามอีเมลลูกค้า Task> GetByCustomerEmailAsync( string email, CancellationToken cancellationToken = default); // เพิ่มคำสั่งซื้อใหม่ Task AddAsync(Order order, CancellationToken cancellationToken = default); // อัปเดตคำสั่งซื้อที่มีอยู่ Task UpdateAsync(Order order, CancellationToken cancellationToken = default); // ลบคำสั่งซื้อ Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); } ``` ```csharp // Domain/Interfaces/IUnitOfWork.cs namespace CleanArchitecture.Domain.Interfaces; // รูปแบบ Unit of Work สำหรับการจัดการธุรกรรม public interface IUnitOfWork : IDisposable { // Repository ที่เข้าถึงได้ผ่าน UoW IOrderRepository Orders { get; } IProductRepository Products { get; } // บันทึกการเปลี่ยนแปลงทั้งหมดแบบอะตอมิก Task SaveChangesAsync(CancellationToken cancellationToken = default); // การจัดการธุรกรรมแบบชัดเจน Task BeginTransactionAsync(CancellationToken cancellationToken = default); Task CommitTransactionAsync(CancellationToken cancellationToken = default); Task RollbackTransactionAsync(CancellationToken cancellationToken = default); } ``` รูปแบบ Unit of Work ประสานการดำเนินงานบนหลาย repository ภายในธุรกรรมเดียว ## ชั้น Application: การกำกับ Use Case ชั้น Application ประกอบด้วยตรรกะของแอปพลิเคชัน (use case) DTO และ interface ของบริการภายนอก ทำหน้าที่กำกับการมีปฏิสัมพันธ์ระหว่างโดเมนและโลกภายนอก ```csharp // Application/Orders/Commands/CreateOrderCommand.cs using MediatR; namespace CleanArchitecture.Application.Orders.Commands; // Command ที่แสดงเจตนาในการสร้างคำสั่งซื้อ public record CreateOrderCommand( string CustomerEmail, List Items ) : IRequest; // DTO สำหรับรายการของคำสั่งซื้อ public record OrderItemDto( Guid ProductId, int Quantity ); ``` ```csharp // Application/Orders/Commands/CreateOrderCommandHandler.cs using MediatR; using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Interfaces; namespace CleanArchitecture.Application.Orders.Commands; // Handler ที่นำ use case มาใช้ public class CreateOrderCommandHandler : IRequestHandler { private readonly IUnitOfWork _unitOfWork; public CreateOrderCommandHandler(IUnitOfWork unitOfWork) { _unitOfWork = unitOfWork; } public async Task Handle( CreateOrderCommand request, CancellationToken cancellationToken) { // สร้าง entity Order ผ่าน factory method var order = Order.Create(request.CustomerEmail); // เพิ่มรายการลงในคำสั่งซื้อ foreach (var item in request.Items) { // ดึงผลิตภัณฑ์จาก repository var product = await _unitOfWork.Products .GetByIdAsync(item.ProductId, cancellationToken); if (product == null) throw new NotFoundException($"ไม่พบผลิตภัณฑ์ {item.ProductId}"); // ใช้เมธอดทางธุรกิจของ entity order.AddItem(product, item.Quantity); } // บันทึกผ่าน repository await _unitOfWork.Orders.AddAsync(order, cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken); return order.Id; } } ``` MediatR นำรูปแบบ Mediator มาใช้เพื่อแยก handler ออกจาก controller โดย command แต่ละตัวจะมี handler ที่รับผิดชอบประมวลผลเพียงตัวเดียว ```csharp // Application/Orders/Queries/GetOrderByIdQuery.cs using MediatR; namespace CleanArchitecture.Application.Orders.Queries; // Query สำหรับดึงคำสั่งซื้อตาม ID public record GetOrderByIdQuery(Guid OrderId) : IRequest; // DTO สำหรับการตอบกลับของคำสั่งซื้อ public record OrderDto( Guid Id, string CustomerEmail, string Status, decimal TotalAmount, DateTime CreatedAt, DateTime? ShippedAt, List Items ); // DTO สำหรับรายการในคำตอบ public record OrderItemResponseDto( Guid Id, string ProductName, decimal UnitPrice, int Quantity, decimal TotalPrice ); ``` ```csharp // Application/Orders/Queries/GetOrderByIdQueryHandler.cs using MediatR; using CleanArchitecture.Domain.Interfaces; namespace CleanArchitecture.Application.Orders.Queries; public class GetOrderByIdQueryHandler : IRequestHandler { private readonly IOrderRepository _orderRepository; public GetOrderByIdQueryHandler(IOrderRepository orderRepository) { _orderRepository = orderRepository; } public async Task Handle( GetOrderByIdQuery request, CancellationToken cancellationToken) { // ดึงพร้อมรายการ var order = await _orderRepository .GetByIdWithItemsAsync(request.OrderId, cancellationToken); if (order == null) return null; // แมปไปยัง DTO (ฉายข้อมูลด้วยตนเอง) return new OrderDto( Id: order.Id, CustomerEmail: order.CustomerEmail, Status: order.Status.ToString(), TotalAmount: order.TotalAmount, CreatedAt: order.CreatedAt, ShippedAt: order.ShippedAt, Items: order.Items.Select(i => new OrderItemResponseDto( Id: i.Id, ProductName: i.ProductName, UnitPrice: i.UnitPrice, Quantity: i.Quantity, TotalPrice: i.TotalPrice )).ToList() ); } } ``` การแยก Commands/Queries เป็นไปตามรูปแบบ CQRS (Command Query Responsibility Segregation) ซึ่งช่วยปรับปรุงการอ่านและการเขียนแยกจากกันอย่างเป็นอิสระ ## การตรวจสอบด้วย FluentValidation การตรวจสอบ command เกิดขึ้นในชั้น Application ก่อนที่ handler จะทำงาน ```csharp // Application/Orders/Validators/CreateOrderCommandValidator.cs using FluentValidation; using CleanArchitecture.Application.Orders.Commands; namespace CleanArchitecture.Application.Orders.Validators; public class CreateOrderCommandValidator : AbstractValidator { public CreateOrderCommandValidator() { // อีเมลจำเป็นและต้องมีรูปแบบที่ถูกต้อง RuleFor(x => x.CustomerEmail) .NotEmpty().WithMessage("จำเป็นต้องระบุอีเมลของลูกค้า") .EmailAddress().WithMessage("รูปแบบอีเมลไม่ถูกต้อง"); // ต้องมีอย่างน้อยหนึ่งรายการ RuleFor(x => x.Items) .NotEmpty().WithMessage("คำสั่งซื้อต้องมีอย่างน้อยหนึ่งรายการ"); // ตรวจสอบทุกรายการ RuleForEach(x => x.Items).ChildRules(item => { item.RuleFor(i => i.ProductId) .NotEmpty().WithMessage("จำเป็นต้องระบุตัวระบุผลิตภัณฑ์"); item.RuleFor(i => i.Quantity) .GreaterThan(0).WithMessage("จำนวนต้องเป็นค่าบวก") .LessThanOrEqualTo(100).WithMessage("จำนวนสูงสุด: 100"); }); } } ``` ```csharp // Application/Common/Behaviors/ValidationBehavior.cs using FluentValidation; using MediatR; namespace CleanArchitecture.Application.Common.Behaviors; // Pipeline behavior สำหรับการตรวจสอบอัตโนมัติ public class ValidationBehavior : IPipelineBehavior where TRequest : notnull { private readonly IEnumerable> _validators; public ValidationBehavior(IEnumerable> validators) { _validators = validators; } public async Task Handle( TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { // ถ้าไม่มี validator ให้ทำงานต่อ if (!_validators.Any()) return await next(); // เรียกใช้ validator ทั้งหมด var context = new ValidationContext(request); var validationResults = await Task.WhenAll( _validators.Select(v => v.ValidateAsync(context, cancellationToken))); // รวบรวมข้อผิดพลาด var failures = validationResults .SelectMany(r => r.Errors) .Where(f => f != null) .ToList(); // โยนข้อยกเว้นเมื่อมีข้อผิดพลาด if (failures.Any()) throw new ValidationException(failures); return await next(); } } ``` ValidationBehavior ทำงานอัตโนมัติก่อน handler ทุกครั้ง รับประกันว่ามีเพียง command ที่ถูกต้องเท่านั้นที่จะถึงตรรกะทางธุรกิจ ## ชั้น Infrastructure: การนำไปใช้งานทางเทคนิค ชั้น Infrastructure ให้การนำไปใช้งานเชิงรูปธรรมของ interface ที่ประกาศไว้ใน Domain และ Application ```csharp // Infrastructure/Persistence/AppDbContext.cs using Microsoft.EntityFrameworkCore; using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.Infrastructure.Persistence; public class AppDbContext : DbContext { public AppDbContext(DbContextOptions options) : base(options) { } public DbSet Orders => Set(); public DbSet OrderItems => Set(); public DbSet Products => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { // นำการกำหนดค่าจาก assembly มาใช้ modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); } } ``` ```csharp // Infrastructure/Persistence/Configurations/OrderConfiguration.cs using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.Infrastructure.Persistence.Configurations; public class OrderConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { // ตารางและคีย์หลัก builder.ToTable("Orders"); builder.HasKey(o => o.Id); // คุณสมบัติ builder.Property(o => o.CustomerEmail) .IsRequired() .HasMaxLength(256); builder.Property(o => o.Status) .IsRequired() .HasConversion(); // เก็บเป็นสตริงที่อ่านเข้าใจง่าย // ความสัมพันธ์กับ OrderItems builder.HasMany(o => o.Items) .WithOne() .HasForeignKey(i => i.OrderId) .OnDelete(DeleteBehavior.Cascade); // ดัชนีสำหรับค้นหาตามอีเมล builder.HasIndex(o => o.CustomerEmail); // เข้าถึงฟิลด์ส่วนตัว _items builder.Navigation(o => o.Items) .UsePropertyAccessMode(PropertyAccessMode.Field); } } ``` ```csharp // Infrastructure/Repositories/OrderRepository.cs using Microsoft.EntityFrameworkCore; using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Interfaces; using CleanArchitecture.Infrastructure.Persistence; namespace CleanArchitecture.Infrastructure.Repositories; public class OrderRepository : IOrderRepository { private readonly AppDbContext _context; public OrderRepository(AppDbContext context) { _context = context; } public async Task GetByIdAsync( Guid id, CancellationToken cancellationToken = default) { return await _context.Orders .FirstOrDefaultAsync(o => o.Id == id, cancellationToken); } public async Task GetByIdWithItemsAsync( Guid id, CancellationToken cancellationToken = default) { // โหลดรายการแบบ eager return await _context.Orders .Include(o => o.Items) .FirstOrDefaultAsync(o => o.Id == id, cancellationToken); } public async Task> GetByCustomerEmailAsync( string email, CancellationToken cancellationToken = default) { return await _context.Orders .Include(o => o.Items) .Where(o => o.CustomerEmail == email) .OrderByDescending(o => o.CreatedAt) .ToListAsync(cancellationToken); } public async Task AddAsync( Order order, CancellationToken cancellationToken = default) { await _context.Orders.AddAsync(order, cancellationToken); } public Task UpdateAsync( Order order, CancellationToken cancellationToken = default) { // EF Core ติดตามการเปลี่ยนแปลงโดยอัตโนมัติ _context.Orders.Update(order); return Task.CompletedTask; } public async Task DeleteAsync( Guid id, CancellationToken cancellationToken = default) { await _context.Orders .Where(o => o.Id == id) .ExecuteDeleteAsync(cancellationToken); } } ``` > **ระวัง Leaky Abstractions** > > Repository ไม่ควรเปิดเผย IQueryable ออกไปโดยตรง เพราะจะสร้างการพึ่งพา EF Core ในชั้นบน ควรใช้เมธอดเฉพาะที่กำหนดพารามิเตอร์ชัดเจน ## การนำ Unit of Work ไปใช้ ```csharp // Infrastructure/Persistence/UnitOfWork.cs using Microsoft.EntityFrameworkCore.Storage; using CleanArchitecture.Domain.Interfaces; using CleanArchitecture.Infrastructure.Repositories; namespace CleanArchitecture.Infrastructure.Persistence; public class UnitOfWork : IUnitOfWork { private readonly AppDbContext _context; private IDbContextTransaction? _transaction; // โหลด repository แบบ lazy private IOrderRepository? _orderRepository; private IProductRepository? _productRepository; public UnitOfWork(AppDbContext context) { _context = context; } // สร้างเมื่อจำเป็น (lazy initialization) public IOrderRepository Orders => _orderRepository ??= new OrderRepository(_context); public IProductRepository Products => _productRepository ??= new ProductRepository(_context); public async Task SaveChangesAsync( CancellationToken cancellationToken = default) { return await _context.SaveChangesAsync(cancellationToken); } public async Task BeginTransactionAsync( CancellationToken cancellationToken = default) { _transaction = await _context.Database .BeginTransactionAsync(cancellationToken); } public async Task CommitTransactionAsync( CancellationToken cancellationToken = default) { if (_transaction == null) throw new InvalidOperationException("ไม่มีธุรกรรมที่ทำงานอยู่"); await _transaction.CommitAsync(cancellationToken); await _transaction.DisposeAsync(); _transaction = null; } public async Task RollbackTransactionAsync( CancellationToken cancellationToken = default) { if (_transaction == null) throw new InvalidOperationException("ไม่มีธุรกรรมที่ทำงานอยู่"); await _transaction.RollbackAsync(cancellationToken); await _transaction.DisposeAsync(); _transaction = null; } public void Dispose() { _transaction?.Dispose(); _context.Dispose(); } } ``` ## การตั้งค่า Dependency Injection การลงทะเบียนบริการเกิดขึ้นในแต่ละชั้นผ่าน extension method แล้วถูกควบคุมรวมใน Program.cs ```csharp // Infrastructure/DependencyInjection.cs using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using CleanArchitecture.Domain.Interfaces; using CleanArchitecture.Infrastructure.Persistence; using CleanArchitecture.Infrastructure.Repositories; namespace CleanArchitecture.Infrastructure; public static class DependencyInjection { public static IServiceCollection AddInfrastructure( this IServiceCollection services, IConfiguration configuration) { // กำหนดค่า Entity Framework services.AddDbContext(options => options.UseSqlServer( configuration.GetConnectionString("DefaultConnection"), b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName))); // ลงทะเบียน Unit of Work (Scoped) services.AddScoped(); // Repository รายตัวเมื่อจำเป็น services.AddScoped(); services.AddScoped(); return services; } } ``` ```csharp // Application/DependencyInjection.cs using FluentValidation; using MediatR; using Microsoft.Extensions.DependencyInjection; using CleanArchitecture.Application.Common.Behaviors; namespace CleanArchitecture.Application; public static class DependencyInjection { public static IServiceCollection AddApplication(this IServiceCollection services) { var assembly = typeof(DependencyInjection).Assembly; // ลงทะเบียน MediatR พร้อม handler services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly)); // ลงทะเบียน validator ของ FluentValidation services.AddValidatorsFromAssembly(assembly); // Pipeline behavior (ลำดับการทำงานสำคัญ) services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); return services; } } ``` ```csharp // Api/Program.cs using CleanArchitecture.Application; using CleanArchitecture.Infrastructure; var builder = WebApplication.CreateBuilder(args); // ลงทะเบียนชั้นต่าง ๆ builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); // การตั้งค่า API builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run(); ``` ## ชั้น Presentation: คอนโทรลเลอร์ API คอนโทรลเลอร์เป็นอแดปเตอร์ที่เรียบง่ายซึ่งมอบหมายงานให้ MediatR ```csharp // Api/Controllers/OrdersController.cs using MediatR; using Microsoft.AspNetCore.Mvc; using CleanArchitecture.Application.Orders.Commands; using CleanArchitecture.Application.Orders.Queries; namespace CleanArchitecture.Api.Controllers; [ApiController] [Route("api/[controller]")] public class OrdersController : ControllerBase { private readonly IMediator _mediator; public OrdersController(IMediator mediator) { _mediator = mediator; } [HttpPost] [ProducesResponseType(typeof(Guid), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task Create( [FromBody] CreateOrderCommand command, CancellationToken cancellationToken) { // มอบหมายงานทั้งหมดให้ MediatR var orderId = await _mediator.Send(command, cancellationToken); return CreatedAtAction( nameof(GetById), new { id = orderId }, orderId); } [HttpGet("{id:guid}")] [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetById( Guid id, CancellationToken cancellationToken) { var order = await _mediator.Send( new GetOrderByIdQuery(id), cancellationToken); if (order == null) return NotFound(); return Ok(order); } [HttpPost("{id:guid}/confirm")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Confirm( Guid id, CancellationToken cancellationToken) { await _mediator.Send(new ConfirmOrderCommand(id), cancellationToken); return NoContent(); } [HttpPost("{id:guid}/ship")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Ship( Guid id, CancellationToken cancellationToken) { await _mediator.Send(new ShipOrderCommand(id), cancellationToken); return NoContent(); } } ``` คอนโทรลเลอร์ยังคงบางและไม่มีตรรกะทางธุรกิจ มีเพียงการจับคู่ HTTP เท่านั้น ## Unit Test สำหรับชั้น Application Clean Architecture ทำให้การเขียน unit test ง่ายขึ้นด้วยการแยกการพึ่งพา ```csharp // Tests/Application/CreateOrderCommandHandlerTests.cs using Moq; using Xunit; using CleanArchitecture.Application.Orders.Commands; using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Interfaces; namespace CleanArchitecture.Tests.Application; public class CreateOrderCommandHandlerTests { private readonly Mock _unitOfWorkMock; private readonly CreateOrderCommandHandler _handler; public CreateOrderCommandHandlerTests() { _unitOfWorkMock = new Mock(); _handler = new CreateOrderCommandHandler(_unitOfWorkMock.Object); } [Fact] public async Task Handle_ValidCommand_CreatesOrderAndReturnsId() { // Arrange var product = new Product { Id = Guid.NewGuid(), Name = "Test", Price = 100 }; _unitOfWorkMock.Setup(x => x.Products.GetByIdAsync( It.IsAny(), It.IsAny())) .ReturnsAsync(product); _unitOfWorkMock.Setup(x => x.Orders.AddAsync( It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); _unitOfWorkMock.Setup(x => x.SaveChangesAsync(It.IsAny())) .ReturnsAsync(1); var command = new CreateOrderCommand( CustomerEmail: "test@example.com", Items: new List { new(ProductId: product.Id, Quantity: 2) }); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert Assert.NotEqual(Guid.Empty, result); _unitOfWorkMock.Verify(x => x.Orders.AddAsync( It.Is(o => o.CustomerEmail == "test@example.com"), It.IsAny()), Times.Once); _unitOfWorkMock.Verify(x => x.SaveChangesAsync( It.IsAny()), Times.Once); } [Fact] public async Task Handle_ProductNotFound_ThrowsNotFoundException() { // Arrange _unitOfWorkMock.Setup(x => x.Products.GetByIdAsync( It.IsAny(), It.IsAny())) .ReturnsAsync((Product?)null); var command = new CreateOrderCommand( CustomerEmail: "test@example.com", Items: new List { new(ProductId: Guid.NewGuid(), Quantity: 1) }); // Act & Assert await Assert.ThrowsAsync( () => _handler.Handle(command, CancellationToken.None)); } } ``` Mock ของ Moq ช่วยแยก handler ออกจากการพึ่งพา ทำให้ทดสอบเฉพาะตรรกะการกำกับเท่านั้น ## บทสรุป Clean Architecture ร่วมกับ .NET มอบโครงสร้างที่แข็งแกร่งสำหรับแอปพลิเคชันระดับองค์กร การแยกความรับผิดชอบอย่างเข้มงวดระหว่างชั้น Domain, Application, Infrastructure และ Presentation รับประกันโค้ดที่บำรุงรักษาและทดสอบได้ในระยะยาว ### เช็กลิสต์ Clean Architecture .NET - ✅ Domain แยกออกโดยไม่มีการพึ่งพาภายนอก - ✅ Entity มีการห่อหุ้มตรรกะทางธุรกิจ - ✅ Interface ของ repository อยู่ใน Domain - ✅ Use Case ผ่าน Commands/Queries (CQRS) - ✅ การตรวจสอบด้วย FluentValidation และ Pipeline Behaviors - ✅ Infrastructure นำ interface ของ Domain ไปใช้ - ✅ คอนโทรลเลอร์บางที่มอบหมายงานให้ MediatR - ✅ Unit test ที่แยกออกพร้อมการใช้ mock การลงทุนเริ่มต้นในสถาปัตยกรรมนี้คุ้มค่าอย่างรวดเร็วในโครงการขนาดกลางถึงใหญ่ ที่ซึ่งการเปลี่ยนข้อกำหนดทางเทคนิค (เช่น การย้ายฐานข้อมูลหรือการเปลี่ยนเฟรมเวิร์ก) จะกระทบเฉพาะชั้นภายนอก โดยคงแกนทางธุรกิจไว้อย่างสมบูรณ์ --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/th/blog/dotnet/clean-architecture-dotnet-practical-guide