HTTP Client TypeScript: ออกแบบ share-client ให้ Swap ได้โดยไม่แตะ UseCase
Scale-Ready Architecture · Part 6 of 9
HTTP Client TypeScript: ออกแบบ share-client ให้ Swap ได้โดยไม่แตะ UseCase
Scale-Ready Architecture Series สร้าง Modular Monolith บน Nx Monorepo ด้วยหลักการของ Hexagonal Architecture + Clean Architecture แบบ Functional
Part นี้ต่อยอดจากสอง Part ก่อนหน้า:
- Part 2 — share-core — วาง ServiceClient Interface ไว้ที่
shared/service-clientแล้วshare-clientจะมา implement ต่อ- Part 5 — share-data Test Strategy — อธิบาย mock boundary ของ Data Layer ซึ่งใช้หลักการเดียวกับ Testing section ใน Part นี้
ถ้ายังไม่ได้อ่านทั้งสอง แนะนำให้เริ่มจากที่นั่นก่อน
HTTP Client ที่เขียนใหม่ทุกครั้ง — ปัญหาที่ทีมเจอซ้ำ ๆ
ในระบบที่มีหลาย Bounded Context เมื่อ Order BC ต้องเรียก Inventory BC ทีมมักเริ่มด้วยวิธีที่เร็วที่สุด — เขียน axios call ตรงในไฟล์ที่ต้องการ
// share-service/src/order-api/command/place-order/placeOrder.useCase.ts
// ❌ ปัญหาที่ 1 — axios call ฝังใน UseCase โดยตรง
const checkStock = async (sku: string) => {
const res = await axios.get(`${process.env.INVENTORY_URL}/inventory/stock/${sku}`)
return res.data
}
const placeOrder = async (input: PlaceOrderInput) => {
const stock = await checkStock(input.sku)
if (!stock.available) return Err({ code: 'OUT_OF_STOCK' })
// ...
}
ดูเหมือน simple แต่ปัญหาเริ่มสะสม
ปัญหาที่ 1 — Type drift โดยไม่รู้ตัว
ฝั่ง Inventory เปลี่ยน response shape จาก { available: boolean } เป็น { isAvailable: boolean, quantity: number } แต่ไม่มีอะไรบอก Order UseCase ว่าต้องแก้ — TypeScript อ่านไม่ออกเพราะ res.data เป็น any compilation ผ่านปกติ bug ไปปรากฏใน production
ปัญหาที่ 2 — HTTP code ซ้ำกัน 3 ที่
Notification BC และ Report BC ต้องการ checkStock เหมือนกัน แต่แต่ละ BC เขียน axios call ของตัวเองแยก endpoint string ซ้ำ error handling pattern ต่างกัน และทุกครั้งที่ Inventory เปลี่ยน base URL ต้องตามแก้ทีละที่
ปัญหาที่ 3 — test ยาก
UseCase ที่มี axios ฝังอยู่ข้างในต้อง mock axios แบบ global ซึ่งพังได้ง่ายเมื่อ test run ขนานกัน และไม่มีทางรู้ว่า mock ครอบคลุม scenario ที่ถูกต้องไหม เพราะไม่มี contract ที่ชัดเจน
ปัญหาที่ 4 — ย้าย Microservice ได้ยาก
เมื่อถึงเวลาแยก Order BC ออกเป็น service แยก ต้องตามหาทุก axios call ที่ฝังอยู่ใน UseCase แล้วเอาออกมาให้ครบ ไม่มีทางรู้ว่าหาครบหรือยัง
share-client แก้ทั้งสี่ปัญหาด้วยหลักการเดียว — HTTP call อยู่ที่เดียว ใช้ contract เดียวกับที่ UseCase รู้จักอยู่แล้ว และ swap ออกได้ที่ Composition Root โดยไม่แตะ UseCase เลย
share-client คืออะไร
share-client คือ HTTP ServiceClient Implementations — implement ServiceClient Interface จาก share-core/shared/service-client ด้วย HTTP (axios) เพื่อเรียก BC อื่นแบบ Remote
ใน Part 2 เราออกแบบ InventoryServiceClient interface ไว้ใน share-core/shared/service-client แล้ว — share-client คือตัวที่รับหน้าที่ implement interface นั้นด้วย HTTP จริง ๆ
share-core <- ServiceClient Interface (interface เท่านั้น)
|
+-- share-client <- implement interface ด้วย HTTP (axios)
UseCase ใน share-service รู้จักแค่ interface จาก share-core ไม่รู้ว่าเบื้องหลังเป็น HTTP หรืออะไร — นั่นคือจุดที่ทำให้ swap ได้
Project Boundary นี้คืออะไรในภาษา Architecture
ใน Prerequisite 3 — Clean + Hexagonal Architecture อธิบายไว้ว่า Adapter แบ่งเป็นสองประเภทตามทิศทางของ flow
- Primary Adapter (Driving) — drive เข้ามาหา Core เช่น HTTP Handler
- Secondary Adapter (Driven) — ถูก Core drive ออกไป เช่น PrismaRepository, SnsEventPublisher
share-clientคือ Secondary Adapter ประเภทเดียวกับinfrastructure-prisma— ต่างกันแค่ target ที่คุยด้วยinfrastructure-prismaคุยกับ DB ส่วนshare-clientคุยกับ HTTP API ของ BC อื่นและตาม Dependency Rule ของ Clean Architecture — Infrastructure Layer (Secondary Adapter) รู้จัก Domain/Core ได้ แต่ Domain/Core ไม่รู้จัก Infrastructure ดังนั้น
InventoryServiceClientinterface จึงอยู่ในshare-coreไม่ใช่ในshare-client— Domain เป็นเจ้าของ contract เสมอ
Dependencies — depend แค่ share-core เท่านั้น
{
"name": "@system/share-client",
"dependencies": {
"@system/share-core": "*"
},
"peerDependencies": {
"axios": "^1.0.0",
"@inh-lib/common": "*"
}
}
share-client ไม่รู้จัก share-service หรือ share-data เลย ทำให้ Nx build graph ไม่ถูก affect เมื่อ service layer เปลี่ยน เช่น เพิ่ม UseCase ใหม่ใน share-service หรือเปลี่ยน DAF ใน share-data — share-client ไม่ต้อง rebuild เลย
นี่คือ Dependency Rule จาก Prerequisite 3 ในทางปฏิบัติ — Secondary Adapter ชี้เข้าหา Core เท่านั้น ไม่มี dependency ออกไปหา layer อื่น
เมื่อ share-service เปลี่ยน:
share-service ← rebuild ✅
share-data ← ไม่ affect (ไม่ depend share-service)
share-client ← ไม่ affect (ไม่ depend share-service)
share-core ← ไม่ affect (ไม่ depend share-service)
เมื่อ share-core เปลี่ยน (เปลี่ยน ServiceClient Interface):
share-core ← rebuild ✅
share-client ← rebuild ✅ (depend share-core)
share-service ← rebuild ✅ (depend share-core)
share-data ← rebuild ✅ (depend share-core)
Folder Structure — group ตาม Provider BC
หมายเหตุ — Naming Convention ของ Implementation Files
pattern:
{bc}Remote{Library}Client.ts— ชื่อไฟล์บอก transport ได้ทันทีโดยไม่ต้องเปิดดู เช่นinventoryRemoteAxiosClient.tsใช้ axios และinventoryRemoteFetchClient.tsใช้ fetchPart 6 นี้แสดงแค่ axios implementation — ดู fetch implementation ได้ที่ Bonus: Swap HTTP Client Library
src/
└── {bc}-api/ ← group ตาม Provider BC
└── {bc}Remote{Library}Client.ts ← implements Provider contract
ตัวอย่างระบบที่มี 3 BC ที่ถูกเรียกใช้:
src/
├── inventory-api/
│ └── inventoryRemoteAxiosClient.ts ← implements InventoryServiceClient
│
├── payment-api/
│ └── paymentRemoteAxiosClient.ts ← implements PaymentServiceClient
│
└── notification-api/
└── notificationRemoteAxiosClient.ts ← implements NotificationServiceClient
ทำไม group ตาม Provider BC ไม่ใช่ Consumer BC?
Implementation ใน share-client ห่อ API ของ provider ไม่ใช่ของ consumer ใด BC หนึ่ง ถ้า group ตาม consumer จะเกิดสถานการณ์นี้
❌ group ตาม Consumer BC — ซ้ำซ้อน
src/
├── order-bc/
│ └── inventoryRemoteAxiosClient.ts ← Order เรียก Inventory
│
└── notification-bc/
└── inventoryRemoteAxiosClient.ts ← Notification เรียก Inventory (code เดิมทุกบรรทัด!)
group ตาม Provider BC ทำให้ consumer หลาย BC ใช้ implementation เดียวกันได้
✅ group ตาม Provider BC — ใช้ร่วมกันได้
src/
└── inventory-api/
└── inventoryRemoteAxiosClient.ts ← Order ใช้ได้ + Notification ใช้ได้
DataResponse<T> — Contract กลางระหว่าง Provider และ Consumer
Provider ในระบบนี้ wrap response ทุกตัวด้วย DataResponse<T> ก่อนส่งออกเป็น HTTP Response Body — response.data จึงไม่ใช่ StockLevel ตรง ๆ แต่เป็น DataResponse<StockLevel>
Provider (Inventory BC)
Application Layer → Result<StockLevel, BaseFailure>
Presentation Layer → DataResponse<StockLevel> ← HTTP Response Body
share-client
InventoryRemoteAxiosClient → pass-through DataResponse<T> ให้ UseCase
UseCase (Order BC)
รับ DataResponse<StockLevel> → unwrap เอง → ตัดสินใจ map error ตาม business ของ BC
share-client pass-through DataResponse<T> ตรง ๆ ให้ UseCase — ไม่ unwrap เอง กรณีเดียวที่ share-client สร้าง DataResponse ขึ้นมาเองคือเมื่อ axios throw (network/gateway error) ซึ่งไม่มี response.data กลับมา
ดูรายละเอียด DataResponse type definition, resultCode convention และ Frontend usage ได้ที่ Bonus: DataResponse — HTTP Response Contract
HTTP Implementation — ใช้ได้จริง
ServiceClient Interface (ใน share-core — ออกแบบไว้แล้วใน Part 2)
// share-core/src/shared/service-client/inventoryServiceClient.type.ts
import { DataResponse } from '@inh-lib/common'
// ─── InventoryServiceClient ───────────────────────────────────────
export interface InventoryServiceClient {
reserve(items: ReserveInput[]): Promise<DataResponse<ReservationResult>>
release(reservationId: string): Promise<DataResponse<null>>
checkStock(sku: string): Promise<DataResponse<StockLevel>>
hold(amount: Money): Promise<DataResponse<HoldResult>>
}
// ─── reserve ──────────────────────────────────────────────────────
export interface ReserveInput {
sku: string
quantity: number
}
export interface ReservationResult {
reservationId: string
expiresAt: Date
}
// ─── checkStock ───────────────────────────────────────────────────
export interface StockLevel {
sku: string
available: boolean
quantity: number
}
// ─── hold ─────────────────────────────────────────────────────────
export interface Money {
amount: number
currency: string
}
export interface HoldResult {
holdId: string
amount: Money
}
ServiceClient Implementation (InventoryRemoteAxiosClient ใน share-client)
// share-client/src/inventory-api/inventoryRemoteAxiosClient.ts
import type {
InventoryServiceClient,
ReserveInput,
ReservationResult,
StockLevel,
Money,
HoldResult,
} from '@system/share-core/shared/service-client/inventoryServiceClient.type'
import { DataResponse, toBaseFailure } from '@inh-lib/common'
import axios from 'axios'
// ─── 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,
})
// ─── InventoryRemoteAxiosClient ───────────────────────────────────────────
export class InventoryRemoteAxiosClient implements InventoryServiceClient {
constructor(private readonly baseUrl: string) {}
async reserve(items: ReserveInput[]): Promise<DataResponse<ReservationResult>> {
try {
const response = await axios.post<DataResponse<ReservationResult>>(
`${this.baseUrl}/inventory/reserve`,
{ items }
)
return response.data // ✅ pass-through DataResponse จาก provider
} 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)
}
}
async checkStock(sku: string): Promise<DataResponse<StockLevel>> {
try {
const response = await axios.get<DataResponse<StockLevel>>(
`${this.baseUrl}/inventory/stock/${sku}`
)
return response.data
} catch (e) {
const failure = toBaseFailure(e)
return toFailDataResponse(failure.code, failure.message)
}
}
async hold(amount: Money): Promise<DataResponse<HoldResult>> {
try {
const response = await axios.post<DataResponse<HoldResult>>(
`${this.baseUrl}/inventory/hold`,
{ amount }
)
return response.data
} catch (e) {
const failure = toBaseFailure(e)
return toFailDataResponse(failure.code, failure.message)
}
}
}
สามกรณีที่ InventoryRemoteAxiosClient จัดการ (สำหรับ Self-hosted Provider ที่ return DataResponse<T>):
- กรณีที่ 1 — Provider ตอบ success →
response.dataเป็นDataResponse<T>ที่isSuccess: true— pass-through ตรง ๆ - กรณีที่ 2 — Provider ตอบ fail →
response.dataเป็นDataResponse<T>ที่isSuccess: false— pass-through เช่นกัน UseCase เป็นคนตัดสินใจว่าจะ handle ยังไง - กรณีที่ 3 — Network/Gateway error → axios throw ไม่มี
response.data— constructDataResponse<null>ที่isSuccess: falseด้วยtoFailDataResponse
กรณีอื่นที่ไม่ได้ cover ใน Part 6 นี้:
- Third-party Provider (Stripe, LINE API ฯลฯ) — response body ไม่ใช่
DataResponse<T>ต้อง map มาเองก่อน return ดู implementation จริงได้ที่ Bonus: DataResponse — HTTP Response ContractInventoryInternalClient— implementInventoryServiceClientเหมือนกัน แต่เรียก UseCase โดยตรงแทน HTTP และ wrapResult→DataResponse<T>เอง ดูรายละเอียดได้ที่ Part 7: share-service — Domain Logic
กฎที่ต้องถือปฏิบัติ
| กฎ | เหตุผล |
|---|---|
implements {Bc}ServiceClient เสมอ | compiler detect method ขาดหรือ signature ผิดทันที |
| ไม่มี transform data ใด ๆ | transform เกิดที่ UseCase ของ consumer BC — ไม่ใช่ที่ transport layer |
| return provider response ตรง ๆ | consumer เป็นคนแปลงเป็น type ของตัวเองตาม business rule ของ BC นั้น |
depend แค่ share-core | ป้องกัน Nx build cascade — service layer เปลี่ยนไม่กระทบ share-client |
| group ตาม Provider BC | consumer หลาย BC ใช้ implementation เดียวกัน ไม่มี code ซ้ำ |
peerDependencies สำหรับ axios | apps/ เป็นคนติดตั้ง version จริง ป้องกัน bundle ซ้ำ |
✅ ถูก vs ❌ ผิด
// ✅ ถูก — pass-through DataResponse จาก provider ตรง ๆ UseCase เป็นคน unwrap เอง
async checkStock(sku: string): Promise<DataResponse<StockLevel>> {
try {
const response = await axios.get<DataResponse<StockLevel>>(
`${this.baseUrl}/inventory/stock/${sku}`
)
return response.data // ✅ ทั้ง success และ fail pass-through เหมือนกัน
} catch (e) {
const failure = toBaseFailure(e)
return toFailDataResponse(failure.code, failure.message)
}
}
// ❌ ผิด — unwrap DataResponse ใน share-client ก่อน return
// UseCase สูญเสีย resultCode ที่ชัดเจน และตัดสิน business error ไม่ได้เอง
async checkStock(sku: string): Promise<DataResponse<StockLevel>> {
try {
const response = await axios.get<DataResponse<StockLevel>>(
`${this.baseUrl}/inventory/stock/${sku}`
)
if (!response.data.isSuccess) throw new Error(response.data.resultDesc) // ❌
return response.data.dataResult as unknown as DataResponse<StockLevel> // ❌
} catch (e) {
const failure = toBaseFailure(e)
return toFailDataResponse(failure.code, failure.message)
}
}
// ✅ ถูก — implements interface จาก share-core
export class InventoryRemoteAxiosClient implements InventoryServiceClient {
// TypeScript guard ทั้ง method ครบหรือไม่ และ signature ถูกไหม
}
// ❌ ผิด — ไม่ implements — ไม่มี type safety
export class InventoryRemoteAxiosClient {
// TypeScript ไม่รู้ว่า method ขาดหรือ signature ผิด จนกว่าจะ runtime error
}
share-client vs Internal ServiceClient — interface เดียวกัน ใช้ต่างกัน
นี่คือจุดที่ทำให้ Architecture นี้ยืดหยุ่น — share-client และ share-service/service-client implement interface เดียวกัน จาก share-core แต่ต่างกันที่วิธีเรียก
share-client | share-service/service-client | |
|---|---|---|
| วิธีเรียก | HTTP via axios | Call UseCase โดยตรง |
| ใช้เมื่อ | BC อยู่คนละ server (Microservice) | BC อยู่ server เดียวกัน (Monolith) |
| depend | share-core | share-core + share-service |
| Network hop | มี (HTTP) | ไม่มี (in-process) |
| Latency | มี | แทบไม่มี |
UseCase ใน share-service ไม่รู้ว่าตัวไหนถูก inject — มันรู้จักแค่ interface จาก share-core ผ่าน Pick<>
// share-service/src/order-api/command/place-order/endpoint/endpoint.config.ts
import type { InventoryServiceClient } from '@system/share-core/shared/service-client/inventoryServiceClient.type'
import { ResultV2 as Result, BaseFailure, CommonFailures } from '@inh-lib/common'
type Deps = {
inventoryClient: Pick<InventoryServiceClient, 'reserve' | 'checkStock'>
// UseCase รู้แค่ interface — ไม่รู้ว่าเบื้องหลังเป็น HTTP หรือ in-process
}
export const makePlaceOrderEndpoint = (deps: Deps) => async (input: PlaceOrderInput) => {
// UseCase รับ DataResponse<T> แล้ว unwrap เอง — ตัดสินใจ map error ตาม business ของ BC ตัวเอง
const stockResponse = await deps.inventoryClient.checkStock(input.sku)
if (!stockResponse.isSuccess) {
return Result.fail(new CommonFailures.GetFail('OUT_OF_STOCK'))
}
if (!stockResponse.dataResult.available) {
return Result.fail(new CommonFailures.GetFail('OUT_OF_STOCK'))
}
const reservationResponse = await deps.inventoryClient.reserve([
{ sku: input.sku, quantity: input.quantity }
])
if (!reservationResponse.isSuccess) {
return Result.fail(new CommonFailures.CreateFail('RESERVATION_FAILED'))
}
// ...
}
Composition Root เลือก inject อันไหน — แก้บรรทัดเดียว
// apps/api/src/composition-root.ts
import { InventoryRemoteAxiosClient } from '@system/share-client/inventory-api'
import { InventoryInternalClient } from '@system/share-service/inventory-api/service-client'
// ✅ Monolith — inject Internal (in-process, ไม่มี network hop)
const inventoryClient = new InventoryInternalClient(makeGetStockUseCase(deps))
// ✅ Microservice — inject HTTP (เปลี่ยนบรรทัดเดียว UseCase ไม่รู้เลย)
const inventoryClient = new InventoryRemoteAxiosClient(env.INVENTORY_SERVICE_URL)
// inject เข้า UseCase ผ่าน deps — UseCase รู้จักแค่ interface
const placeOrderEndpoint = makePlaceOrderEndpoint({ inventoryClient, orderRepo })
นี่คือ Monolith → Microservice migration path ที่ไม่ต้องแตะ UseCase เลย — แค่เปลี่ยน implementation ที่ inject ใน Composition Root
Monolith → Microservice Migration — ทำอะไรบ้าง
เมื่อถึงเวลาแยก Inventory BC ออกเป็น Microservice มีสี่ขั้นตอน
ขั้นที่ 1 — สร้าง HTTP Server สำหรับ Inventory BC
apps/
├── main-api/ ← app หลักที่มีอยู่แล้ว
└── inventory-api/ ← สร้างใหม่ — HTTP Server ของ Inventory BC
├── package.json deps: { @system/share-service, @system/share-data,
│ @system/share-core, fastify, @prisma/client }
├── composition-root.ts ← wire Inventory UseCase + Repository
└── main.ts ← start HTTP server
apps/inventory-api เป็น Composition Root ของ Inventory BC โดยเฉพาะ — Presentation Layer ที่นี่ทำหน้าที่รับ HTTP Request และ wrap Result → DataResponse<T> ก่อนส่งออก
// apps/inventory-api/src/routes/inventory/checkStock.handler.ts
const handler = async (req: FastifyRequest, reply: FastifyReply) => {
const result = await checkStock(deps, req.params.sku)
const { statusCode, body } = toDataResponse(result, 200, req.headers['x-trace-id'])
return reply.status(statusCode).send(body)
}
ขั้นที่ 2 — เพิ่ม env var ใน app หลัก
# .env ของ apps/main-api
INVENTORY_SERVICE_URL=https://inventory-service.internal
ขั้นที่ 3 — แก้ Composition Root ของ app หลัก — บรรทัดเดียว
// apps/main-api/src/composition-root.ts
// ✅ ก่อน — Monolith (in-process)
import { InventoryInternalClient } from '@system/share-service/inventory-api/service-client'
const inventoryClient = new InventoryInternalClient(makeCheckStockUseCase(deps))
// ✅ หลัง — Microservice (HTTP) — แก้สองบรรทัดนี้เท่านั้น
import { InventoryRemoteAxiosClient } from '@system/share-client/inventory-api'
const inventoryClient = new InventoryRemoteAxiosClient(env.INVENTORY_SERVICE_URL)
// UseCase ไม่แตะเลย — รับ DataResponse<T> เหมือนเดิมทุกอย่าง
const placeOrderEndpoint = makePlaceOrderEndpoint({ inventoryClient, orderRepo })
ขั้นที่ 4 — ลบ InventoryInternalClient (ถ้าไม่มีใครใช้แล้ว)
share-service/src/inventory-api/service-client/
└── inventoryInternalClient.ts ← ลบได้ถ้า app ทุกตัวย้ายไปใช้ RemoteAxiosClient แล้ว
ไม่ต้องย้ายไฟล์ไหนเลย เพราะทุก package ยังอยู่ใน Monorepo เดิม —
share-service,share-data,share-coreของ Inventory BC ยังอยู่ที่เดิม แค่apps/inventory-apiถูก deploy แยก process หรือ container ต่างหาก
สรุป file changes:
| action | file |
|---|---|
| ✅ สร้างใหม่ | apps/inventory-api/ — HTTP Server ของ Inventory BC |
| ✅ แก้ | apps/main-api/composition-root.ts — เปลี่ยน inject 2 บรรทัด |
| ✅ เพิ่ม | apps/main-api/.env — INVENTORY_SERVICE_URL |
| ⚠️ ลบ (optional) | share-service/src/inventory-api/service-client/inventoryInternalClient.ts |
| ❌ ไม่ต้องแตะ | UseCase, Repository, Domain Logic, share-core ทั้งหมด |
Dependency ที่ minimal ของ share-client ทำให้ Nx build graph มีประสิทธิภาพสูง
Dependency Graph (จาก Part 1):
share-core ─→ share-client (share-client depend แค่ core)
share-core ─→ share-service (share-service depend แค่ core)
share-core ─→ share-data (share-data depend แค่ core)
share-client, share-service, share-data ─→ app-{name} (wiring ที่เดียว)
เมื่อ share-service เปลี่ยน (เพิ่ม UseCase ใหม่):
affected: share-service, app-{name}
NOT affected: share-client ✅ (ไม่ depend share-service)
NOT affected: share-data ✅
เมื่อ share-client เปลี่ยน (แก้ HTTP endpoint):
affected: share-client, app-{name}
NOT affected: share-service ✅
NOT affected: share-data ✅
เมื่อ share-core เปลี่ยน (แก้ ServiceClient Interface):
affected: share-core, share-client, share-service, share-data, app-{name}
← ทุก package ที่ depend share-core rebuild หมด เพราะ Nx ไม่รู้ว่าเปลี่ยนส่วนไหน
ถ้า share-client depend share-service ด้วย ทุกครั้งที่เพิ่ม UseCase ใหม่ใน share-service จะ trigger rebuild share-client โดยไม่จำเป็น ใน Monorepo ขนาดกลางที่มี 10+ app การ rebuild ที่ไม่จำเป็นนี้สะสมเป็นเวลาหลายสิบนาทีต่อวัน
Export Strategy — BC Level
share-client expose ที่ BC level — ซ่อน implementation file ทั้งหมด เปิดเผยแค่ class ที่ต้องการ inject
// share-client/src/inventory-api/index.ts
export { InventoryRemoteAxiosClient } from './inventoryRemoteAxiosClient'
// share-client/src/payment-api/index.ts
export { PaymentRemoteAxiosClient } from './paymentRemoteAxiosClient'
// share-client/src/notification-api/index.ts
export { NotificationRemoteAxiosClient } from './notificationRemoteAxiosClient'
ทำไม index.ts ไม่มี
export typeเลย?
share-clientไม่มี public type เป็นของตัวเอง — type ทั้งหมดอยู่ที่share-coreแล้ว ทั้งInventoryServiceClient,ReserveInput,StockLevelและBaseFailure
share-clientรู้จัก type เหล่านี้เพื่อ implement เท่านั้น ไม่ใช่เจ้าของ consumer ที่ต้องการ type ก็ import จากshare-coreโดยตรง ไม่ผ่านshare-client// ✅ type import จาก share-core โดยตรง — share-client ไม่ใช่เจ้าของ type import type { InventoryServiceClient } from '@system/share-core/shared/service-client/inventoryServiceClient.type' // ✅ class inject จาก share-client — share-client เป็นเจ้าของ implementation import { InventoryRemoteAxiosClient } from '@system/share-client/inventory-api'
{
"exports": {
"./inventory-api": {
"import": "./src/inventory-api/index.ts"
},
"./payment-api": {
"import": "./src/payment-api/index.ts"
},
"./notification-api": {
"import": "./src/notification-api/index.ts"
}
}
}
// ✅ ใช้ได้ — import จาก BC level
import { InventoryRemoteAxiosClient } from '@system/share-client/inventory-api'
import { PaymentRemoteAxiosClient } from '@system/share-client/payment-api'
// ❌ ใช้ไม่ได้ — deep import ถูก block โดย exports field
import { InventoryRemoteAxiosClient } from '@system/share-client/inventory-api/inventoryRemoteAxiosClient'
ต่างจาก share-core ที่ expose ที่ action level — share-client expose ที่ BC level เพราะ implementation ทั้ง BC รวมอยู่ในไฟล์เดียว ไม่มี action-level structure
Testing — mock axios, test HTTP call ถูก endpoint/payload
หลักการ mock ของ share-client
share-client เป็น Secondary Adapter (Driven Adapter) ตามหลัก Hexagonal Architecture — boundary ของมันคือ HTTP ดังนั้น mock boundary คือ mock axios ไม่ใช่ mock ที่ระดับอื่น
share-client
└── InventoryRemoteAxiosClient
└── axios ← mock ที่นี่
Test ของ share-client ต้องตรวจสอบสามอย่าง:
- เรียก endpoint ถูก — method, URL, request body/params ถูกต้อง
- pass-through DataResponse ตรง ๆ — ทั้ง success และ fail pass-through เหมือนกัน
- construct DataResponse fail — เมื่อ axios throw ต้อง return
DataResponse<null>ที่isSuccess: false
Setup — mock axios ใน jest
// share-client/src/inventory-api/inventoryRemoteAxiosClient.test.ts
import axios from 'axios'
import { type DataResponse } from '@inh-lib/common'
import { InventoryRemoteAxiosClient } from './inventoryRemoteAxiosClient'
import type { ReserveInput, StockLevel } from '@system/share-core/shared/service-client/inventoryServiceClient.type'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
const BASE_URL = 'https://inventory.internal'
const client = new InventoryRemoteAxiosClient(BASE_URL)
// helper สร้าง DataResponse mock
const mockSuccess = <T>(dataResult: T): DataResponse<T> => ({
statusCode: 200,
isSuccess: true,
resultCode: 'SYS_001',
resultDesc: 'OK',
dataResult,
})
const mockFail = (
resultCode: string,
resultDesc: string,
statusCode: number = 400
): DataResponse<null> => ({
statusCode,
isSuccess: false,
resultCode,
resultDesc,
dataResult: null,
})
afterEach(() => {
jest.clearAllMocks()
})
Test แต่ละ method
describe('InventoryRemoteAxiosClient', () => {
// ─── checkStock ──────────────────────────────────────────────────
describe('checkStock', () => {
it('GET /inventory/stock/:sku — pass-through DataResponse success', async () => {
const stockLevel: StockLevel = { sku: 'SKU-001', available: true, quantity: 50 }
const mockResponse = mockSuccess(stockLevel)
mockedAxios.get.mockResolvedValueOnce({ data: mockResponse })
const result = await client.checkStock('SKU-001')
// ✅ เรียก endpoint ถูก
expect(mockedAxios.get).toHaveBeenCalledWith(
`${BASE_URL}/inventory/stock/SKU-001`
)
// ✅ pass-through DataResponse ตรง ๆ ไม่แปลง
expect(result).toEqual(mockResponse)
expect(result.isSuccess).toBe(true)
expect(result.dataResult).toEqual(stockLevel)
})
it('pass-through DataResponse fail เมื่อ provider ตอบ isSuccess: false', async () => {
const mockResponse = mockFail('INV_001', 'SKU not found', 404)
mockedAxios.get.mockResolvedValueOnce({ data: mockResponse })
const result = await client.checkStock('SKU-001')
// ✅ pass-through DataResponse fail — ไม่ throw ไม่ unwrap
expect(result).toEqual(mockResponse)
expect(result.isSuccess).toBe(false)
expect(result.resultCode).toBe('INV_001')
})
it('construct DataResponse fail เมื่อ axios throw (network error)', async () => {
mockedAxios.get.mockRejectedValueOnce(new Error('Network Error'))
const result = await client.checkStock('SKU-001')
// ✅ construct DataResponse fail ด้วย toFailDataResponse
expect(result.isSuccess).toBe(false)
expect(result.dataResult).toBeNull()
})
})
// ─── reserve ──────────────────────────────────────────────────────
describe('reserve', () => {
it('POST /inventory/reserve พร้อม items ใน body', async () => {
const items: ReserveInput[] = [{ sku: 'SKU-001', quantity: 2 }]
const reservationResult = { reservationId: 'rsv-001', expiresAt: new Date('2025-12-31') }
const mockResponse = mockSuccess(reservationResult)
mockedAxios.post.mockResolvedValueOnce({ data: mockResponse })
const result = await client.reserve(items)
expect(mockedAxios.post).toHaveBeenCalledWith(
`${BASE_URL}/inventory/reserve`,
{ items }
)
expect(result).toEqual(mockResponse)
expect(result.isSuccess).toBe(true)
})
})
// ─── release ──────────────────────────────────────────────────────
describe('release', () => {
it('DELETE /inventory/reservations/:reservationId', async () => {
const mockResponse = mockSuccess(null)
mockedAxios.delete.mockResolvedValueOnce({ data: mockResponse })
const result = await client.release('rsv-001')
expect(mockedAxios.delete).toHaveBeenCalledWith(
`${BASE_URL}/inventory/reservations/rsv-001`
)
expect(result.isSuccess).toBe(true)
})
})
// ─── hold ──────────────────────────────────────────────────────────
describe('hold', () => {
it('POST /inventory/hold พร้อม amount ใน body', async () => {
const amount = { amount: 1000, currency: 'THB' }
const holdResult = { holdId: 'hold-001', amount }
const mockResponse = mockSuccess(holdResult)
mockedAxios.post.mockResolvedValueOnce({ data: mockResponse })
const result = await client.hold(amount)
expect(mockedAxios.post).toHaveBeenCalledWith(
`${BASE_URL}/inventory/hold`,
{ amount }
)
expect(result).toEqual(mockResponse)
expect(result.isSuccess).toBe(true)
})
})
})
สิ่งที่ไม่ test ใน share-client
// ❌ ไม่ test business logic — ไม่ใช่หน้าที่ของ share-client
it('should return OUT_OF_STOCK when available is false', ...) // ← logic นี้อยู่ที่ UseCase
// ❌ ไม่ test retry logic หรือ timeout — เว้นแต่ implement ไว้จริง
// ❌ ไม่ test การแปลง response — เพราะไม่ควรมี transform เลย
share-client test แค่ว่า เรียก HTTP ถูกที่ถูกวิธี และ return สิ่งที่ provider ตอบมาโดยตรง — ไม่มากไม่น้อยกว่านั้น
ใช้ MSW แทน mock axios ได้และดีกว่าในหลายด้าน
MSW (Mock Service Worker) interceptor ทำงานที่ Network level ไม่ใช่ axios level ทำให้มีข้อได้เปรียบที่สำคัญ
- library-agnostic — test เดิมใช้ได้ทันทีถ้าเปลี่ยนจาก axios เป็น fetch หรือ library อื่น ไม่ต้องแก้ test เลย
- สมจริงกว่า — จำลอง HTTP response จริงทั้ง status code, headers และ body ไม่ใช่แค่ mock return value
- setup ครั้งเดียว — นิยาม handler ที่เดียวใช้ได้ทั้ง project ไม่ต้อง setup per-file
Bonus: Swap HTTP Client Library จะแสดง MSW แบบ implement ได้จริง พร้อมเห็นว่า test ไม่เปลี่ยนเลยหลัง swap axios → fetch ซึ่งคือ proof ที่ชัดที่สุดว่า UseCase ไม่รู้จัก HTTP library จริง ๆ
สรุป
share-client แก้ปัญหา HTTP call ซ้ำและ type drift ด้วยสามหลักการ
หนึ่ง — implement ServiceClient Interface ที่นิยามไว้ใน share-core แล้ว TypeScript จะ guard ให้ว่า method ครบและ signature ตรง ถ้า interface เปลี่ยน compiler บอกทันที ไม่ต้องรอ runtime
สอง — group ตาม Provider BC ทำให้ consumer หลาย BC ใช้ implementation เดียวกัน ไม่มี code ซ้ำ แก้ endpoint ครั้งเดียวทุก consumer ได้รับการแก้ไขพร้อมกัน
สาม — depend แค่ share-core ทำให้ share-client ไม่ถูก affect เมื่อ service layer เปลี่ยน Nx build graph สะอาด rebuild เฉพาะที่จำเป็นจริง ๆ
ที่สำคัญที่สุด — UseCase ไม่รู้ว่าตัวเองกำลังเรียก HTTP หรือ in-process เปลี่ยนจาก Monolith เป็น Microservice ได้ที่ Composition Root บรรทัดเดียว
Article ถัดไปใน Series
| Part | หัวข้อ | สรุป |
|---|---|---|
| 1 | ภาพรวมและหลักการออกแบบ | Dependency Graph, Project Types |
| 2 | share-core — Abstraction Layer | contract.type.ts, Provider Contract, Pick<> |
| 3 | Testing Overview | Test Matrix, Mock Strategy, Fixture vs Seed Data |
| 4 | share-data — Data Layer | Action-Based Structure, Entry, Task, DAF |
| 5 | share-data — Test Strategy | Fixtures, Mock Level, Jest config |
| 6 | share-client — HTTP ServiceClient | HTTP Remote Client, implements contract |
| 7 | share-service — Domain Logic | Constraint, Calculation, Transform, Rule |
| 8 | share-service — Orchestrator Pattern | CQRS, routeSteps, endpoint.config |
| 9 | app-{name} — Composition Root | Wiring, Monolith→Microservice, Integration Test |
- Bonus: Swap HTTP Client Library — axios ↔ fetch, MSW, Result<T,E> — ต่อยอดจาก Part 6 โดยตรง อ่านได้อิสระไม่กระทบ series หลัก
- Part 7: share-service — Domain Logic — Constraint, Calculation, Transform และ Rule functions ที่เป็น pure functions ทดสอบได้โดยไม่ต้อง mock อะไรเลย
FAQ
Q: ทำไม share-client ถึง return provider response ตรง ๆ ไม่แปลงให้เลย?
หลักการคือ ความรับผิดชอบอยู่ที่ consumer ไม่ใช่ transport layer
share-client ทำหน้าที่แค่ส่งข้อมูลไปกลับ ไม่ใช่ interpreter ถ้า Order BC ต้องการแปลง StockLevel เป็น AvailabilityCheck นั่นเป็น business decision ของ Order BC ที่ควรอยู่ใน UseCase
การใส่ transform ใน share-client จะทำให้ implementation หนึ่งตัวรับรู้ business rule ของหลาย BC พร้อมกัน ซึ่งทำลาย Bounded Context separation
Q: share-client กับ share-service/service-client ต่างกันอย่างไร และเลือกใช้อันไหนเมื่อไหร่?
ทั้งสอง implement interface เดียวกันจาก share-core แต่ต่างกันที่วิธีเรียก
share-client | share-service/service-client | |
|---|---|---|
| วิธีเรียก | HTTP via axios | Call UseCase โดยตรง |
| ใช้เมื่อ | BC อยู่คนละ server (Microservice) | BC อยู่ server เดียวกัน (Monolith) |
เปลี่ยนได้ที่ Composition Root บรรทัดเดียว — UseCase ไม่รู้เลยว่าใช้แบบไหน
Q: ถ้า Provider เพิ่ม method ใหม่ จะต้องแก้ที่ไหนบ้าง?
แก้สามที่ตามลำดับ:
- เพิ่ม method signature ใน
share-core/shared/service-client/{bc}ServiceClient.type.ts - TypeScript จะ error ที่
share-clientทันที — แก้ให้ implement method ใหม่ - Consumer ที่ต้องการใช้ method ใหม่ เพิ่ม
Pick<>เข้าไปในDepstype ของ UseCase นั้น
Consumer ที่ไม่ได้ใช้ method ใหม่ไม่ต้องแก้เลย
Q: ทำไม error handling ใน share-client ใช้ toBaseFailure แทนการ map HTTP status เป็น specific Failure?
share-client เป็น transport layer — หน้าที่คือส่ง request และรับ response กลับมา ไม่ใช่ตัดสินว่า error นั้น “หมายความว่าอะไร” ในเชิง business
การ map 404 เป็น NotFoundFail หรือ 503 เป็น ServiceUnavailableFail เป็น business decision ที่ UseCase ของ consumer BC ควรเป็นคนตัดสินเอง เพราะ 404 จาก Inventory อาจหมายความต่างกันใน Order BC กับ Notification BC
toBaseFailure จาก @inh-lib/common จัดการ error ทุกรูปแบบให้เป็น InternalFail พร้อม details ไว้ดูใน log — UseCase รับ BaseFailure กลับมาแล้วตัดสินใจเองว่าจะ map เป็น error code อะไรของ BC ตัวเอง
Q: ทำไม response.data ใช้ axios generic type แทน zod validate?
ทำไมใช้ axios generic type:
- provider และ consumer ใช้ contract เดียวกันจาก
share-coreอยู่แล้ว - ถ้า provider เปลี่ยน response shape ต้องแก้ contract — TypeScript จะ error ทั้งฝั่ง provider และ consumer ทันที
- validation เกิดที่ compile time โดยไม่ต้องเพิ่ม runtime overhead
ทำไม zod ไม่เหมาะใน share-client:
- ต้องนิยาม schema คู่ขนานกับ interface ใน
share-coreทุก type — เพิ่มโอกาส out of sync - zod เหมาะกับ third-party API ที่ไม่ได้ควบคุม response shape และไม่มี shared contract
Q: จำเป็นต้อง test error path ใน share-client ไหม?
จำเป็นครับ เพราะ share-client implement error handling เองด้วย toBaseFailure
พฤติกรรมที่ต้อง test:
- เมื่อ axios throw ต้อง return
Result.fail(BaseFailure)ไม่ใช่ throw ขึ้นไปให้ caller จัดการ
สิ่งที่ต้อง assert:
result.isFailureเป็นtrueresult.errorValue()เป็น instance ของBaseFailure- ไม่จำเป็นต้อง assert ว่าเป็น Failure class ใดโดยเฉพาะ เพราะ
toBaseFailureจัดการให้แล้ว