ข้าม​ไป​ยัง​เนื้อหา

REPR (Request-Endpoint-Response)

นิยาม API endpoint ด้วย​สาม​ส่วน: Request, Endpoint, Response

เมื่อ​สร้าง web API ด้วย ASP.NET Core MVC แบบ​ดั้งเดิม เรา​มัก​เริ่ม​จาก Controller 1 class ต่อ resource แล้ว​ยัด action หลาย​ตัว (GET, POST, PUT, DELETE) ไว้​ใน class เดียวกัน เมื่อ project โต controller เหล่า​นี้​จะ​กลาย​เป็น “god class” ที่​มี dependency พ่วง​เยอะ มี action หลาย​สิบ​ตัว​ที่​ไม่​เกี่ยวข้อง​กัน และ share constructor/field กัน​โดย​ไม่​จำเป็น — ทั้ง​ที่​แต่ละ action มัก​ไม่​ได้​ใช้ dependency เดียวกัน​เลย

ปัญหา​อีก​ชั้น​คือ MVC ถูก​ออกแบบ​มา​สำหรับ app UI ที่ render View เป็น HTML ตั้งแต่​แรก ไม่ใช่​สำหรับ API ที่​คืน​ค่า​เป็น JSON การ​เอา MVC มาทำ API จึง​มัก​ต้อง​ผูก ViewModel, model binding, และ action filter ที่​ไม่​ตรง​กับ​ความ​ต้องการ​จริง​ของ endpoint

REPR (Request-Endpoint-Response) ซึ่ง Steve Smith (ผู้​เขียน DevIQ) เป็น​ผู้​บัญญัติ​ชื่อ แก้​ปัญหา​นี้​ด้วย​แนวคิด​ง่าย ๆ: มอง API แต่ละ endpoint เป็น “หน่วย​งาน​เดียว” ที่​ประกอบ​ด้วย​สาม​ส่วน

  1. Request — รูปร่าง​ของ​ข้อมูล input ที่ endpoint ต้องการ
  2. Endpoint — ตรรกะ​ที่ endpoint ทำงาน​เมื่อ​ได้​รับ request (มี method เดียว)
  3. Response — รูปร่าง​ของ​ข้อมูล output ที่ endpoint คืน​กลับ

แต่ละ endpoint กลาย​เป็น class ของ​ตัวเอง ไม่ share กับ endpoint อื่น สอดคล้อง​โดยตรง​กับ Single Responsibility Principle — 1 class หนึ่ง​เหตุผล​ใน​การ​เปลี่ยนแปลง และ​ยัง​จับ​คู่​เป็น​ธรรมชาติ​กับ​แนวคิด Vertical Slice ที่​จัด code ตาม feature แทนที่​จะ​จัด​ตาม layer

REPR ไม่​ต้อง​มี controller กลาง​ที่​รวม action หลาย​ตัว แต่ละ endpoint เป็น class อิสระ​ที่​ผูก Request type กับ Response type ของ​ตัวเอง​ผ่าน generic base class (เช่น​ใน library FastEndpoints)

classDiagram
    class GetOrderRequest {
      +Guid OrderId
    }
    class OrderResponse {
      +Guid Id
      +string Status
      +decimal Total
    }
    class EndpointBase {
      +Configure()
      +HandleAsync(req, ct)
    }
    class GetOrderEndpoint {
      -IOrderRepository repo
      +Configure()
      +HandleAsync(req, ct)
    }
    EndpointBase <|-- GetOrderEndpoint
    GetOrderEndpoint ..> GetOrderRequest
    GetOrderEndpoint ..> OrderResponse

จุด​สำคัญ​ของ​โครงสร้าง​นี้​คือ ไม่มี controller กลาง ที่ endpoint หลาย​ตัว​ต้อง share กัน — GetOrderEndpoint ผูก​กับ GetOrderRequest/OrderResponse ของ​มัน​เอง​เท่านั้น หาก endpoint อื่น​ต้องการ dependency ต่าง​กัน ก็​ประกาศ constructor ของ​ตัวเอง​ได้​อย่าง​อิสระ ไม่​กระทบ endpoint อื่น​เลย

เมื่อ HTTP request เข้า​มา framework (ไม่​ว่า​จะ​เป็น ASP.NET Core MVC ที่​ครอบ​ด้วย convention, Minimal API, หรือ library เฉพาะ​ทาง​อย่าง FastEndpoints) จะ:

  1. Bind ข้อมูล​จาก HTTP body/query/route เข้า​เป็น Request object
  2. (มัก​มี) รัน validation บน Request object ก่อน​เข้า​สู่ business logic
  3. ส่ง Request เข้า​สู่ Endpoint ซึ่ง​มี method เดียว (เช่น HandleAsync) ที่​ทำ business logic ทั้งหมด​ของ endpoint นั้น — เรียก repository, domain service, หรือ​ส่ง​ต่อ Command/Query ผ่าน Mediator ก็ได้
  4. Endpoint สร้าง Response object แล้ว serialize กลับ​เป็น HTTP response
sequenceDiagram
    participant Client
    participant Endpoint as GetOrderEndpoint
    participant Repo as IOrderRepository

    Client->>Endpoint: HTTP GET /orders/123
    Endpoint->>Endpoint: bind + validate GetOrderRequest
    Endpoint->>Repo: GetByIdAsync(orderId)
    Repo-->>Endpoint: Order
    Endpoint->>Endpoint: map เป็น OrderResponse
    Endpoint-->>Client: HTTP 200 + OrderResponse

สังเกต​ว่า​ไม่มี​ขั้นตอน​ไหน​ที่ endpoint นี้​ต้อง​รู้จัก​หรือ share state กับ endpoint อื่น — ต่าง​จาก MVC controller ที่ action หลาย​ตัว​อยู่​ใน class เดียว​และ​มัก share constructor dependency ร่วม​กัน​โดย​ไม่​จำเป็น

ตัวอย่าง​ด้าน​ล่าง​จำลอง​การ​ใช้ REPR แบบ​ที่ library อย่าง FastEndpoints ใช้​จริง โดย​แยก Request, Response และ Endpoint ออก​เป็น​คนละ class และ​มี validation ใน​ตัว

// 1) Request — รูปร่างของ input
public class GetOrderRequest
{
public Guid OrderId { get; set; }
}
// 2) Response — รูปร่างของ output
public class OrderResponse
{
public Guid Id { get; set; }
public string Status { get; set; } = default!;
public decimal Total { get; set; }
}
// 3) Endpoint — ตรรกะทั้งหมดของ endpoint นี้อยู่ใน class เดียว
public class GetOrderEndpoint : Endpoint<GetOrderRequest, OrderResponse>
{
private readonly IOrderRepository _repository;
// dependency เฉพาะของ endpoint นี้เท่านั้น ไม่ share กับ endpoint อื่น
public GetOrderEndpoint(IOrderRepository repository)
{
_repository = repository;
}
public override void Configure()
{
Get("/orders/{OrderId}"); // กำหนด route + HTTP verb ของ endpoint นี้
AllowAnonymous();
}
public override async Task HandleAsync(GetOrderRequest req, CancellationToken ct)
{
var order = await _repository.GetByIdAsync(req.OrderId, ct);
if (order is null)
{
await SendNotFoundAsync(ct);
return;
}
// map domain model เป็น response model โดยตรง ไม่ผ่าน ViewModel กลาง
await SendAsync(new OrderResponse
{
Id = order.Id,
Status = order.Status.ToString(),
Total = order.Total
}, cancellation: ct);
}
}
// endpoint อีกตัวสำหรับ use case อื่น — คนละ class คนละ dependency โดยสิ้นเชิง
public class CreateOrderEndpoint : Endpoint<CreateOrderRequest, CreateOrderResponse>
{
private readonly IOrderService _orderService;
public CreateOrderEndpoint(IOrderService orderService) => _orderService = orderService;
public override void Configure() => Post("/orders");
public override async Task HandleAsync(CreateOrderRequest req, CancellationToken ct)
{
var orderId = await _orderService.CreateAsync(req.CustomerId, req.Items, ct);
await SendCreatedAtAsync<GetOrderEndpoint>(
new { OrderId = orderId },
new CreateOrderResponse { Id = orderId },
cancellation: ct);
}
}

จะ​เห็น​ว่า​แต่ละ endpoint test ได้​อย่าง​อิสระ — สร้าง GetOrderEndpoint ขึ้น​มา​พร้อม mock IOrderRepository แล้ว​เรียก HandleAsync โดยตรง ไม่​ต้อง bootstrap MVC pipeline ทั้ง​ชุด

  • กำลัง​สร้าง web API (ไม่ใช่ app ที่ render HTML view) และ​ต้องการ​โครงสร้าง​ที่​โฟกัส​ที่ endpoint โดยตรง
  • ทีม​ต้องการ​เลิก​ใช้ controller อ้วน​ที่​มี action จำนวน​มาก share constructor เดียวกัน
  • ต้องการ​จัด code ตาม Vertical Slice — แต่ละ feature อยู่​ครบ​ใน​ที่​เดียว (request, validation, handler, response)
  • ต้องการ endpoint ที่ test ง่าย เพราะ​แต่ละ class มี dependency น้อย​และ​ชัดเจน​เฉพาะ​ตัว
  • ใช้​ร่วม​กับ CQRS หรือ Mediator ได้​ดี — Endpoint ทำ​หน้าที่​เป็น​ชั้น​บาง ๆ ที่​ส่ง​ต่อ​ไป​ยัง Command/Query handler
  • app ที่​ยัง​ต้อง render View (HTML) เป็น​หลัก — MVC หรือ Razor Pages ยัง​เหมาะ​กว่า
  • API ขนาด​เล็ก​มาก​ที่​มี endpoint ไม่​กี่​ตัว ซึ่ง​ความ​ซับซ้อน​ของ controller ยัง​ไม่ใช่​ปัญหา การ​เพิ่ม file/class อาจ​ไม่​คุ้ม
  • ทีม​ที่​ยัง​ไม่​คุ้น​กับ​แนวคิด​นี้​และ​ไม่มี​การ​เซ็ต architecture test คุม​วินัย เพราะ​ไม่มี​อะไร​บังคับ​ไม่​ให้ endpoint เติบโต​จน​มี logic เกิน​ความ​รับผิดชอบ​เดิม
  • project ที่​ผูก​กับ tooling หรือ convention ของ MVC controller อยู่​แล้ว​อย่าง​ลึก (เช่น filter, model binding แบบ custom จำนวน​มาก) การ​ย้าย​อาจ​ไม่​คุ้ม​ค่า
ด้านข้อดีข้อ​เสีย
โครงสร้าง codeแต่ละ endpoint แยก​อิสระ อ่าน​ง่าย ตาม SRPจำนวน file/class เพิ่ม​ขึ้น​มาก เมื่อ API มี endpoint จำนวน​มาก
DependencyConstructor ของ​แต่ละ endpoint มี​แค่​สิ่ง​ที่ endpoint นั้น​ใช้​จริงต้อง DI/register หลาย class มาก​ขึ้น
Testabilitytest แยก endpoint ได้​ง่าย ไม่​ต้อง​พ่วง MVC pipeline
Performanceendpoint ที่ implement ผ่าน library อย่าง FastEndpoints มัก​เร็ว​กว่า MVC controller เพราะ bypass filter pipeline ที่​ไม่​ใช้
ความ​เป็น​ผู้ใหญ่​ของ patternแก้​ปัญหา controller อ้วน​ได้​ตรง​จุดยัง​ค่อน​ข้าง​ใหม่ เอกสาร/ตัวอย่าง​ใน​ชุมชน​น้อย​กว่า MVC; ต้อง​มี​วินัย​ทีม (เช่น architecture test) ไม่​ให้ endpoint โต​เกิน​ขอบเขต
Swagger/OpenAPIการ​จัด​กลุ่ม endpoint ใน Swagger UI ต้อง​ตั้ง​ค่า tag เพิ่ม​เอง เพราะ​ไม่มี controller ให้ group ตาม​ธรรมชาติ