Application Layer ใน Clean Architecture: UseCase ที่ดีควรมีแค่นี้

Application Layer ใน Clean Architecture: UseCase ที่ดีควรมีแค่นี้

Application Layer ใน Clean Architecture: UseCase ที่ดีไม่ควรมี Business Logic เลย

💡 บทความนี้เป็นส่วนที่ 2 ของ series Dotnet Architecture — ต่อจากบทความ Core Layer ที่อธิบาย Interface, Domain Rules และ project structure ถ้ายังไม่ได้อ่านแนะนำให้อ่านก่อนเพื่อเข้าใจ Contract ที่ Application Layer จะนำมาใช้

ปัญหาที่พบบ่อยที่สุดใน Application Layer คือ UseCase ที่อ้วนขึ้นเรื่อยๆ — Business Rules ถูก copy มาไว้ใน if statement, Mapping logic กระจัดกระจายอยู่หลายจุด และไม่มีใครรู้แน่ชัดว่า “ส่วนนี้ควรอยู่ที่ UseCase หรือ Domain”

บทความนี้จะอธิบาย Application Layer ที่มีกฎเพียงข้อเดียว: UseCase ทำแค่ Orchestrate — ไม่มี Business Logic โดยตรง


Application Layer คืออะไร และต่างจาก Core Layer อย่างไร

Application Layer คือชั้นที่รู้ว่า “ต้องทำอะไรก่อนหลัง” โดยอาศัย Contract ที่ Core Layer ประกาศไว้

Core Layer        รู้ว่า "อะไรถูก/ผิด" (Business Rules)
Application Layer รู้ว่า "ทำอะไรก่อนหลัง" (Orchestration)
Infrastructure    รู้ว่า "ทำยังไง" (Implementation)

Application Layer depend on Core แต่ ไม่รู้จัก Infrastructure โดยตรง — มันเรียกผ่าน Interface ที่ Core ประกาศไว้เท่านั้น ทำให้สามารถ Test Application Layer ได้โดยไม่ต้องมี Database จริง


โครงสร้าง Folder ของ Application Layer

MyApp.Application/
└── Features/
    ├── Order/
    │   ├── Commands/
    │   │   └── CreateOrder/
    │   │       ├── CreateOrderCommand.cs          ← Input จาก API
    │   │       ├── CreateOrderValidator.cs         ← Fail Fast: format + input semantic
    │   │       ├── CreateOrderUseCase.cs           ← Orchestrate flow
    │   │       ├── CreateOrderMapper.cs            ← Pure mapping ทุกทิศทาง
    │   │       └── CreateOrderResponse.cs          ← Output ไปยัง API
    │   └── Queries/
    │       └── GetOrderDetail/
    │           ├── GetOrderDetailQuery.cs
    │           ├── GetOrderDetailQueryValidator.cs
    │           ├── GetOrderDetailUseCase.cs
    │           ├── GetOrderDetailMapper.cs
    │           └── GetOrderDetailResponse.cs
    └── Payment/
        └── Commands/
            └── ProcessPayment/
                ├── ProcessPaymentCommand.cs
                ├── ProcessPaymentUseCase.cs
                ├── ProcessPaymentMapper.cs
                └── ProcessPaymentResponse.cs

แต่ละ UseCase มีไฟล์ 5 ตัวที่มีหน้าที่แยกกันชัดเจน ซึ่งจะอธิบายทีละส่วนในหัวข้อถัดไป


Data Flow ผ่าน Application Layer

ก่อนลงรายละเอียดแต่ละไฟล์ ควรเข้าใจภาพรวม data flow ก่อน:

API Request

FastEndpoints deserialize JSON → Command/Query     ← type check อัตโนมัติ

[Validator — FluentValidation]                     ← format + input semantic (Fail Fast)
    ↓  ← fail → 400 ทันที, ไม่เข้า UseCase เลย
UseCase

I/O — fetch ข้อมูลที่ Rules ต้องการ               ← เช่น IsCustomerActiveAsync

[Mapper] → Order Model                             ← Command → Order (enforce Invariant)
           สร้าง Value Types จาก primitive          สร้าง UniqueId.New() สำหรับ OrderId
           สร้าง OrderLineVO จาก OrderLineItem      (pure, fail ได้จาก VO.Create())

[Mapper] → {MethodName}RuleInput                   ← Order + I/O result → Context Type

Domain Rules                                       ← Business Rules enforcement (pure, ไม่มี I/O)
           Rules ทุกตัวรับ Context Type เสมอ

[Mapper] → {MethodName}Input                       ← Order Model → RepositoryInput
           extract Value Types กลับเป็น primitive   (pure)

Repository                                         ← persist หรือ query (I/O)

[Mapper] → {UseCase}Response                       ← Output → Response (pure)

API Response

สังเกตว่า I/O เกิดขึ้น 2 ครั้ง ใน Command UseCase — ครั้งแรกเพื่อ fetch ข้อมูลที่ Rules ต้องการ ครั้งที่สองเพื่อ persist ส่วน Validator, Mapper และ Domain Rules เป็น pure logic ทั้งหมด โดย Mapper คือ เจ้าของ Value Object lifecycle — สร้าง Order Model (enforce Invariant) ก่อนส่งให้ Rules และ extract กลับเป็น primitive ก่อนส่งให้ Repository

Data Flow ผ่าน Application Layer — แสดง path ตั้งแต่ HTTP Request ผ่าน FastEndpoints, FluentValidation, UseCase, Mapper, Business Rules จนถึง Repository


Command และ Query — Input ของ UseCase

Command และ Query คือ object ที่รับข้อมูลมาจาก API Layer ก่อนส่งต่อให้ UseCase:

// Application/Features/Order/Commands/CreateOrder/CreateOrderCommand.cs
namespace MyApp.Application.Features.Order.Commands.CreateOrder;

public sealed record CreateOrderCommand(
    string CustomerId,  // ← plain string จาก API
    IReadOnlyList<OrderLineItem> Lines,
    string? Note
);

// Nested type — ModelName = OrderLine, suffix Item = plain primitive จาก API
public sealed record OrderLineItem(
    string ProductId,
    int Quantity,
    decimal UnitPrice
);
// Application/Features/Order/Queries/GetOrderDetail/GetOrderDetailQuery.cs
namespace MyApp.Application.Features.Order.Queries.GetOrderDetail;

public sealed record GetOrderDetailQuery(string OrderId);

Command และ Query ใช้ record เช่นเดียวกับ Core Layer เพราะ input ที่ส่งเข้า UseCase ไม่ควรถูกเปลี่ยนแปลงระหว่างทาง

ความต่างระหว่าง Command กับ Query:

CommandQuery
จุดประสงค์เปลี่ยนแปลง stateอ่านข้อมูล
มี side effect✅ ใช่❌ ไม่มี
ต้อง validate✅ จำเป็น⚠️ น้อยกว่า
คู่กับRepositoryViewReader

Validator — Fail Fast ที่ API Boundary

Validator ทำหน้าที่กัน invalid input ออกก่อนเข้า UseCase โดยไม่ต้องเปิด DB connection เลย

กฎสำคัญ 2 ข้อ:

  1. Fail Fast เท่านั้น — ตรวจ format, type, และ input semantic ที่ไม่ต้องการ I/O
  2. ไม่ใช่เจ้าของ Business Rule — Invariant จริงๆ ยังต้องอยู่ใน Core Layer เสมอ
// Application/Features/Order/Commands/CreateOrder/CreateOrderValidator.cs
namespace MyApp.Application.Features.Order.Commands.CreateOrder;

using FluentValidation;

public class CreateOrderValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderValidator()
    {
        // Format — type system จัดการ Guid format ให้แล้ว, NotEmpty ตรวจ empty Guid
        RuleFor(x => x.CustomerId)
            .NotEmpty().WithMessage("CustomerId is required.");

        // Input Semantic — Fail Fast ก่อนถึง Domain Invariant
        RuleFor(x => x.Lines)
            .NotEmpty().WithMessage("Order must have at least one line.");

        // Format + Range
        RuleForEach(x => x.Lines).ChildRules(line =>
        {
            line.RuleFor((OrderLineItem l) => l.ProductId)
                .NotEmpty().WithMessage("ProductId is required.");

            line.RuleFor((OrderLineItem l) => l.Quantity)
                .GreaterThan(0).WithMessage("Quantity must be greater than zero.");

            line.RuleFor((OrderLineItem l) => l.UnitPrice)
                .GreaterThan(0).WithMessage("UnitPrice must be greater than zero.");
        });
    }
}

FastEndpoints ค้นหา AbstractValidator<CreateOrderCommand> จาก DI Container อัตโนมัติและรันก่อน HandleAsync ทุกครั้ง ถ้า Validator fail — FastEndpoints return 400 Bad Request โดย UseCase ไม่ถูกเรียกเลย

⚠️ Validator ไม่ใช่ตัวเลือกแทน Core LayerLines.NotEmpty() ใน Validator เป็น Fail Fast optimization, แต่ OrderErrors.EmptyOrderLines ใน Core Layer ยังต้องมีอยู่เพื่อ enforce Invariant เมื่อมีการเรียก UseCase จากทางอื่น เช่น Background Job หรือ Test

หลักการตัดสินใจ — Validator หรือ Invariant?

เมื่อ BA ระบุ requirement มา ให้ถาม 3 คำถามนี้ตามลำดับ:

คำถามที่ 1 — “Domain Expert เข้าใจและสนใจ rule นี้ไหม?”

Domain Expert คือคนที่รู้เรื่อง business เช่น Sales Manager หรือ BA ถามว่าถ้าอธิบาย rule นี้ให้เขาฟัง เขาจะรู้เรื่องไหม

"CustomerId ต้องเป็น GUID format"
  → Sales Manager ไม่รู้จักคำว่า GUID
  → นี่คือ technical constraint ของ API
  → Validator เท่านั้น ✋ หยุดที่นี่

"Lines ต้องไม่ว่าง"
  → Sales Manager เข้าใจทันทีว่า Order ต้องมีสินค้า
  → ถามต่อ →

คำถามที่ 2 — “rule นี้ต้องเป็นจริงตลอด lifecycle ของ Domain Object ไหม?”

ไม่ใช่แค่ตอนสร้างผ่าน API นี้ — แต่ทุกครั้งที่มีการ operation นั้นเกิดขึ้น ไม่ว่าจะมาจาก Background Job, Admin Tool, Event Consumer, หรือ Test

"Lines ต้องไม่ว่าง"
  → ถ้า Background Job สร้าง Order อัตโนมัติ ยังต้องมีสินค้าไหม? → ใช่
  → Core Layer (Invariant) + Validator (Fail Fast) ทั้งคู่

"Note บังคับสำหรับ Web form นี้"
  → Order จาก Background Job ต้องมี Note ไหม? → ไม่
  → Validator เท่านั้น ✋ หยุดที่นี่

คำถามที่ 3 — “rule นี้ต้องการ I/O เพื่อตรวจสอบไหม?”

เช่น ต้องไปดู DB หรือเรียก Service อื่น

"Customer ต้องเป็น Active"
  → ต้องไป query DB → ไม่ใช่ Validator, ไม่ใช่ Invariant (ต้องการ I/O จึงไม่ใช่ pure)
  → UseCase orchestrates: repo.IsCustomerActiveAsync(...)

"Quantity > 0"
  → ตรวจได้จาก value ที่มีอยู่ ไม่ต้องการ I/O
  → Core Layer (Invariant) + Validator (Fail Fast)

สรุปผลของ 3 คำถาม:

เงื่อนไขValidatorCore LayerUseCase
Technical format (GUID, email, regex)
API-specific constraint (field บังคับเฉพาะ endpoint นี้)
Invariant — pure, ไม่ต้องการ I/O✅ Fail Fast✅ เจ้าของจริง
Business rule ที่ต้องการ I/O

หลักการตัดสินใจ 3 คำถาม — ถาม Domain Expert → ถาม lifecycle → ถาม I/O เพื่อแยก Validator, Invariant, และ UseCase

แปลง requirement ตัวอย่างจาก BA:

BA: "CustomerId ต้องระบุ"
  Q1: Domain Expert เข้าใจไหม? → เข้าใจ แต่...
  Q2: ต้องเป็นจริงตลอด lifecycle ไหม? → ใช่ แต่...
  Q3: ต้องการ I/O ไหม? → ไม่ (แค่ตรวจว่าไม่ว่าง)
  → Core Layer (Invariant: CustomerId required) + Validator (Fail Fast: NotEmpty)
  ✋ แต่ "CustomerId ต้องเป็น GUID format" → Validator เท่านั้น เพราะ Q1 ตก

BA: "Order ต้องมีสินค้าอย่างน้อย 1 รายการ"
  Q1: Domain Expert เข้าใจไหม? → ใช่
  Q2: ต้องเป็นจริงตลอด lifecycle ไหม? → ใช่ (ทุก code path)
  Q3: ต้องการ I/O ไหม? → ไม่
  → Core Layer (Invariant) + Validator (Fail Fast)

BA: "Quantity ต้องมากกว่า 0"
  Q1: Domain Expert เข้าใจไหม? → ใช่
  Q2: ต้องเป็นจริงตลอด lifecycle ไหม? → ใช่
  Q3: ต้องการ I/O ไหม? → ไม่
  → Core Layer (Invariant) + Validator (Fail Fast)

BA: "Customer ต้องเป็น Active จึงจะสั่ง Order ได้"
  Q1: Domain Expert เข้าใจไหม? → ใช่
  Q2: ต้องเป็นจริงตลอด lifecycle ไหม? → ใช่
  Q3: ต้องการ I/O ไหม? → ใช่ (ต้องไป query DB)
  → UseCase: await repo.IsCustomerActiveAsync(...)

UseCase — Orchestration เท่านั้น

นี่คือหัวใจของ Application Layer กฎเดียวที่ต้องจำ:

UseCase ต้องไม่มี Business Logic โดยตรง — Business Rules อยู่ใน Core Domain, I/O อยู่ใน Infrastructure, UseCase ทำแค่ orchestrate ลำดับการทำงาน

// Application/Features/Order/Commands/CreateOrder/CreateOrderUseCase.cs
namespace MyApp.Application.Features.Order.Commands.CreateOrder;

using MyApp.Core.Features.Order.Commands.CreateOrder;
using MyApp.Core.Features.Order.Shared;
using MyApp.Core.ServiceClients;

public class CreateOrderUseCase(
    ICreateOrderRepository repo,
    IPaymentServiceClient payment)
{
    public async Task<Result<CreateOrderResponse>> ExecuteAsync(
        CreateOrderCommand command,
        CancellationToken ct = default)
    {
        // 1. Mapping — Command → Order Model (enforce Invariant) — sync ไม่มี I/O
        var orderResult = CreateOrderMapper.ToOrder(command);
        if (orderResult.IsFailure)
            return Result.Failure(orderResult.Error);
        var order = orderResult.Value;

        // 2. I/O — fetch ข้อมูลที่ Rules ต้องการ
        var isActive = await repo.IsCustomerActiveAsync(command.CustomerId, ct);

        // 3. Business Rules — แต่ละ Rule รับ Context Type ของตัวเอง
        //    order เป็น closure ใช้ร่วมกันทุก Rule
        var activeCheck = CreateOrderRules.MustBeActiveCustomer(
            MustBeActiveCustomerRuleInput.Create(isActive));
        if (activeCheck.IsFailure) return Result.Failure(activeCheck.Error);

        var linesCheck = CreateOrderRules.MustHaveOrderLines(
            MustHaveOrderLinesRuleInput.Create(order));
        if (linesCheck.IsFailure) return Result.Failure(linesCheck.Error);

        var uniqueCheck = CreateOrderRules.MustHaveUniqueProducts(
            MustHaveUniqueProductsRuleInput.Create(order));
        if (uniqueCheck.IsFailure) return Result.Failure(uniqueCheck.Error);

        var total = CreateOrderRules.CalculateOrderTotal(
            CalculateOrderTotalRuleInput.Create(order));
        if (total.IsFailure) return Result.Failure(total.Error);

        // 4. Mapping — Order Model + total → CreateOrderInput (extract → plain)
        var repoInput = CreateOrderMapper.ToCreateOrderInput(order, total.Value);

        // 5. I/O — persist
        var output = await repo.CreateOrderAsync(repoInput, ct);

        // 6. Map Response — pure, ไม่ต้อง await
        return Result.Success(CreateOrderMapper.ToCreateOrderResponse(output));
    }
}

สังเกต comment ทั้ง 6 ขั้นตอน — UseCase ที่ดีควรอ่าน comment แล้วเข้าใจ flow ได้ทันที โดยไม่ต้องดูรายละเอียด implementation

UseCase แบบ ROP + Instrumented Tracing (style หลักที่ใช้ใน series นี้)

ROP (Railway-Oriented Programming) แปลง flow ข้างบนให้เป็น pipeline ต่อเนื่อง ถ้า step ไหน fail — step ที่เหลือถูก skip อัตโนมัติโดยไม่ต้องเขียน if/else เลย

InstrumentedResultExtensions จาก FeedCommonLib.Application.Abstractions.Tracing รวม ROP กับ OpenTelemetry ไว้ด้วยกัน — span name ของแต่ละ step คือชื่อ function ที่ถูกเรียก ไม่ใช่ string ที่ระบุเอง ดังนั้นทุก step ใน chain ต้อง call named function เสมอ ห้ามเขียน inline lambda ที่มี logic หรือ ternary

กฎ ROP chain ที่ต้องจำ:

กฎที่ 1 — 1 Chain step = 1 Named function เท่านั้น
  span name มาจากชื่อ function ที่ถูกเรียก ถ้า call หลาย function ใน step เดียว
  span จะจับชื่อไม่ถูกต้องและไม่รู้ว่าพังที่ function ไหน

✅ .Then(isActive => CreateAndValidateOrder(command, isActive))
   → span name = "CreateAndValidateOrder"

✅ .Then(order => CalculateTotal(order))
   → span name = "CalculateTotal"

✅ .Map(output => CreateOrderMapper.ToCreateOrderResponse(output))
   → span name = "ToCreateOrderResponse" — lambda มี 1 named function เท่านั้น

❌ .Then(order => {
       var check = CreateOrderRules.MustHaveOrderLines(MustHaveOrderLinesRuleInput.Create(order));
       var dup   = CreateOrderRules.MustHaveUniqueProducts(MustHaveUniqueProductsRuleInput.Create(order));
       return check;  // 2 functions = ผิด
   })
   → span จับชื่อไม่ได้ว่าพังที่ MustHaveOrderLines หรือ MustHaveUniqueProducts

กฎที่ 2 — ห้าม inline lambda ที่มี logic หรือ ternary

✅ .Then(isActive => CreateAndValidateOrder(command, isActive))
   → span name = "CreateAndValidateOrder" (จากชื่อ named method)

❌ .Then(isActive => isActive ? Result.Success(order) : Result.Failure(...))
   → span จับชื่อไม่ได้ + ผิด pattern

Command UseCase:

// Application/Features/Order/Commands/CreateOrder/CreateOrderUseCase.cs
namespace MyApp.Application.Features.Order.Commands.CreateOrder;

using FeedCommonLib.Application.Abstractions.Tracing;
using MyApp.Core.Features.Order.Commands.CreateOrder;
using MyApp.Core.Features.Order.Shared;
using MyApp.Core.ServiceClients;

public class CreateOrderUseCase(
    ICreateOrderRepository repo,
    IPaymentServiceClient payment)
{
    public async Task<Result<CreateOrderResponse>> ExecuteAsync(
        CreateOrderCommand command,
        CancellationToken ct = default)
    {
        return await InstrumentedResultExtensions
            .BeginTracingAsync(() => repo.IsCustomerActiveAsync(command.CustomerId, ct))
            .Then(isActive  => CreateAndValidateOrder(command, isActive))
            .Then(order     => CalculateTotal(order))
            .Then(result    => CreateOrderMapper.ToCreateOrderInput(result.Order, result.Total))
            .ThenAsync(repoInput => repo.CreateOrderAsync(repoInput, ct))
            .Then(output    => CreateOrderMapper.ToCreateOrderResponse(output))
            .Map(response   => Result<CreateOrderResponse>.Success(response, SuccessCodes.Created));
    }

    // span = "CreateAndValidateOrder" — รวม ToOrder + Validate
    // internal เพื่อให้ Test project เข้าถึงได้ผ่าน InternalsVisibleTo
    internal static async Task<Result<Order>> CreateAndValidateOrder(
        CreateOrderCommand command, bool isActive)
    {
        // Chain A — สร้าง Order Model (BeginTracingAsync รับ Func<Result<T>> ได้โดยตรง)
        var orderResult = await InstrumentedResultExtensions
            .BeginTracingAsync(() => CreateOrderMapper.ToOrder(command));

        if (orderResult.IsFailure) return orderResult.Error;
        var order = orderResult.Value;

        // Chain B — Validate ทุก Rule ด้วย CreateWithResult
        return await InstrumentedResultExtensions
            .BeginTracingAsync(() => MustBeActiveCustomerRuleInput.CreateWithResult(isActive))
            .Then(input => CreateOrderRules.MustBeActiveCustomer(input))
            .Then(_ => MustHaveOrderLinesRuleInput.CreateWithResult(order))
            .Then(input => CreateOrderRules.MustHaveOrderLines(input))
            .Then(_ => MustHaveUniqueProductsRuleInput.CreateWithResult(order))
            .Then(input => CreateOrderRules.MustHaveUniqueProducts(input))
            .Map(_ => order);
    }

    // span = "CalculateTotal" — calculate + combine Order กับ Total
    internal static Result<(Order Order, OrderTotal Total)> CalculateTotal(Order order) =>
        CreateOrderRules.CalculateOrderTotal(
                CalculateOrderTotalRuleInput.Create(order))
            .Map(total => (order, total));
}

CreateOrderRules แต่ละ function มี Context Type ของตัวเอง พร้อม Create และ CreateWithResult:

// Core/Features/Order/Commands/CreateOrder/CreateOrderRules.cs
namespace MyApp.Core.Features.Order.Commands.CreateOrder;

// Create           — ใช้ใน Imperative style
// CreateWithResult — ใช้ใน ROP chain เพื่อให้ BeginTracingAsync และ Then รับต่อได้
// span name = "{TypeName}.CreateWithResult"
public sealed record MustBeActiveCustomerRuleInput(bool IsCustomerActive)
{
    public static MustBeActiveCustomerRuleInput Create(bool isActive) => new(isActive);
    public static Result<MustBeActiveCustomerRuleInput> CreateWithResult(bool isActive) =>
        Result.Success(Create(isActive));
}
public sealed record MustHaveOrderLinesRuleInput(Order Order)
{
    public static MustHaveOrderLinesRuleInput Create(Order order) => new(order);
    public static Result<MustHaveOrderLinesRuleInput> CreateWithResult(Order order) =>
        Result.Success(Create(order));
}
public sealed record MustHaveUniqueProductsRuleInput(Order Order)
{
    public static MustHaveUniqueProductsRuleInput Create(Order order) => new(order);
    public static Result<MustHaveUniqueProductsRuleInput> CreateWithResult(Order order) =>
        Result.Success(Create(order));
}
public sealed record CalculateOrderTotalRuleInput(Order Order)
{
    public static CalculateOrderTotalRuleInput Create(Order order) => new(order);
    public static Result<CalculateOrderTotalRuleInput> CreateWithResult(Order order) =>
        Result.Success(Create(order));
}

public static class CreateOrderRules
{
    public static Result MustBeActiveCustomer(MustBeActiveCustomerRuleInput input) =>
        input.IsCustomerActive
            ? Result.Success()
            : Result.Failure(OrderErrors.CustomerNotActive);

    public static Result MustHaveOrderLines(MustHaveOrderLinesRuleInput input) =>
        input.Order.Lines.Count == 0
            ? Result.Failure(OrderErrors.EmptyOrderLines)
            : Result.Success();

    public static Result MustHaveUniqueProducts(MustHaveUniqueProductsRuleInput input) =>
        input.Order.Lines.GroupBy(l => l.ProductId).Any(g => g.Count() > 1)
            ? Result.Failure(OrderErrors.DuplicateProductLine)
            : Result.Success();

    public static Result<OrderTotal> CalculateOrderTotal(CalculateOrderTotalRuleInput input)
    {
        var totalValue = input.Order.Lines.Sum(l => l.UnitPrice.Value * l.Quantity.Value);
        return OrderTotal.Create(totalValue, "THB");
    }
}

CreateOrderInput ใน Interface file รับ plain primitive เสมอ — Mapper รับผิดชอบ extract:

// Core/Features/Order/Commands/CreateOrder/ICreateOrderRepository.cs
public sealed record CreateOrderInput(
    string OrderId,
    string CustomerId,
    IReadOnlyList<OrderLineRow> Lines,
    string? Note,
    decimal TotalAmount
);

Trace ที่ได้อัตโนมัติ:

Trace: CreateOrderUseCase.ExecuteAsync
  |
  +-- [Span] IsCustomerActiveAsync           ← BeginTracingAsync (ExecuteAsync)
  |
  +-- [Span] CreateAndValidateOrder          ← Then
  |     |
  |     +-- [Span] ToOrder                   ← BeginTracingAsync (Chain A)
  |     |
  |     +-- [Span] MustBeActiveCustomerRuleInput.CreateWithResult  ← BeginTracingAsync (Chain B)
  |     +-- [Span] MustBeActiveCustomer      ← Then
  |     +-- [Span] MustHaveOrderLinesRuleInput.CreateWithResult    ← Then
  |     +-- [Span] MustHaveOrderLines        ← Then
  |     +-- [Span] MustHaveUniqueProductsRuleInput.CreateWithResult ← Then
  |     +-- [Span] MustHaveUniqueProducts    ← Then
  |
  +-- [Span] CalculateTotal                  ← Then
  |
  +-- [Span] ToCreateOrderInput              ← Then
  |
  +-- [Span] CreateOrderAsync                ← ThenAsync
  |
  +-- [Span] ToCreateOrderResponse           ← Then
  |
  +-- [Span] Success wrapping                ← Map (terminal)

💡 internal methods ทดสอบได้ตรงๆ ผ่าน InternalsVisibleTo:

// [assembly: InternalsVisibleTo("MyApp.Tests")] ใน Application project

[Fact]
public async Task CreateAndValidateOrder_WhenCustomerNotActive_ShouldFail()
{
    var order  = CreateTestOrder(command);
    var result = await CreateOrderUseCase.CreateAndValidateOrder(command, isActive: false);
    Assert.True(result.IsFailure);
    Assert.Equal(OrderErrors.CustomerNotActive, result.Error);
}

ความต่างจาก Imperative style:

ImperativeROP
Error handlingif (IsFailure) return ทุก stepskip อัตโนมัติเมื่อ fail
Tracingไม่มี span อัตโนมัติspan name = ชื่อ function ที่ส่งเข้า chain
จุดเริ่มต้นasync/await ตามปกติBeginTracingAsync(...) เสมอ
async signatureasync Task<Result<T>>async Task<Result<T>> + return await
อ่านเหมือนimperative flowrecipe ที่อ่านจากบนลงล่าง

ROP Two Tracks — success track ไหลตรงจนถึง Response, failure track แยกออกที่ step ที่ fail และ skip ทุก step หลังนั้น

Query UseCase:

// Application/Features/Order/Queries/GetOrderDetail/GetOrderDetailUseCase.cs
namespace MyApp.Application.Features.Order.Queries.GetOrderDetail;

using FeedCommonLib.Application.Abstractions.Tracing;
using MyApp.Core.Features.Order.Queries.GetOrderDetail;

public class GetOrderDetailUseCase(IGetOrderDetailViewReader reader)
{
    public async Task<Result<GetOrderDetailResponse>> ExecuteAsync(
        GetOrderDetailQuery query,
        CancellationToken ct = default)
    {
        var viewInput = GetOrderDetailMapper.ToGetOrderDetailViewReaderInput(query); // pure sync ไม่มี I/O — ไม่ต้องอยู่ใน chain

        return await InstrumentedResultExtensions
            .BeginTracingAsync(() => reader.GetAsync(viewInput, ct))
            .Map(output => GetOrderDetailMapper.ToGetOrderDetailResponse(output))
            .Map(response => Result<GetOrderDetailResponse>.Success(response, SuccessCodes.Ok));
    }
}

กฎ chain step: ทุก step ใน chain ไม่ว่าจะเป็น Then, ThenAsync, หรือ Map — ต้องมี 1 named function เท่านั้น ห้าม inline logic, ห้าม nested call, ห้ามรวมหลาย operation ไว้ด้วยกัน

// ✅ 1 step = 1 named function
.Then(input => CreateOrderRules.CalculateOrderTotal(input))
.Map(output => CreateOrderMapper.ToCreateOrderResponse(output))
.Map(response => Result<T>.Success(response, SuccessCodes.Created))

// ❌ รวม mapping กับ Result wrapping ใน Map เดียว
.Map(output => Result<T>.Success(CreateOrderMapper.ToCreateOrderResponse(output), SuccessCodes.Created))

// ❌ Nested call ใน Then — 2 functions ใน step เดียว
.Then(input => CreateOrderRules.CalculateOrderTotal(
    CreateOrderMapper.ToCreateOrderInput(input)))

// ❌ Ternary inline — ไม่มี named function
.Then(isActive => isActive ? Result.Success(input) : Result.Failure(...))

Mapper — Pure Mapping ทุกทิศทาง

Mapper รับผิดชอบการแปลง data shape ระหว่าง Layer โดยมีกฎสำคัญ 4 ข้อ:

  1. Pure เสมอ — ไม่มี async, ไม่มี I/O, ไม่มี side effect
  2. รวมทุก Mapping ของ UseCase นั้นไว้ที่เดียว — ทุกทิศทางอยู่ใน file เดียว
  3. ตั้งชื่อตาม Context Type ที่ return — To{ContextType} — อ่านแล้วรู้ทันทีว่า function นี้คืนอะไรและใช้กับ function ไหน
  4. เจ้าของ Value Type lifecycle — สร้าง Order Model (enforce Invariant) และ extract Value Types กลับเป็น primitive ที่นี่เท่านั้น ไม่มีที่อื่น

Mapper มี function 2 ประเภทที่มี visibility ต่างกัน:

internal — UseCase เรียก        private — ToOrder เรียก
──────────────────────────────────────────────────────────────────────
ToOrder                         ToOrderLineVO
ToCreateOrderInput                 ↑ เรียกโดย ToOrder
ToCreateOrderResponse           ToOrderLineRow
                                   ↑ เรียกโดย ToCreateOrderInput

UseCase รู้แค่ว่า “ฉันต้องการ Order” — ไม่รู้ว่าข้างในต้องแปลง OrderLineItemOrderLineVO อย่างไร นั่นเป็นเรื่องของ Mapper เอง

ToOrder(...)                → Order                         (สร้าง Order Model)
ToCreateOrderInput(...)     → CreateOrderInput               (สำหรับ CreateOrderAsync)
ToCreateOrderResponse(...)  → CreateOrderResponse

ถ้า UseCase อื่นมี Context Type ต่างกัน ชื่อก็ต่างกันชัดเจน:

ToCancelOrderRuleInput(...)  → CancelOrderRuleInput  (สำหรับ CancelOrder Rules)
ToCancelOrderInput(...)      → CancelOrderInput      (สำหรับ CancelOrderAsync)
// Application/Features/Order/Commands/CreateOrder/CreateOrderMapper.cs
namespace MyApp.Application.Features.Order.Commands.CreateOrder;

using MyApp.Core.Features.Order.Commands.CreateOrder;
using MyApp.Core.Features.Order.Shared;

internal static class CreateOrderMapper
{
    // ── internal: UseCase เรียก ──────────────────────────────────────────

    // Command → Order Model (enforce Invariant + สร้าง UniqueId)
    // return Result<T> เพราะ ToOrderLineVO และ OrderNote.Create สามารถ fail ได้
    internal static Result<Order> ToOrder(CreateOrderCommand command)
    {
        // ✅ Fail fast — หยุดทันทีที่ fail ตัวแรก
        var lines = new List<OrderLineVO>();
        foreach (var line in command.Lines)
        {
            var result = ToOrderLineVO(line);
            if (result.IsFailure)
                return Result<Order>.Failure(result.Error);
            lines.Add(result.Value);
        }

        var note = OrderNote.Create(command.Note);
        if (note.IsFailure)
            return Result<Order>.Failure(note.Error);

        // CustomerId มาจาก API — Mapper สร้าง UniqueId จาก string
        var customerId = UniqueId.Create(command.CustomerId);
        if (customerId.IsFailure)
            return Result<Order>.Failure(customerId.Error);

        return Result<Order>.Success(new Order(
            OrderId:    UniqueId.New(),           // ← system สร้าง ID — เปลี่ยน strategy ได้จากที่เดียว
            CustomerId: customerId.Value,
            Lines:      lines,
            Note:       note.Value,
            Status:     OrderStatus.Pending
        ));
    }

    // Order Model + OrderTotal → Result<CreateOrderInput> (extract → plain primitive)
    internal static Result<CreateOrderInput> ToCreateOrderInput(Order order, OrderTotal total) =>
        Result.Success(new CreateOrderInput(
            order.OrderId.Value,          // ← extract UniqueId กลับเป็น string
            order.CustomerId.Value,       // ← extract UniqueId กลับเป็น string
            order.Lines.Select(ToOrderLineRow).ToList(),
            order.Note?.Value,            // ← extract OrderNote กลับเป็น primitive
            total.Value                   // ← extract OrderTotal กลับเป็น primitive
        ));

    // Repository Output → Result<CreateOrderResponse>
    internal static Result<CreateOrderResponse> ToCreateOrderResponse(CreateOrderOutput output) =>
        Result.Success(new CreateOrderResponse(output.OrderId, output.OrderNumber, output.CreatedAt));

    // ── private: Context Type function เรียก ────────────────────────────

    // OrderLineItem (App) → OrderLineVO (Core Model)
    // สร้าง Value Types จาก primitive — เจ้าของ Value Type lifecycle
    private static Result<OrderLineVO> ToOrderLineVO(OrderLineItem line)
    {
        var productId = UniqueId.Create(line.ProductId);
        if (productId.IsFailure) return Result<OrderLineVO>.Failure(productId.Error);

        var qty = Quantity.Create(line.Quantity);
        if (qty.IsFailure) return Result<OrderLineVO>.Failure(qty.Error);

        var price = Money.Create(line.UnitPrice, "THB");
        if (price.IsFailure) return Result<OrderLineVO>.Failure(price.Error);

        return Result<OrderLineVO>.Success(new OrderLineVO(
            productId.Value, qty.Value, price.Value));
    }

    // OrderLineVO (Core Model) → OrderLineRow (Core Repository)
    // extract Value Types กลับเป็น primitive
    private static OrderLineRow ToOrderLineRow(OrderLineVO line) =>
        new(line.ProductId.Value, line.Quantity.Value, line.UnitPrice.Value);
}

Mapper คือจุดเดียวที่ สร้าง Order Model (ToOrder) และ extract กลับเป็น primitive (ToCreateOrderInput, ToOrderLineRow)

ผลที่ได้คือ validation แบ่งหน้าที่ชัดเจน 3 ชั้น:

ชั้นตรวจอะไรfail เมื่อไหร่
FluentValidationformat + range (raw primitive)ก่อนเข้า UseCase
Value Type Create() ใน Mapperinvariant ของ type (ตอนสร้าง Order Model)ใน ToOrder ก่อนส่งให้ Rules
CreateOrderRulesBusiness Rules ของ UseCaseหลัง Order Model สร้างสำเร็จ

Validation 3 ชั้น — FluentValidation ตรวจ format ก่อน, Value Type Create() ตรวจ type invariant ใน Mapper, Business Rules ตรวจ use case logic หลัง mapping

Rules แต่ละตัวเหลือแค่ Business Rules จริงๆ เพราะ Invariant ถูกจัดการโดย Value Types ใน Mapper แล้ว:

// CreateOrderRules.cs — Context Type มีแค่ที่ใช้จริง ไม่มี Order ใน MustBeActiveCustomer
public static Result MustBeActiveCustomer(MustBeActiveCustomerRuleInput input) =>
    input.IsCustomerActive ? Result.Success() : Result.Failure(OrderErrors.CustomerNotActive);

public static Result MustHaveOrderLines(MustHaveOrderLinesRuleInput input) =>
    input.Order.Lines.Count == 0 ? Result.Failure(OrderErrors.EmptyOrderLines) : Result.Success();

public static Result MustHaveUniqueProducts(MustHaveUniqueProductsRuleInput input) =>
    input.Order.Lines.GroupBy(l => l.ProductId).Any(g => g.Count() > 1)
        ? Result.Failure(OrderErrors.DuplicateProductLine) : Result.Success();

public static Result<OrderTotal> CalculateOrderTotal(CalculateOrderTotalRuleInput input)
{
    var totalValue = input.Order.Lines.Sum(l => l.UnitPrice.Value * l.Quantity.Value);
    return OrderTotal.Create(totalValue, "THB");
}

เมื่อไหร่ควรแยก Mapper file และเมื่อไหร่ inline ใน UseCase?

Mapping logic ซับซ้อนแค่ไหน?

        ├─ แค่ new record ใส่ค่า ไม่มี logic   →  inline ใน UseCase ได้เลย

        └─ มี transformation หรือ reuse        →  แยก Mapper file

ถ้า Mapping ต้องการ I/O เช่น ต้องไปดึงข้อมูลเพิ่มก่อน map — นั่นไม่ใช่ Mapping แล้ว ให้ย้าย logic นั้นมาอยู่ใน UseCase


Response — Output ของ UseCase

Response คือ shape ที่ UseCase คืนกลับไปยัง API Layer:

// Application/Features/Order/Commands/CreateOrder/CreateOrderResponse.cs
namespace MyApp.Application.Features.Order.Commands.CreateOrder;

public sealed record CreateOrderResponse(
    string OrderId,  // ← plain string ส่งกลับ API
    string OrderNumber,
    DateTime CreatedAt
);
// Application/Features/Order/Queries/GetOrderDetail/GetOrderDetailResponse.cs
namespace MyApp.Application.Features.Order.Queries.GetOrderDetail;

public sealed record GetOrderDetailResponse(
    string OrderId,
    string OrderNumber,
    string CustomerName,
    IReadOnlyList<OrderLineResponse> Lines,
    decimal TotalAmount,
    string Status
);

public sealed record OrderLineResponse(
    string ProductName,
    int Quantity,
    decimal UnitPrice,
    decimal SubTotal
);

Response ไม่ใช่ Output จาก Core — มันคือ shape ที่ API ต้องการ ซึ่งมักต่างจาก Domain Output Mapper คือตัวเชื่อมระหว่างสองสิ่งนี้


Namespace Map และ Using ที่ต้องประกาศ

Assembly: MyApp.Application

MyApp.Application.Features.{Feature}.Commands.{UseCase}  → Command, Validator, UseCase, Mapper, Response
MyApp.Application.Features.{Feature}.Queries.{UseCase}   → Query, Validator, UseCase, Mapper, Response

UseCase ต้อง using จาก Core เสมอ:

// CreateOrderUseCase.cs
using MyApp.Core.Features.Order.Commands.CreateOrder; // ICreateOrderRepository + Rules + Records
using MyApp.Core.Features.Order.Shared;               // OrderErrors
using MyApp.Core.ServiceClients;                      // IPaymentServiceClient

Mapper using น้อยกว่า UseCase เพราะไม่ยุ่งกับ ServiceClient หรือ flow:

// CreateOrderMapper.cs
using MyApp.Core.Features.Order.Commands.CreateOrder; // Input/Output Records + Rules pure fn
using MyApp.Core.Features.Order.Shared;               // ถ้า mapping ต้องใช้ Shared Types

File Responsibilities สรุป

FileLayerหน้าที่
{UseCase}Command.csApplicationรับ input จาก API (write side)
{UseCase}Query.csApplicationรับ input จาก API (read side)
{UseCase}Validator.csApplicationFail Fast: format + input semantic ก่อนเข้า UseCase
{UseCase}UseCase.csApplicationOrchestrate: Domain Rules → map → I/O → map response
{UseCase}Mapper.csApplicationPure mapping ทุกทิศทาง
{UseCase}Response.csApplicationShape ที่คืนกลับไปยัง API
{UseCase}Rules.csCoreBusiness Rules + Invariants, pure functions
I{UseCase}Repository.csCoreContract + Records

โครงสร้างสมบูรณ์ทั้ง 2 Layer

MyApp.Core/
├── Features/
│   └── Order/
│       ├── Commands/
│       │   └── CreateOrder/
│       │       ├── CreateOrderRules.cs           ← Business Rules (Core)
│       │       └── ICreateOrderRepository.cs     ← Interface + Records (Core)
│       ├── Queries/
│       │   └── GetOrderDetail/
│       │       └── IGetOrderDetailViewReader.cs  ← Interface + Records (Core)
│       └── Shared/
│           ├── OrderTypes.cs                     ← plain anemic records + Nested Model types (เช่น OrderLineVO, OrderLineRow)
│           ├── OrderValues.cs                    ← Value Objects จริงๆ ที่มี invariant (เช่น Money, Quantity)
│           ├── OrderEnums.cs                     ← shared enums
│           ├── OrderRules.cs                     ← shared Business Rules ข้าม UseCase
│           └── OrderErrors.cs                    ← Error definitions
├── ServiceClients/
│   └── IPaymentServiceClient.cs
└── Shared/
    ├── CommonErrors.cs
    └── ErrorCodeRegistry.cs

MyApp.Application/
└── Features/
    └── Order/
        ├── Commands/
        │   └── CreateOrder/
        │       ├── CreateOrderCommand.cs         ← Input (Application)
        │       ├── CreateOrderValidator.cs        ← Fail Fast (Application)
        │       ├── CreateOrderUseCase.cs          ← Orchestrate (Application)
        │       ├── CreateOrderMapper.cs           ← Pure Mapping (Application)
        │       └── CreateOrderResponse.cs         ← Output (Application)
        └── Queries/
            └── GetOrderDetail/
                ├── GetOrderDetailQuery.cs
                ├── GetOrderDetailQueryValidator.cs
                ├── GetOrderDetailUseCase.cs
                ├── GetOrderDetailMapper.cs
                └── GetOrderDetailResponse.cs

สรุป

Application Layer ที่ดีมีลักษณะ 3 ข้อ:

  1. UseCase อ่านเหมือน flow diagram — ดูแค่ UseCase แล้วรู้ว่าเกิดอะไรขึ้นโดยไม่ต้องดู implementation ของ Domain หรือ Repository
  2. ไม่มี Business Logic ใน UseCase — ถ้า UseCase มี if ที่เกี่ยวกับ business rule ให้ย้ายไปที่ Core Layer ({UseCase}Rules.cs)
  3. Mapper เป็น pure function เสมอ — ถ้า Mapper ต้องการ I/O แปลว่า logic นั้นอยู่ผิดที่

สองสิ่งที่ควรระวังที่สุด: อย่าให้ Business Logic ไหลลงมาอยู่ใน UseCase และอย่าให้ I/O ไหลเข้าไปอยู่ใน Mapper


คำถามที่พบบ่อย

Q: UseCase กับ {UseCase}Rules.cs ต่างกันอย่างไร?

UseCase อยู่ใน Application Layer ทำหน้าที่ orchestrate — รู้ว่าต้องเรียกอะไรก่อนหลัง แต่ไม่รู้ว่า “อะไรถูก/ผิด”

{UseCase}Rules.cs อยู่ใน Core Layer ทำหน้าที่เป็น pure Business Rules — รู้ว่าอะไรถูก/ผิด แต่ไม่รู้จัก I/O เลย กฎง่ายๆ คือถ้า function ต้องการ await มันต้องอยู่ใน UseCase ถ้าไม่ต้องการ await มันอยู่ใน Core Layer


Q: ถ้า UseCase ต้องการข้อมูลเพิ่มก่อน map ควรทำอย่างไร?

ดึงข้อมูลใน UseCase แล้วส่งให้ Mapper เป็น parameter — ไม่ใช่ให้ Mapper ดึงเอง:

// ✅ ดึงข้อมูลใน UseCase แล้วส่งให้ Mapper
var product = await repo.GetProductAsync(command.ProductId, ct);
var input = CreateOrderMapper.ToCreateOrderInput(command, product);

// ❌ ไม่ควรให้ Mapper รับ Repository เป็น parameter
internal static CreateOrderInput ToCreateOrderInput(
    CreateOrderCommand command,
    IProductRepository repo)  // ← I/O ใน Mapper = ผิด

Q: Mapper ควร return T หรือ Result<T>?

ขึ้นอยู่กับว่า mapping step นั้นสามารถ fail ได้หรือไม่:

กรณีreturn typeเหตุผล
Mapping ธรรมดา — แค่ใส่ค่าใน recordTไม่มีทางพัง
Mapping ที่ต้องสร้าง Value ObjectResult<T>Quantity.Create() / Money.Create() สามารถ fail ได้

ถ้า Mapper return Result<T> — ROP chain รับได้เลยโดยไม่ต้องเปลี่ยน pattern เพราะ Then จัดการ Result<T> อยู่แล้ว


Q: ควรมี Mapper file แยกเสมอหรือเปล่า?

ไม่จำเป็น ถ้า mapping เป็นแค่ new Record(a, b, c) ธรรมดา inline ใน UseCase ได้เลย แยกออกมาก็ต่อเมื่อ mapping มี transformation logic จริงๆ หรือ reuse ข้าม UseCase ใน Feature เดียวกัน การแยกเร็วเกินไปทำให้มีไฟล์เพิ่มโดยไม่ได้ประโยชน์


Q: Command/Query ต่างจาก RuleInput และ RepositoryInput อย่างไร ทำไมต้องมีทั้งหมด?

แต่ละ type มีเจ้าของและ concern ต่างกัน:

Context TypeLayerเนื้อหาตัวอย่าง
{UseCase}CommandApplicationplain primitiveOrderLineItem(string ProductId, int Quantity)
{MethodName}RuleInputCore (Rules)parameter ที่ Rule นั้นต้องการจริงๆ + Create factoryMustBeActiveCustomerRuleInput(bool), MustHaveOrderLinesRuleInput(Order)
{MethodName}InputCore (Interface)plain primitiveOrderLineRow(string ProductId, int Quantity)

💡 Mapper เป็นเจ้าของ Value Type lifecycle

  • ToOrder — สร้าง Order Model (enforce Invariant ทุก field ผ่าน Value Type)
  • ToCreateOrderInput — extract Order Model → plain primitive

Rule และ Repository ไม่รู้จักกันเลย — UseCase เป็นคนกลางที่ orchestrate ทั้งคู่ผ่าน Mapper เสมอ

Mapper คั่นกลางแต่ละช่วงโดยตั้งชื่อตาม Context Type ที่ return:

Command → ToOrder → Order

       CalculateOrderTotalRuleInput(Order, isActive) → Rules → OrderTotal

       ToCreateOrderInput(Order, total) → CreateOrderInput → CreateOrderAsync

       ToCreateOrderResponse → CreateOrderResponse

Q: UniqueId.New() กับ UniqueId.Create() ต่างกันอย่างไร ใช้เมื่อไหร่?

ต่างกันที่ แหล่งที่มาของ ID:

Methodใช้เมื่อตัวอย่าง
UniqueId.New()system สร้าง ID ใหม่CreateOrder — Mapper สร้าง Order ใหม่
UniqueId.Create(string)ID มาจาก client ต้อง validateUpdateOrder, DeleteOrder — Command ส่ง OrderId มา
UniqueId.FromTrustedValue(string)ID มาจาก DB — เชื่อใจแล้วInfrastructure Mapper แปลง entity กลับ
// CreateOrder — system สร้าง ID ใหม่
return new Order(OrderId: UniqueId.New(), ...);

// UpdateOrder — validate ID ที่มาจาก client
var orderId = UniqueId.Create(command.OrderId);
if (orderId.IsFailure) return orderId.Error;

// Infrastructure — ดึงจาก DB
var order = new Order(OrderId: UniqueId.FromTrustedValue(entity.OrderId), ...);

Q: ทำไม Mapper function ต้องตั้งชื่อว่า To{ContextType} ไม่ใช่ ToRuleInput หรือ ToRepositoryInput?

เพราะ ToRuleInput และ ToRepositoryInput ไม่บอกว่าใช้กับ function ไหน — UseCase หนึ่งอาจมีหลาย Rules function และหลาย Repository method ทำให้อ่านแล้วเดาไม่ออกว่า Mapper นี้ pair กับอะไร

To{ContextType} แก้ปัญหานี้เพราะ Context Type ผูกกับ function นั้นโดยตรงอยู่แล้ว:

// ❌ ไม่บอกว่าใช้กับ function ไหน
ToRuleInput(...)         // MustHaveOrderLines? CalculateOrderTotal?
ToRepositoryInput(...)   // CreateOrderAsync? CancelOrderAsync?

// ✅ อ่านแล้วรู้ทันทีว่า pair กับอะไร
ToOrder(...)                      // → Order Model (enforce Invariant)
ToCreateOrderInput(...)           // → CreateOrderInput ของ CreateOrderAsync
ToCancelOrderInput(...)           // → CancelOrderInput ของ CancelOrderAsync

Q: ถ้ามีคนเรียก UseCase นี้โดยไม่ผ่าน HTTP เช่น Background Job, Admin Tool, หรือ Test — rule นี้ยังต้อง enforce ไหม?

คำตอบผลลัพธ์
ใช่Core Layer (Business Rules) + Validator (Fail Fast เสริม)
ไม่Validator เท่านั้น เช่น format หรือ constraint เฉพาะ endpoint นี้
"CustomerId ต้องเป็น GUID format"  → Validator เท่านั้น  (Domain Expert ไม่รู้จัก GUID)
"Lines ต้องไม่ว่าง"                → Core Layer + Validator  (ต้อง enforce ทุก code path)

Q: span name ใน trace มาจากอะไร?

span name = ชื่อ function ที่ถูกเรียกใน step นั้น — InstrumentedResultExtensions อ่านจาก expression tree อัตโนมัติ ไม่ต้องระบุ string เอง นั่นคือเหตุผลที่ 1 step ต้องมี 1 named function เท่านั้น:

.Then(isActive => CreateAndValidateOrder(command, isActive))   → span = "CreateAndValidateOrder"  ✅
.Then(order    => CalculateTotal(order))                       → span = "CalculateTotal"          ✅
.ThenAsync(repoInput => repo.CreateOrderAsync(repoInput, ct))  → span = "CreateOrderAsync"        ✅
.Then(isActive => isActive ? Result.Success(...) : ...)        → span = จับไม่ได้                 ❌

Q: ข้อผิดพลาดที่พบบ่อยที่สุดใน Application Layer คืออะไร?

Business Rules ใน UseCase — เช่นเขียน if (order.Lines.Count == 0) return error ตรงใน UseCase แทนที่จะย้ายไปที่ {UseCase}Rules.cs ใน Core Layer ทำให้ UseCase อ้วนขึ้นเรื่อยๆ และ Business Rules กระจัดกระจายอยู่สองที่

Mapping Logic ที่มี I/O — เช่น Mapper ที่ต้องไปดึง lookup table ก่อน map กฎง่ายๆ คือถ้า Mapper ต้องการ await — logic นั้นอยู่ผิดที่ ให้ดึงข้อมูลใน UseCase แล้วส่งเป็น parameter ให้ Mapper แทน

Mapper ตั้งชื่อตาม layer ไม่ใช่ตาม Context Type — เช่น ToRuleInput, ToRepositoryInput ทำให้อ่านแล้วไม่รู้ว่า function นั้น pair กับ Rules function หรือ Repository method ไหน ควรใช้ To{ContextType} เช่น ToOrder, ToCreateOrderInput เพราะ Context Type ผูกกับ function นั้นโดยตรงอยู่แล้ว


Q: Application Layer เกี่ยวข้องกับ CQRS อย่างไร?

Application Layer คือจุดที่ CQRS แสดงออกมาชัดที่สุด — แต่ละ UseCase ถูก classify เป็น Command (เปลี่ยน state) หรือ Query (อ่านอย่างเดียว) ตั้งแต่ชื่อ folder และชื่อ class ทำให้รู้ทันทีว่า UseCase นั้นมี side effect หรือไม่โดยไม่ต้องอ่านโค้ด และถ้าต้องการ scale ในอนาคต การแยก read/write database connection ก็ทำได้โดยไม่กระทบโครงสร้าง

Supawut Thomas

Supawut Thomas

Software Developer

มีประสบการณ์พัฒนา Software ระดับ Enterprise มากกว่า 10 ปี ผ่านงานจริงหลากหลายโปรเจกต์องค์กร — เชื่อว่าความรู้ที่ดีที่สุดคือความรู้ที่มาจากประสบการณ์จริง และอยากแบ่งปันสิ่งเหล่านั้นให้เพื่อน Developer ทุกคนได้นำไปพัฒนาตัวเองได้ดีขึ้นในทุกๆ วัน