DataResponse: ออกแบบ HTTP Response Contract ที่ทุก Client ใช้ได้
Scale-Ready Architecture · Bonus Article
DataResponse: ออกแบบ HTTP Response Contract ที่ทุก Client ใช้ได้
Bonus Article นี้ต่อยอดจาก Series Scale-Ready Architecture
- Part 6 — share-client — ServiceClient implement Provider Contract และ return
DataResponse<T>ให้ UseCase unwrap เอง- Part 7 — share-service Domain Logic — Application Layer return
Result<T, BaseFailure>ที่ Presentation Layer แปลงเป็น DataResponse ก่อนส่งออก- Prerequisite 3 — Clean + Hexagonal Architecture — อธิบาย Application Layer vs Presentation Layer ว่าแต่ละ layer มีหน้าที่ต่างกันอย่างไร
อ่านได้อิสระไม่กระทบ series หลัก แต่ถ้ายังไม่ได้อ่าน Part 6 แนะนำให้อ่านก่อนเพื่อเห็นภาพ flow ทั้งหมด
ปัญหาสองชั้นที่ Consumer ต้องเผชิญ
เมื่อ Backend Service ต้องเรียก Service อื่น หรือ Frontend ต้องเรียก API ปัญหาที่เจอมีสองชั้นเสมอ
ชั้นที่ 1 — HTTP Client แต่ละเจ้า Wrap Response ไม่เหมือนกัน
HTTP client แต่ละเจ้ามี response wrapper ของตัวเองพร้อม field name ที่ไม่ตรงกัน
axios → response.status (number) response.data
fetch → response.status (number) response.ok (boolean) await response.json()
.NET → response.StatusCode (enum) await response.Content.ReadAsStringAsync()
consumer ที่ต้องการรู้ว่า request สำเร็จหรือไม่ต้องรู้ API ของ HTTP client เจ้าที่ใช้อยู่ เปลี่ยน library ทีต้องตามแก้ทุกที่
ชั้นที่ 2 — Service Response Body Format ต่างกัน
เมื่อ unwrap HTTP client wrapper ออกมาแล้ว response body ของแต่ละ service ก็ยังมี shape ต่างกันอีก
// Inventory BC — wrap ใน data object
GET /inventory/stock/:sku
→ { data: { available: true, quantity: 50 } }
// Payment BC — return ตรง ๆ
POST /payment/charge
→ { chargeId: "ch_001", status: "succeeded" }
// Notification BC — มี error field แยก
POST /notification/send
→ { result: { messageId: "msg_001" }, error: null }
consumer ที่ต้องเรียกทั้งสาม service จำเป็นต้องรู้ว่าแต่ละ service เก็บ success ไว้ที่ field ไหน error อ่านได้จากอะไร และ payload อยู่ที่ไหน
// ❌ consumer ต้องรู้ "รายละเอียดภายใน" ของแต่ละ service
const checkStock = async (sku: string) => {
const res = await axios.get(url)
return res.data.data.available // ← รู้ว่า Inventory ซ้อน .data.data
}
const chargePayment = async (amount: number) => {
const res = await axios.post(url, { amount })
if (res.data.error) throw new Error(res.data.error) // ← รู้ว่า Payment ใช้ .error
return res.data // ← ไม่เหมือน Inventory
}
เมื่อ Inventory เปลี่ยน format จาก { data: T } เป็น { payload: T } ทุก consumer ที่เรียก Inventory ต้องตามแก้ ถ้า consumer กระจายอยู่หลาย BC ก็ตามหาได้ไม่ครบ
DataResponse แก้ทั้งสองชั้น
DataResponse<T> เป็น contract กลางที่ทุก service ใช้เหมือนกัน consumer unwrap สองชั้นแล้วได้ shape เดียวกันเสมอ ไม่ว่าจะใช้ HTTP client เจ้าไหนหรือเรียก service ใด
ชั้นที่ 1 — unwrap HTTP client wrapper
axios → response.data → DataResponse<T>
fetch → await response.json() → DataResponse<T>
.NET → response.Content... → DataResponse<T>
ชั้นที่ 2 — ใช้ DataResponse<T> ได้เลย field เดียวกันทุก service
dataResponse.isSuccess ← แทน response.ok หรือ status >= 200
dataResponse.resultCode ← แทนการ parse error จาก body เอง
dataResponse.dataResult ← payload ที่ต้องการ
DataResponse — Type Definition
// จาก @inh-lib/common
export type DataResponse<T> = {
statusCode: number // HTTP Status Code จริงของ response นั้น
isSuccess: boolean // consumer ใช้แยก flow — ไม่ต้อง check HTTP range
traceId?: string // Distributed tracing (optional)
resultCode: 'SYS_001' | string // consumer ใช้ map UI message หรือ handle error
resultDesc: string // อธิบาย resultCode — dev/debug เท่านั้น
dataResult: T | null // Payload — มีได้แม้ isSuccess: false
}
isSuccess — แทน HTTP Status Range ทั้งหมด
HTTP Status Code ในกลุ่ม 2xx มีหลายค่า เช่น 200, 201, 202, 204, 207 consumer ไม่ควรต้องรู้ว่า range ไหนคือ success
// ❌ ต้องรู้ HTTP range — ผิดพลาดได้ง่าย
if (res.statusCode >= 200 && res.statusCode < 300) { ... }
// ✅ ใช้ isSuccess แทน — ชัดเจน ทุก HTTP client ใช้เหมือนกัน
if (dataResponse.isSuccess) { ... }
Backend enforce contract นี้เสมอ
isSuccess: true → resultCode จะไม่มีทางเป็น fail code
isSuccess: false → resultCode จะเป็น fail code เสมอ
resultCode และ resultDesc — คนละ Audience
| Field | ใครใช้ | ใช้ทำอะไร |
|---|---|---|
resultCode | Frontend / Consumer | map หา UI message หรือ handle business error |
resultDesc | Developer | อ่านตอน debug — ไม่ควรแสดงใน UI เด็ดขาด |
resultDesc เป็น developer-facing text เช่น "User id 99 not found" เหมาะสำหรับ log ไม่เหมาะแสดงใน UI เพราะอาจเปิดเผย internal detail และไม่ได้รับการแปล
dataResult — มีค่าได้แม้ isSuccess: false
กรณี batch operation บาง record อาจ fail แต่ service ยังคืนข้อมูลให้ consumer รู้ว่า record ไหน fail และด้วยเหตุใด
{
"statusCode": 207,
"isSuccess": false,
"resultCode": "SYS_002",
"resultDesc": "2 of 5 records failed to update",
"dataResult": {
"successIds": [1, 2, 3],
"failedItems": [
{ "id": 4, "reason": "USR_001" },
{ "id": 5, "reason": "ORD_002" }
]
}
}
ResultCode Convention
System-level Code (SYS_xxx)
ใช้สำหรับกรณี generic ที่ใช้ข้าม service ได้ consumer แสดง message เดียวกันหมดไม่ว่าจะมาจาก service ไหน
SYS_001 → Success (default fallback เมื่อ backend ไม่ระบุ code พิเศษ)
SYS_002 → Partial Fail (batch operation มีบาง record fail)
Service-level Code
ใช้เมื่อ consumer ต้องแสดง message ต่างกันตาม error หรือต้องจัดการ logic ต่างกัน
USR_001 → User service: User not found
USR_002 → User service: Email already exists
USR_003 → User service: Invalid credentials
ORD_001 → Order service: Order not found
ORD_002 → Order service: Insufficient stock
ORD_003 → Order service: Order already cancelled
PAY_001 → Payment service: Payment failed
PAY_002 → Payment service: Invalid card
PAY_003 → Payment service: Insufficient balance
เลือกใช้ Code ระดับไหน
Consumer แสดง message เดียวกันหมดทุก error → ใช้ SYS_xxx ✅
Consumer ต้องแสดง message ต่างกันตาม error → ใช้ service-level code ✅
ถ้า Frontend ทุกหน้าแสดง message เดียวกันว่า “เกิดข้อผิดพลาด” ไม่ต้องสร้าง service-level code ใช้ generic fail code ได้เลย
// Frontend map resultCode → UI message
const RESULT_MESSAGES: Record<string, string> = {
SYS_001: 'สำเร็จ',
SYS_002: 'บางรายการดำเนินการไม่สำเร็จ',
USR_001: 'ไม่พบผู้ใช้งาน กรุณาตรวจสอบอีกครั้ง',
USR_002: 'อีเมลนี้ถูกใช้งานแล้ว',
ORD_002: 'สินค้าในสต็อกไม่เพียงพอ',
PAY_001: 'ชำระเงินไม่สำเร็จ กรุณาลองใหม่',
}
const displayMessage = RESULT_MESSAGES[res.resultCode] ?? 'เกิดข้อผิดพลาด กรุณาลองใหม่'
ฝั่ง Provider — Wrap Result → DataResponse ที่ Presentation Layer
ใน Prerequisite 3 อธิบายว่า Application Layer และ Presentation Layer มีหน้าที่ต่างกัน
Application Layer → return Result<T, BaseFailure> ← รู้จัก Business Error ไม่รู้จัก HTTP
Presentation Layer → แปลงเป็น DataResponse<T> ← HTTP Response Body จริง
UseCase ไม่รู้จัก DataResponse เลย การแปลงเป็น HTTP Response เป็นหน้าที่ของ HTTP Handler ใน Presentation Layer เท่านั้น
Application Layer — Return Result ตรง ๆ
// share-service/src/sales-api/command/place-sales-order/placeSalesOrder.useCase.ts
import type { ResultV2 as Result, BaseFailure } from '@inh-lib/common'
// ✅ UseCase return Result — ไม่รู้จัก DataResponse เลย
export const placeSalesOrder = async (
deps: Deps,
input: PlaceSalesOrderInput
): Promise<Result<SalesOrder, BaseFailure>> => {
const customer = await deps.customerRepo.findById(input.customerId)
if (!customer)
return Result.fail(new CommonFailures.NotFoundFail('ไม่พบข้อมูลลูกค้า'))
const creditResult = validateCreditLimit(customer, input.order)
if (creditResult.isFailure)
return Result.fail(creditResult.errorValue())
const order = SalesOrder.create(input)
if (order.isFailure)
return Result.fail(order.errorValue())
await deps.orderRepo.save(order.getValue())
return Result.ok(order.getValue())
}
Presentation Layer — แปลง Result → DataResponse ที่ HTTP Handler
// app-sales/src/routes/sales-order/placeSalesOrder.handler.ts
import { DataResponse } from '@inh-lib/common'
import type { FastifyRequest, FastifyReply } from 'fastify'
const handler = async (req: FastifyRequest, reply: FastifyReply) => {
const result = await placeSalesOrder(deps, req.body)
if (result.isFailure) {
const failure = result.errorValue()
return reply.status(failure.statusCode).send({
statusCode: failure.statusCode,
isSuccess: false,
traceId: req.headers['x-trace-id'] as string | undefined,
resultCode: failure.code,
resultDesc: failure.message,
dataResult: null,
} satisfies DataResponse<null>)
}
return reply.status(201).send({
statusCode: 201,
isSuccess: true,
traceId: req.headers['x-trace-id'] as string | undefined,
resultCode: 'SYS_001',
resultDesc: 'Created',
dataResult: result.getValue(),
} satisfies DataResponse<SalesOrder>)
}
satisfies DataResponse<T> ให้ TypeScript ตรวจสอบว่า object ครบทุก field และ type ตรง ถ้าขาด field หรือ type ผิด compiler จะ error ทันที
toDataResponse Helper
ถ้า handler หลายตัวต้องแปลง Result → DataResponse ซ้ำ ๆ extract เป็น helper ได้
// app-sales/src/shared/toDataResponse.ts
import { ResultV2 as Result, BaseFailure, DataResponse } from '@inh-lib/common'
export const toDataResponse = <T>(
result: Result<T, BaseFailure>,
successStatusCode: number = 200,
traceId?: string
): { statusCode: number; body: DataResponse<T | null> } => {
if (result.isFailure) {
const failure = result.errorValue()
return {
statusCode: failure.statusCode,
body: {
statusCode: failure.statusCode,
isSuccess: false,
traceId,
resultCode: failure.code,
resultDesc: failure.message,
dataResult: null,
} satisfies DataResponse<null>,
}
}
return {
statusCode: successStatusCode,
body: {
statusCode: successStatusCode,
isSuccess: true,
traceId,
resultCode: 'SYS_001',
resultDesc: 'OK',
dataResult: result.getValue(),
} satisfies DataResponse<T>,
}
}
// ใช้ใน handler
const handler = async (req: FastifyRequest, reply: FastifyReply) => {
const result = await placeSalesOrder(deps, req.body)
const { statusCode, body } = toDataResponse(result, 201, req.headers['x-trace-id'] as string)
return reply.status(statusCode).send(body)
}
ฝั่ง Consumer — ServiceClient Return DataResponse, UseCase Unwrap เอง
ServiceClient Interface — Contract ที่ UseCase เห็น
InventoryServiceClient interface ใน share-core กำหนดว่าทุก method return DataResponse<T> เสมอ
// share-core/src/shared/service-client/inventoryServiceClient.type.ts
interface InventoryServiceClient {
checkStock(sku: string): Promise<DataResponse<StockLevel>>
reserve(items: ReserveInput[]): Promise<DataResponse<ReservationResult>>
release(reservationId: string): Promise<DataResponse<null>>
}
UseCase รับ DataResponse<T> มาแล้ว unwrap เอง ทำให้ UseCase เป็นคนตัดสินใจว่าจะ map error แต่ละแบบเป็น business error อะไรของ BC ตัวเอง
// share-service/src/order-api/command/place-order/placeOrder.useCase.ts
const placeOrder = async (deps: Deps, input: PlaceOrderInput) => {
// ✅ UseCase รับ DataResponse<T> แล้ว unwrap เอง
const stockResponse = await deps.inventoryClient.checkStock(input.sku)
if (!stockResponse.isSuccess) {
// UseCase ตัดสินใจว่า map เป็น error code อะไรของ Order BC
return Result.fail(new CommonFailures.BusinessFail('OUT_OF_STOCK'))
}
const stock = stockResponse.dataResult // ← StockLevel
if (!stock.available) {
return Result.fail(new CommonFailures.BusinessFail('OUT_OF_STOCK'))
}
// ... ต่อ business logic
}
AxiosServiceClient — Map ทุก Scenario ให้เป็น DataResponse
InventoryRemoteAxiosClient ต้อง return DataResponse<T> เสมอ ไม่ว่า HTTP call จะออกมาอย่างไร มีสามกรณีที่ต้องจัดการ
กรณีที่ 1 — Provider เป็น service ของเรา
→ response.data คือ DataResponse<T> อยู่แล้ว → pass through
กรณีที่ 2 — Provider เป็น third-party (Stripe, LINE API ฯลฯ)
→ response.data เป็น shape อื่น → map มาเป็น DataResponse<T> เอง
กรณีที่ 3 — Network / Gateway error (axios throw)
→ ไม่มี response.data → construct DataResponse<null> ที่ isSuccess: false
// share-client/src/inventory-api/inventoryRemoteAxiosClient.ts
import axios from 'axios'
import { DataResponse, toBaseFailure } from '@inh-lib/common'
import type { InventoryServiceClient, StockLevel, ReserveInput, ReservationResult } from '@system/share-core'
// ─── helper — construct DataResponse fail สำหรับ network/gateway error ─────
const toFailDataResponse = (
resultCode: string,
resultDesc: string,
statusCode: number = 500
): DataResponse<null> => ({
statusCode,
isSuccess: false,
resultCode,
resultDesc,
dataResult: null,
})
// ─── implementation ──────────────────────────────────────────────────────────
export class InventoryRemoteAxiosClient implements InventoryServiceClient {
constructor(private readonly baseUrl: string) {}
async checkStock(sku: string): Promise<DataResponse<StockLevel>> {
try {
const response = await axios.get<DataResponse<StockLevel>>(
`${this.baseUrl}/inventory/stock/${sku}`
)
// ✅ กรณีที่ 1 และ 2: pass through DataResponse จาก provider
return response.data
} catch (e) {
// ✅ กรณีที่ 3: network error → construct DataResponse fail
const failure = toBaseFailure(e)
return toFailDataResponse(failure.code, failure.message)
}
}
async reserve(items: ReserveInput[]): Promise<DataResponse<ReservationResult>> {
try {
const response = await axios.post<DataResponse<ReservationResult>>(
`${this.baseUrl}/inventory/reserve`,
{ items }
)
return response.data
} catch (e) {
const failure = toBaseFailure(e)
return toFailDataResponse(failure.code, failure.message)
}
}
async release(reservationId: string): Promise<DataResponse<null>> {
try {
const response = await axios.delete<DataResponse<null>>(
`${this.baseUrl}/inventory/reservations/${reservationId}`
)
return response.data
} catch (e) {
const failure = toBaseFailure(e)
return toFailDataResponse(failure.code, failure.message)
}
}
}
กรณี Third-party Provider
ถ้า provider เป็น third-party ที่ response body ไม่ใช่
DataResponse<T>ต้อง map มาเองก่อน return// StripeRemoteAxiosClient — map Stripe shape → DataResponse<T> async chargeCard(amount: Money): Promise<DataResponse<ChargeResult>> { try { const response = await axios.post<StripeChargeResponse>( 'https://api.stripe.com/v1/charges', { amount: amount.amount, currency: amount.currency } ) // ✅ map Stripe response → DataResponse<T> return { statusCode: 200, isSuccess: true, resultCode: 'SYS_001', resultDesc: 'Charge succeeded', dataResult: { chargeId: response.data.id, status: response.data.status }, } } catch (e) { const failure = toBaseFailure(e) return toFailDataResponse(failure.code, failure.message) } }UseCase ยังเห็น
DataResponse<ChargeResult>เหมือนกันทุก provider ไม่รู้ว่าข้างในใช้ Stripe หรือ Omise
Testing — AxiosServiceClient และ UseCase
Mock Helper สำหรับ DataResponse
// share-client/src/__test__/helpers/mockDataResponse.ts
import { DataResponse } from '@inh-lib/common'
export const mockSuccess = <T>(dataResult: T): DataResponse<T> => ({
statusCode: 200,
isSuccess: true,
resultCode: 'SYS_001',
resultDesc: 'OK',
dataResult,
})
export const mockFail = (
resultCode: string,
resultDesc: string,
statusCode: number = 400
): DataResponse<null> => ({
statusCode,
isSuccess: false,
resultCode,
resultDesc,
dataResult: null,
})
Test AxiosServiceClient — ครบ 3 กรณี
// share-client/src/__test__/inventory-api/inventoryRemoteAxiosClient.spec.ts
import axios from 'axios'
import { InventoryRemoteAxiosClient } from '../../inventory-api/inventoryRemoteAxiosClient'
import { mockSuccess, mockFail } from '../helpers/mockDataResponse'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
const BASE_URL = 'http://inventory-service'
let client: InventoryRemoteAxiosClient
beforeEach(() => {
client = new InventoryRemoteAxiosClient(BASE_URL)
jest.clearAllMocks()
})
describe('checkStock', () => {
// ─── กรณีที่ 1 และ 2: pass through DataResponse จาก provider ─────────────
it('return DataResponse success เมื่อ provider ตอบ isSuccess: true', async () => {
const stockLevel = { sku: 'SKU-001', available: true, quantity: 50 }
mockedAxios.get.mockResolvedValueOnce({ data: mockSuccess(stockLevel) })
const result = await client.checkStock('SKU-001')
expect(mockedAxios.get).toHaveBeenCalledWith(`${BASE_URL}/inventory/stock/SKU-001`)
expect(result.isSuccess).toBe(true)
expect(result.dataResult).toEqual(stockLevel)
})
it('return DataResponse fail เมื่อ provider ตอบ isSuccess: false', async () => {
mockedAxios.get.mockResolvedValueOnce({
data: mockFail('INV_001', 'SKU not found', 404),
})
const result = await client.checkStock('SKU-999')
expect(result.isSuccess).toBe(false)
expect(result.resultCode).toBe('INV_001')
})
// ─── กรณีที่ 3: network/gateway error ────────────────────────────────────
it('return DataResponse fail เมื่อ axios throw (network error)', async () => {
mockedAxios.get.mockRejectedValueOnce(new Error('Network Error'))
const result = await client.checkStock('SKU-001')
expect(result.isSuccess).toBe(false)
expect(result.dataResult).toBeNull()
})
})
describe('reserve', () => {
it('POST /inventory/reserve และ return DataResponse success', async () => {
const items = [{ sku: 'SKU-001', quantity: 2 }]
const reservationResult = { reservationId: 'rsv-001', expiresAt: new Date('2026-12-31') }
mockedAxios.post.mockResolvedValueOnce({ data: mockSuccess(reservationResult) })
const result = await client.reserve(items)
expect(mockedAxios.post).toHaveBeenCalledWith(
`${BASE_URL}/inventory/reserve`,
{ items }
)
expect(result.isSuccess).toBe(true)
expect(result.dataResult).toEqual(reservationResult)
})
it('return DataResponse fail เมื่อ stock ไม่พอ', async () => {
mockedAxios.post.mockResolvedValueOnce({
data: mockFail('ORD_002', 'Insufficient stock for SKU-001', 422),
})
const result = await client.reserve([{ sku: 'SKU-001', quantity: 9999 }])
expect(result.isSuccess).toBe(false)
expect(result.resultCode).toBe('ORD_002')
})
})
Test UseCase — Unwrap DataResponse จาก ServiceClient
UseCase test ใช้ mock ServiceClient ที่ return DataResponse<T> — verify ว่า UseCase unwrap และ map error ตาม business logic ถูกต้อง
// share-service/src/__test__/order-api/placeOrder.useCase.spec.ts
import { mockSuccess, mockFail } from '../helpers/mockDataResponse'
import { placeOrder } from '../../order-api/command/place-order/placeOrder.useCase'
const mockInventoryClient = {
checkStock: jest.fn(),
reserve: jest.fn(),
release: jest.fn(),
}
describe('placeOrder', () => {
it('return Result.ok เมื่อ stock เพียงพอ', async () => {
mockInventoryClient.checkStock.mockResolvedValueOnce(
mockSuccess({ sku: 'SKU-001', available: true, quantity: 50 })
)
const result = await placeOrder(
{ inventoryClient: mockInventoryClient, ...otherDeps },
{ sku: 'SKU-001', quantity: 2 }
)
expect(result.isSuccess).toBe(true)
})
it('return Result.fail OUT_OF_STOCK เมื่อ provider ตอบ fail', async () => {
mockInventoryClient.checkStock.mockResolvedValueOnce(
mockFail('INV_001', 'SKU not found', 404)
)
const result = await placeOrder(
{ inventoryClient: mockInventoryClient, ...otherDeps },
{ sku: 'SKU-999', quantity: 2 }
)
// ✅ UseCase map DataResponse fail → business error ของ Order BC เอง
expect(result.isFailure).toBe(true)
expect(result.errorValue().code).toBe('OUT_OF_STOCK')
})
})
mockSuccess / mockFail helper ใช้ร่วมกันได้ทั้ง backend test และ frontend test เพราะ contract เดียวกัน
Frontend และ Backend ใช้ Contract เดียวกัน
DataResponse<T> type มาจาก @inh-lib/common ซึ่ง frontend และ backend ใช้ร่วมกันได้ ทุก client ทุก platform implement ในแบบเดียวกัน ต่างกันแค่ HTTP library ข้างใน
Backend Service (share-client — Node.js)
InventoryRemoteAxiosClient implements InventoryServiceClient
→ unwrap axios response.data → DataResponse<T> ← UseCase รับต่อ
Frontend Web App
inventoryApiClient.checkStock(sku)
→ unwrap fetch response.json() → DataResponse<T> ← Component/Store รับต่อ
Mobile / .NET Client
InventoryHttpClient.CheckStock(sku)
→ unwrap HttpClient response.Content → DataResponse<T> ← ViewModel รับต่อ
// frontend สร้าง apiClient ของตัวเองด้วย pattern เดียวกับ backend ServiceClient
const inventoryApiClient = {
checkStock: async (sku: string): Promise<DataResponse<StockLevel>> => {
try {
const res = await fetch(`${API_BASE}/inventory/stock/${sku}`)
// ✅ unwrap fetch wrapper → DataResponse<T>
return res.json() as Promise<DataResponse<StockLevel>>
} catch (e) {
return {
statusCode: 500, isSuccess: false,
resultCode: 'SYS_ERR', resultDesc: String(e), dataResult: null,
}
}
}
}
// Component หรือ Store — รับ DataResponse<T> แล้ว unwrap เอง
const stockResponse = await inventoryApiClient.checkStock(sku)
if (stockResponse.isSuccess) {
renderStock(stockResponse.dataResult)
} else {
switch (stockResponse.resultCode) {
case 'SYS_002':
showPartialFail(stockResponse.dataResult)
break
case 'AUTH_001':
redirectToLogin()
break
default:
showErrorMessage(RESULT_MESSAGES[stockResponse.resultCode] ?? 'เกิดข้อผิดพลาด กรุณาลองใหม่')
}
}
DataResponse ในแต่ละ Part ของ Series
| Part | บทบาทของ DataResponse |
|---|---|
| Part 6 — share-client | ServiceClient interface return DataResponse<T> — AxiosServiceClient map HTTP response ให้เป็น DataResponse<T> เสมอ |
| Part 7 — share-service Domain Logic | Application Layer return Result<T, BaseFailure> — UseCase unwrap DataResponse<T> จาก ServiceClient เอง |
| Part 8 — share-service Orchestrator | UseCase orchestrate ผ่าน ServiceClient และ unwrap DataResponse<T> ตาม business logic |
| Part 9 — app-{name} Composition Root | Presentation Layer wrap Result → DataResponse<T> ก่อนส่งออกเป็น HTTP Response |
app-{name} Presentation Layer
→ wrap Result → DataResponse<T> ← HTTP Response ออกไปหา Consumer
AxiosServiceClient (share-client)
→ unwrap HTTP wrapper → DataResponse<T> ← UseCase รับต่อ
→ network error → DataResponse<null> ที่ isSuccess: false
UseCase (share-service)
→ รับ DataResponse<T> จาก ServiceClient
→ unwrap เอง → return Result<T, BaseFailure>
สรุป
DataResponse<T> แก้ปัญหาสองชั้นที่ consumer เผชิญด้วยหลักการเดียวกัน
หนึ่ง — Normalize HTTP client ต่างกัน unwrap HTTP client wrapper ก่อน (axios .data, fetch .json(), .NET .Content) เพื่อให้ได้ DataResponse<T> ที่มี shape เดียวกันเสมอ consumer ไม่ต้องรู้ API ของ HTTP client เจ้าที่ใช้ข้างใน เปลี่ยน library ได้โดยไม่แตะ consumer code
สอง — contract เดียวทุก service และทุก platform backend ServiceClient, frontend apiClient และ mobile client ใช้ DataResponse<T> เหมือนกันทั้งหมด type ร่วมกันได้จาก @inh-lib/common mock helper ก็ใช้ร่วมกันได้ทั้ง backend test และ frontend test
สาม — แยกหน้าที่ชัดเจน isSuccess สำหรับ flow control, resultCode สำหรับ handle error หรือ map UI message, resultDesc สำหรับ dev/debug — ไม่ผสมกัน ไม่มีทาง misuse
สี่ — UseCase เป็นคน unwrap DataResponse เอง ServiceClient return DataResponse<T> เสมอ UseCase ตัดสินใจเองว่าจะ map error แต่ละแบบเป็น business error อะไรของ BC ตัวเอง Presentation Layer เป็นคนแปลง Result เป็น DataResponse ก่อนส่งออก
ถ้าระบบต้องการเปลี่ยน response format ในอนาคต แก้แค่ toDataResponse helper ที่ Presentation Layer และ toFailDataResponse helper ที่ AxiosServiceClient — UseCase ทั้งหมดไม่ต้องแตะเลย
→ Part 6: share-client — HTTP ServiceClient — implementation เต็มของ InventoryRemoteAxiosClient และ ServiceClient interface ที่ return DataResponse<T>
→ Part 7: share-service — Domain Logic — UseCase ที่รับ DataResponse<T> จาก ServiceClient แล้ว unwrap ตาม business logic
FAQ
Q: ทำไมต้องมี isSuccess ทั้งที่มี HTTP Status Code แล้ว?
HTTP client แต่ละเจ้า expose status ด้วย field name และ type ต่างกัน — axios ใช้ response.status เป็น number, fetch มี response.ok เป็น boolean, .NET ใช้ response.StatusCode เป็น enum นอกจากนี้ status code ในกลุ่ม 2xx ยังมีหลายค่าที่ consumer ต้องตีความเอง
isSuccess ใน DataResponse<T> แก้ทั้งสองปัญหา — เป็น boolean ที่ Backend enforce ไว้แล้ว ทุก consumer ทุก platform ใช้ field เดียวกันได้เลย และยังคงอยู่ใน body เมื่อ HTTP Status Code ถูก strip ผ่าน API Gateway
Q: resultCode กับ resultDesc ต่างกันอย่างไร และใครควรใช้อันไหน?
resultCode เป็น machine-readable code ที่ consumer ใช้ map หา UI message หรือ handle business logic เป็น stable identifier ที่ไม่ควรเปลี่ยนบ่อยเพราะ consumer ทุกเจ้า depend อยู่
resultDesc เป็น human-readable text สำหรับ developer debug เช่น "User id 99 not found" ควรแสดงใน log เท่านั้น ไม่ควรแสดงใน UI เพราะไม่ได้รับการแปลและอาจเปิดเผย internal detail
Q: ทำไม dataResult ถึงมีค่าได้แม้ isSuccess: false?
เพราะบาง operation fail บางส่วนไม่ใช่ fail ทั้งหมด ตัวอย่างชัดที่สุดคือ batch operation ที่ status 207 (Multi-Status) — บาง record succeed บาง record fail dataResult เก็บข้อมูลว่า record ไหน fail ด้วยเหตุใด consumer ใช้แสดงให้ user เห็นได้ทันทีโดยไม่ต้องทำ request ซ้ำ
Q: ทำไม UseCase ถึงต้อง unwrap DataResponse เอง ไม่ให้ ServiceClient unwrap ให้?
เพราะ UseCase เป็นเจ้าของ business decision — เมื่อ Inventory ตอบ isSuccess: false ด้วย resultCode: 'INV_001' UseCase ของ Order BC อาจ map เป็น OUT_OF_STOCK แต่ UseCase ของ Notification BC อาจ map เป็น SKIP_NOTIFICATION error เดียวกันแต่ความหมายต่างกันตาม BC
ถ้า ServiceClient unwrap ให้ก่อน UseCase จะสูญเสีย resultCode ที่ชัดเจนและต้องเข้าถึงผ่าน errorValue().details ซึ่ง indirect และ error-prone กว่า
Q: ข้อผิดพลาดที่พบบ่อยเมื่อ implement DataResponse คืออะไร?
ข้อแรก — wrap DataResponse ใน Application Layer แทน Presentation Layer
UseCase return DataResponse<T> แทน Result<T, BaseFailure> ทำให้ UseCase รู้จัก HTTP Response Contract ซึ่งผิด Dependency Rule ของ Clean Architecture
ข้อสอง — แสดง resultDesc ใน UI โดยตรง
resultDesc เป็น developer text ถ้าแสดงใน UI อาจเปิดเผย internal detail และ user เห็น message ที่ไม่ได้รับการแปล ให้ใช้ resultCode map หา message ที่เตรียมไว้แทน
ข้อสาม — AxiosServiceClient return Result<T, BaseFailure> แทน DataResponse<T>
ServiceClient ที่ unwrap เองก่อน return ทำให้ UseCase สูญเสีย resultCode ที่ชัดเจน และ consumer ทุกเจ้าต้องรับ type ที่ต่างกันทั้งที่ควรใช้ contract เดียวกัน
ข้อสี่ — สร้าง service-level code ทุกกรณีโดยไม่จำเป็น
ถ้า consumer แสดง message เดียวกันหมดทุก error ไม่ต้องสร้าง service-level code ใช้ SYS_xxx generic code ได้เลย ลด maintenance burden ของการ maintain code dictionary