Swap HTTP Client Library TypeScript: เปลี่ยน axios เป็น fetch โดยไม่แตะ UseCase

Scale-Ready Architecture · Bonus ของ Part 6

Swap HTTP Client Library TypeScript: เปลี่ยน axios เป็น fetch โดยไม่แตะ UseCase


Bonus นี้ต่อยอดจาก Part 6 — share-client โดยตรง

Part 6 อธิบาย share-client แบบ axios เป็น default และ swap ระดับ ServiceClient (HTTP ↔ Internal) — Bonus นี้ขยาย concept ไปที่ swap ระดับ HTTP Library (axios ↔ fetch) ภายใน share-client เอง

แนะนำให้อ่าน Part 6 ก่อน โดยเฉพาะ section DataResponse<T> และ InventoryRemoteAxiosClient เพราะ Bonus นี้ไม่ repeat เนื้อหาเหล่านั้น


ทำไมต้อง swap HTTP Library — axios ไม่ได้รันได้ทุกที่

ทีมส่วนใหญ่เริ่มต้นด้วย axios เพราะ ergonomic ดี มี interceptor ให้ใช้ทันที และ error handling ตรงไปตรงมา — แต่วันหนึ่งความต้องการเปลี่ยน

Edge Runtime ไม่รองรับ axios Cloudflare Workers, Vercel Edge Functions และ Deno Deploy รัน JavaScript ใน restricted environment ที่ไม่มี Node.js API เต็มรูปแบบ axios depend บน Node.js http module ทำให้ deploy ไม่ได้เลยในหลาย Edge Runtime ส่วน fetch เป็น Web Standard ที่ run ได้ทุกที่ตั้งแต่ browser จนถึง Cloudflare Workers

axios เป็น dependency ที่ต้องติดตั้งเสมอ ขนาด ~13KB (minified+gzipped) ถ้าโปรเจกต์มีหลาย package ที่รัน standalone เช่น Lambda Function หรือ Edge Worker ขนาด bundle มีผลโดยตรงต่อ cold start time fetch เป็น built-in ใน Node.js 18+ ขนาดเพิ่มเป็นศูนย์

Streaming response fetch return ReadableStream native ตาม Web Streams API ใช้งานได้ทุก runtime ที่รองรับ fetch ส่วน axios ทำ streaming ได้ใน Node.js แต่ต้องใช้ config เพิ่มและไม่รองรับใน Edge Runtime


ปัญหาคือ ถ้า HTTP Library ผูกอยู่กับ UseCase การเปลี่ยนจาก axios เป็น fetch หมายความว่าต้องแตะทุกไฟล์ที่มี axios call — UseCase, test file และ mock setup พร้อมกัน

แต่ถ้า share-client ออกแบบถูก เปลี่ยน library ได้โดยไม่แตะ UseCase เลยแม้แต่บรรทัดเดียว


ทำไมถึง swap ได้ — Secondary Adapter boundary

UseCase รู้จักแค่ InventoryServiceClient interface จาก share-core ไม่รู้ว่าเบื้องหลัง implementation ใช้ HTTP library ตัวไหน

share-core
  └── InventoryServiceClient interface   ← UseCase รู้จักแค่นี้
              ↑ implements
share-client
  ├── InventoryRemoteAxiosClient         ← axios implementation
  └── InventoryRemoteFetchClient         ← fetch implementation (เพิ่มใน Bonus นี้)

ใน Prerequisite 3 — Clean + Hexagonal Architecture อธิบายไว้ว่า share-client คือ Secondary Adapter (Driven) — ตาม Dependency Rule ของ Clean Architecture Infrastructure Layer (Secondary Adapter) ชี้เข้าหา Core ได้ แต่ Core ไม่รู้จัก Infrastructure

swap ระดับนี้คือการเปลี่ยน implementation ที่ชั้นนอกสุด โดย Core ไม่ถูกแตะเลย ต่างจาก swap ระดับ ServiceClient (HTTP ↔ Internal) ที่ Part 6 cover แล้ว ซึ่งเป็นการเปลี่ยนว่า UseCase คุยกับ BC อื่นผ่าน HTTP หรือ in-process

Swap ระดับ ServiceClient (Part 6):
  HTTP  InventoryRemoteAxiosClient
      ↕ เปลี่ยนที่ Composition Root
  In-process InventoryInternalClient

Swap ระดับ HTTP Library (Bonus นี้):
  axios  InventoryRemoteAxiosClient
      ↕ เปลี่ยนที่ Composition Root
  fetch  InventoryRemoteFetchClient

ทั้งสอง swap เกิดที่ Composition Root บรรทัดเดียว UseCase ไม่รู้เลยในทั้งสองกรณี


ความต่างที่ต้องรู้ก่อน swap

สิ่งที่ fetch ทำได้ดีกว่า

Streaming Responseresponse.body เป็น ReadableStream ตาม Web Streams API ใช้งานได้ทุก runtime รองรับ SSE และ chunked download โดยไม่ต้อง Node.js stream adapter

AbortController — Web Standard ที่ใช้ได้ทุก runtime axios รองรับ AbortController ตั้งแต่ v0.22+ เช่นกัน แต่ fetch ออกแบบมาให้ใช้คู่กันตั้งแต่แรก

Edge Runtime compatibility — fetch เป็น built-in ใน browser, Node.js 18+, Deno, Bun และ Cloudflare Workers ไม่ต้องติดตั้งอะไรเพิ่ม

Bundle size — 0KB สำหรับ Node.js 18+ เพราะเป็น built-in

สิ่งที่ต้อง implement เพิ่มเมื่อใช้ fetch (axios ทำให้อัตโนมัติ)

เช็ค response.ok เอง — axios throw error อัตโนมัติเมื่อ HTTP status เป็น 4xx หรือ 5xx แต่ fetch ถือว่า “สำเร็จ” เมื่อรับ response กลับมาได้ ไม่ว่า status จะเป็นอะไร response.ok เป็น false เมื่อ status ไม่อยู่ในช่วง 200-299 แต่ไม่ throw

JSON.stringify request body เอง — axios จัดการ serialization ให้อัตโนมัติเมื่อ body เป็น object fetch ต้องทำเองพร้อมกับ set Content-Type: application/json header

Timeout ด้วย AbortController — axios มี { timeout: ms } option พร้อมใช้ fetch ต้อง implement timeout เองด้วย AbortController + setTimeout หรือ Promise.race

ไม่มี interceptor — axios interceptor เป็น feature ที่ใช้บ่อยสำหรับ attach auth header หรือ log request fetch ไม่มี interceptor built-in ต้อง wrap function เอง

response.json() — axios parse JSON response body ให้อัตโนมัติ เข้าถึงได้ผ่าน response.data fetch ต้องเรียก await response.json() เอง

ตารางสรุปเปรียบเทียบ

Featureaxiosfetch
Auto JSON parse response❌ ต้อง .json() เอง
Throw on 4xx/5xx❌ ต้องเช็ค response.ok
Request timeout{ timeout: ms }AbortController + setTimeout
Auto JSON.stringify body❌ ต้องทำเอง
Set Content-Type✅ อัตโนมัติ❌ ต้อง set เอง
Request interceptor
Streaming response⚠️ Node.js only✅ Web Standard
AbortController✅ v0.22+✅ native
Edge Runtime⚠️ ไม่รองรับทุกตัว✅ built-in
Bundle size~13KB0KB
TypeScript generic typeaxios.get<T>()response.json() as T (ไม่มี runtime check)

Baseline — InventoryRemoteAxiosClient

Implementation เต็มมีอยู่แล้วใน Part 6 — ส่วนนี้สรุปจุดสำคัญที่จะเปรียบเทียบกับ InventoryRemoteFetchClient

// 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 — ใช้ร่วมกันทั้ง axios และ fetch ─────────────────────────────────

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) {}

  // axios generic type → response.data เป็น DataResponse<T> เสมอ
  // pass-through ตรง ๆ ไม่ unwrap เอง

  async checkStock(sku: string): Promise<DataResponse<StockLevel>> {
    try {
      const response = await axios.get<DataResponse<StockLevel>>(
        `${this.baseUrl}/inventory/stock/${sku}`
      )
      return response.data  // ✅ pass-through — axios throw เมื่อ 4xx/5xx อัตโนมัติ
    } catch (e) {
      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 }  // ✅ axios serialize JSON + set Content-Type อัตโนมัติ
      )
      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)
    }
  }

  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)
    }
  }
}

จุดสำคัญสามข้อที่ axios จัดการให้อัตโนมัติและ InventoryRemoteFetchClient ต้องทำเอง

  1. throw เมื่อ 4xx/5xxcatch (e) ของ axios จับได้ทั้ง network error และ HTTP error
  2. JSON.stringify body — ส่ง object เข้า axios.post(url, body) ได้เลย
  3. response.data เป็น DataResponse<T> — axios parse JSON และใส่ใน generic type ให้

Swap เป็น fetch — InventoryRemoteFetchClient

package.json ที่ถูกต้องเมื่อต้องการ support ทั้ง axios และ fetch — axios เป็น optional

{
  "name": "@system/share-client",
  "dependencies": {
    "@system/share-core": "*"
  },
  "peerDependencies": {
    "axios": "^1.0.0",
    "@inh-lib/common": "*"
  },
  "peerDependenciesMeta": {
    "axios": {
      "optional": true
    }
  }
}

axios เป็น optional — ถ้าใช้ InventoryRemoteFetchClient อย่างเดียวไม่ต้องติดตั้ง axios เลย fetch เป็น built-in ใน Node.js 18+ จึงไม่ต้องใส่ใน peerDependencies

Node.js version compatibility

  • Node.js 18+fetch built-in ไม่ต้องติดตั้งอะไรเพิ่ม
  • Node.js 16 หรือต่ำกว่า — ต้องใช้ polyfill เช่น node-fetch หรือ undici และต้อง set global.fetch ก่อนใช้
// share-client/src/inventory-api/inventoryRemoteFetchClient.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'

// ─── helper — เหมือนกับใน InventoryRemoteAxiosClient ──────────────────────────
// ย้ายออกมาเป็น shared utility ได้ถ้าต้องการ reuse ข้ามทั้งสอง implementation

const toFailDataResponse = (
  resultCode: string,
  resultDesc: string,
  statusCode: number = 500
): DataResponse<null> => ({
  statusCode,
  isSuccess: false,
  resultCode,
  resultDesc,
  dataResult: null,
})

// ─── InventoryRemoteFetchClient ───────────────────────────────────────────────

export class InventoryRemoteFetchClient implements InventoryServiceClient {
  constructor(
    private readonly baseUrl: string,
    private readonly timeoutMs: number = 5000
  ) {}

  // ─── safeFetch — ชดเชย feature ที่ axios ทำให้อัตโนมัติ ────────────────────
  //
  // fetch ไม่ throw เมื่อ 4xx/5xx และไม่มี timeout built-in
  // safeFetch ห่อ logic เหล่านี้ไว้ที่เดียว ทุก method ใช้ safeFetch ได้ทันที

  private async safeFetch(url: string, options: RequestInit = {}): Promise<Response> {
    const controller = new AbortController()

    // timeout ด้วย AbortController + setTimeout
    const timeoutId = setTimeout(() => {
      controller.abort()
    }, this.timeoutMs)

    try {
      const response = await fetch(url, {
        ...options,
        signal: controller.signal,
        headers: {
          'Content-Type': 'application/json',  // ✅ ต้อง set เอง — axios ทำให้อัตโนมัติ
          ...options.headers,
        },
      })

      // ✅ เช็ค response.ok เอง — axios throw เมื่อ 4xx/5xx fetch ไม่ throw
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`)
      }

      return response
    } finally {
      clearTimeout(timeoutId)
    }
  }

  // ─── checkStock ─────────────────────────────────────────────────────────────

  async checkStock(sku: string): Promise<DataResponse<StockLevel>> {
    try {
      const response = await this.safeFetch(
        `${this.baseUrl}/inventory/stock/${sku}`
        // GET ไม่มี body — ไม่ต้อง JSON.stringify
      )
      // ✅ ต้อง await response.json() เอง — axios parse ให้อัตโนมัติ
      return response.json() as Promise<DataResponse<StockLevel>>
    } catch (e) {
      const failure = toBaseFailure(e)
      return toFailDataResponse(failure.code, failure.message)
    }
  }

  // ─── reserve ─────────────────────────────────────────────────────────────────

  async reserve(items: ReserveInput[]): Promise<DataResponse<ReservationResult>> {
    try {
      const response = await this.safeFetch(
        `${this.baseUrl}/inventory/reserve`,
        {
          method: 'POST',
          body: JSON.stringify({ items }),  // ✅ ต้อง serialize เอง — axios ทำให้อัตโนมัติ
        }
      )
      return response.json() as Promise<DataResponse<ReservationResult>>
    } catch (e) {
      const failure = toBaseFailure(e)
      return toFailDataResponse(failure.code, failure.message)
    }
  }

  // ─── release ─────────────────────────────────────────────────────────────────

  async release(reservationId: string): Promise<DataResponse<null>> {
    try {
      const response = await this.safeFetch(
        `${this.baseUrl}/inventory/reservations/${reservationId}`,
        { method: 'DELETE' }
      )
      return response.json() as Promise<DataResponse<null>>
    } catch (e) {
      const failure = toBaseFailure(e)
      return toFailDataResponse(failure.code, failure.message)
    }
  }

  // ─── hold ──────────────────────────────────────────────────────────────────

  async hold(amount: Money): Promise<DataResponse<HoldResult>> {
    try {
      const response = await this.safeFetch(
        `${this.baseUrl}/inventory/hold`,
        {
          method: 'POST',
          body: JSON.stringify({ amount }),
        }
      )
      return response.json() as Promise<DataResponse<HoldResult>>
    } catch (e) {
      const failure = toBaseFailure(e)
      return toFailDataResponse(failure.code, failure.message)
    }
  }
}

สิ่งที่ safeFetch เพิ่มเข้ามาเพื่อชดเชย axios:

สิ่งที่ขาดวิธีชดเชย
Auto throw on 4xx/5xxเช็ค response.ok แล้ว throw เอง
Timeout optionAbortController + setTimeout
Set Content-Typeใส่ใน default headers ของ safeFetch
JSON parse responseเรียก response.json() ทุก method
JSON serialize bodyJSON.stringify() ก่อนส่ง

DataResponse pass-through vs throw approach

Bonus นี้เลือก pass-through DataResponse<T> ตรง ๆ โดยไม่ throw — เปรียบเทียบให้เห็นว่าต่างกันอย่างไร

// ❌ throw approach — caller ต้อง try/catch เอง ไม่มี contract ชัดเจน
async checkStock(sku: string): Promise<StockLevel> {
  const response = await fetch(`${this.baseUrl}/inventory/stock/${sku}`)
  if (!response.ok) throw new Error(`HTTP ${response.status}`)
  const data = await response.json()
  if (!data.isSuccess) throw new Error(data.resultDesc)
  return data.dataResult  // ← unwrap ใน share-client ก่อน return
}

// UseCase ต้อง try/catch ทุกที่ — ไม่รู้ล่วงหน้าว่าจะ throw เมื่อไหร่
const placeOrder = async (input: PlaceOrderInput) => {
  try {
    const stock = await deps.inventoryClient.checkStock(input.sku)  // อาจ throw
    if (!stock.available) return Result.fail(...)
  } catch (e) {
    // ต้องจัดการ exception ที่อาจมาจากหลายสาเหตุ: network, timeout, 4xx, 5xx
  }
}
// ✅ DataResponse pass-through — caller รู้ล่วงหน้าว่าจะได้ DataResponse<T>
//    ไม่มี exception ที่ไม่คาดคิด UseCase จัดการ isSuccess เอง
async checkStock(sku: string): Promise<DataResponse<StockLevel>> {
  try {
    const response = await this.safeFetch(`${this.baseUrl}/inventory/stock/${sku}`)
    return response.json() as Promise<DataResponse<StockLevel>>
    // ✅ pass-through ตรง ๆ — ไม่ unwrap ไม่ throw
  } catch (e) {
    // network error หรือ timeout — construct DataResponse fail แทน throw
    const failure = toBaseFailure(e)
    return toFailDataResponse(failure.code, failure.message)
  }
}

// UseCase รับ DataResponse<T> — signature บอกล่วงหน้าว่าจะได้อะไร
const placeOrder = async (input: PlaceOrderInput) => {
  const stockResponse = await deps.inventoryClient.checkStock(input.sku)
  if (!stockResponse.isSuccess) {
    // map error ตาม business ของ BC ตัวเอง — ไม่ใช่แค่ re-throw
    return Result.fail(new CommonFailures.GetFail('INVENTORY_UNAVAILABLE'))
  }
  if (!stockResponse.dataResult.available) {
    return Result.fail(new CommonFailures.GetFail('OUT_OF_STOCK'))
  }
  // ...
}

Series นี้เลือก DataResponse pass-through เพราะสามเหตุผล

Contract ชัดเจนPromise<DataResponse<T>> บอก caller ล่วงหน้าทุกกรณีที่เป็นไปได้ ไม่มี hidden exception ที่ต้องจำ

UseCase ตัดสินใจ error ด้วยตัวเองresultCode จาก provider เดิมไปถึง UseCase โดยไม่ถูกแปลงระหว่างทาง UseCase ของแต่ละ BC map error ตาม business context ของตัวเองได้

Network error กับ Business error ใช้ shape เดียวกัน — ไม่ว่าจะ timeout หรือ provider ตอบ 404 UseCase รับ DataResponse<null> ที่ isSuccess: false เหมือนกัน ไม่ต้องจัดการสองแบบ


Streaming — กรณีที่ fetch เหนือกว่าชัดเจน

fetch return response.body เป็น ReadableStream ตาม Web Streams API ใช้ได้ทุก runtime ที่รองรับ fetch — ตั้งแต่ browser จนถึง Cloudflare Workers

InventoryRemoteFetchClient extend ได้ด้วย method นอก interface หลักสำหรับ streaming use case โดยไม่กระทบ UseCase ที่ใช้ interface หลักอยู่

// ตัวอย่าง: streaming stock update ด้วย SSE (Server-Sent Events)
// method นี้อยู่นอก InventoryServiceClient interface — เป็น fetch-specific feature

export class InventoryRemoteFetchClient implements InventoryServiceClient {
  // ... interface methods เหมือนเดิม

  // ─── streamStockUpdates — fetch-only feature ────────────────────────────────
  // ใช้สำหรับ subscribe SSE stream จาก Inventory BC
  // axios ทำได้ใน Node.js แต่ไม่ได้ใน Edge Runtime

  async *streamStockUpdates(sku: string): AsyncGenerator<StockLevel> {
    const controller = new AbortController()

    const response = await fetch(
      `${this.baseUrl}/inventory/stock/${sku}/stream`,
      {
        signal: controller.signal,
        headers: { Accept: 'text/event-stream' },
      }
    )

    if (!response.ok || !response.body) {
      throw new Error(`Stream failed: HTTP ${response.status}`)
    }

    const reader = response.body
      .pipeThrough(new TextDecoderStream())
      .getReader()

    try {
      while (true) {
        const { done, value } = await reader.read()
        if (done) break

        // parse SSE format: "data: {...}\n\n"
        const lines = value.split('\n')
        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const payload = JSON.parse(line.slice(6)) as StockLevel
            yield payload
          }
        }
      }
    } finally {
      reader.releaseLock()
      controller.abort()
    }
  }
}
// ตัวอย่าง: download large export file แบบ streaming
// ไม่ต้อง buffer ทั้งไฟล์ไว้ใน memory ก่อน

  async downloadStockReport(
    outputPath: string
  ): Promise<void> {
    const { createWriteStream } = await import('fs')
    const { Writable } = await import('stream')

    const response = await fetch(`${this.baseUrl}/inventory/stock/export`)
    if (!response.ok || !response.body) {
      throw new Error(`Download failed: HTTP ${response.status}`)
    }

    const fileStream = createWriteStream(outputPath)
    const writable = Writable.toWeb(fileStream)

    // pipe ReadableStream → WritableStream โดยตรง ไม่ buffer ใน memory
    await response.body.pipeTo(writable)
  }

ทำไม axios ทำยากกว่าสำหรับ Streaming:

axios ทำ streaming ได้ใน Node.js ผ่าน responseType: 'stream' และ onDownloadProgress แต่ใช้ Node.js IncomingMessage stream ซึ่งไม่ใช่ Web Streams API ทำให้ใช้ไม่ได้ใน Edge Runtime และ code ที่เขียนสำหรับ Node.js ไม่ได้ port ไป Edge ได้โดยตรง


Testing — mock ต่างกันยังไง

mock axios

// share-client/src/inventory-api/__tests__/inventoryRemoteAxiosClient.spec.ts

import axios from 'axios'
import { InventoryRemoteAxiosClient } from '../inventoryRemoteAxiosClient'
import type { DataResponse } from '@inh-lib/common'
import type { StockLevel } from '@system/share-core/shared/service-client/inventoryServiceClient.type'

jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>

const BASE_URL = 'http://inventory-service'

// helper สร้าง DataResponse mock
const mockSuccess = <T>(dataResult: T): DataResponse<T> => ({
  statusCode: 200,
  isSuccess: true,
  resultCode: 'SYS_001',
  resultDesc: 'OK',
  dataResult,
})

describe('InventoryRemoteAxiosClient', () => {
  let client: InventoryRemoteAxiosClient

  beforeEach(() => {
    client = new InventoryRemoteAxiosClient(BASE_URL)
    jest.clearAllMocks()
  })

  // ─── checkStock happy path ───────────────────────────────────────────

  describe('checkStock', () => {
    it('GET /inventory/stock/:sku แล้ว pass-through DataResponse ตรง ๆ', 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')

      expect(mockedAxios.get).toHaveBeenCalledWith(`${BASE_URL}/inventory/stock/SKU-001`)
      expect(result).toEqual(mockResponse)  // ✅ pass-through — result คือ DataResponse<T> ตรง ๆ
      expect(result.isSuccess).toBe(true)
      expect(result.dataResult).toEqual(stockLevel)
    })

    // ─── 4xx/5xx — axios throw เมื่อ provider ตอบ error status ──────────

    it('construct DataResponse fail เมื่อ axios throw (4xx/5xx)', async () => {
      // axios throw อัตโนมัติเมื่อ response status เป็น 4xx/5xx
      mockedAxios.get.mockRejectedValueOnce(
        Object.assign(new Error('Request failed with status code 404'), {
          response: { status: 404, data: { message: 'Not Found' } }
        })
      )

      const result = await client.checkStock('UNKNOWN-SKU')

      // ✅ ไม่ throw ออกไป — construct DataResponse fail แทน
      expect(result.isSuccess).toBe(false)
      expect(result.dataResult).toBeNull()
    })

    // ─── network/timeout error ────────────────────────────────────────

    it('construct DataResponse fail เมื่อ 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()
    })
  })

  // ─── reserve ──────────────────────────────────────────────────────────

  describe('reserve', () => {
    it('POST /inventory/reserve พร้อม items ใน body', async () => {
      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([{ sku: 'SKU-001', quantity: 2 }])

      expect(mockedAxios.post).toHaveBeenCalledWith(
        `${BASE_URL}/inventory/reserve`,
        { items: [{ sku: 'SKU-001', quantity: 2 }] }
      )
      expect(result.isSuccess).toBe(true)
    })
  })
})

mock fetch

// share-client/src/inventory-api/__tests__/inventoryRemoteFetchClient.spec.ts

import { InventoryRemoteFetchClient } from '../inventoryRemoteFetchClient'
import type { DataResponse } from '@inh-lib/common'
import type { StockLevel } from '@system/share-core/shared/service-client/inventoryServiceClient.type'

// mock global.fetch — ไม่ต้อง jest.mock() module ใด
global.fetch = jest.fn()
const mockedFetch = fetch as jest.MockedFunction<typeof fetch>

const BASE_URL = 'http://inventory-service'

const mockSuccess = <T>(dataResult: T): DataResponse<T> => ({
  statusCode: 200,
  isSuccess: true,
  resultCode: 'SYS_001',
  resultDesc: 'OK',
  dataResult,
})

// helper สร้าง mock Response object
const createMockResponse = (data: unknown, status = 200): Response => ({
  ok: status >= 200 && status < 300,
  status,
  statusText: status === 200 ? 'OK' : 'Error',
  json: () => Promise.resolve(data),
} as unknown as Response)

describe('InventoryRemoteFetchClient', () => {
  let client: InventoryRemoteFetchClient

  beforeEach(() => {
    client = new InventoryRemoteFetchClient(BASE_URL, 5000)
    jest.clearAllMocks()
  })

  // ─── checkStock happy path ────────────────────────────────────────────

  describe('checkStock', () => {
    it('GET /inventory/stock/:sku แล้ว pass-through DataResponse ตรง ๆ', async () => {
      const stockLevel: StockLevel = { sku: 'SKU-001', available: true, quantity: 50 }
      const mockResponse = mockSuccess(stockLevel)

      mockedFetch.mockResolvedValueOnce(createMockResponse(mockResponse))

      const result = await client.checkStock('SKU-001')

      expect(mockedFetch).toHaveBeenCalledWith(
        `${BASE_URL}/inventory/stock/SKU-001`,
        expect.objectContaining({
          headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
        })
      )
      expect(result).toEqual(mockResponse)
      expect(result.isSuccess).toBe(true)
    })

    // ─── 4xx/5xx — fetch ไม่ throw อัตโนมัติ ต้องเช็ค response.ok ─────────────
    // ต่างจาก axios ที่ throw อัตโนมัติ ต้อง mock ให้ ok: false

    it('construct DataResponse fail เมื่อ server ตอบ 404 (response.ok: false)', async () => {
      // ✅ fetch mock — status 404 แต่ไม่ throw เอง (ต่างจาก axios)
      // safeFetch เช็ค response.ok แล้ว throw ให้ catch จับ
      mockedFetch.mockResolvedValueOnce(createMockResponse({ message: 'Not Found' }, 404))

      const result = await client.checkStock('UNKNOWN-SKU')

      expect(result.isSuccess).toBe(false)
      expect(result.dataResult).toBeNull()
    })

    // ─── network error ────────────────────────────────────────────────

    it('construct DataResponse fail เมื่อ network error', async () => {
      mockedFetch.mockRejectedValueOnce(new Error('Network Error'))

      const result = await client.checkStock('SKU-001')

      expect(result.isSuccess).toBe(false)
      expect(result.dataResult).toBeNull()
    })

    // ─── timeout ────────────────────────────────────────────────────────

    it('construct DataResponse fail เมื่อ timeout', async () => {
      jest.useFakeTimers()

      // fetch ไม่ resolve ทันที — simulates slow response
      mockedFetch.mockImplementationOnce(
        () => new Promise<Response>((resolve) => setTimeout(resolve, 10_000))
      )

      const clientWithShortTimeout = new InventoryRemoteFetchClient(BASE_URL, 100)
      const resultPromise = clientWithShortTimeout.checkStock('SKU-001')

      // advance timer ให้เกิน timeout
      jest.advanceTimersByTime(200)

      const result = await resultPromise

      expect(result.isSuccess).toBe(false)
      expect(result.dataResult).toBeNull()

      jest.useRealTimers()
    })
  })

  // ─── reserve ──────────────────────────────────────────────────────────

  describe('reserve', () => {
    it('POST /inventory/reserve พร้อม JSON body และ Content-Type header', async () => {
      const reservationResult = { reservationId: 'rsv-001', expiresAt: new Date('2025-12-31') }
      const mockResponse = mockSuccess(reservationResult)

      mockedFetch.mockResolvedValueOnce(createMockResponse(mockResponse))

      const items = [{ sku: 'SKU-001', quantity: 2 }]
      const result = await client.reserve(items)

      // ✅ ตรวจสอบว่า JSON.stringify body และ set Content-Type ถูกต้อง
      expect(mockedFetch).toHaveBeenCalledWith(
        `${BASE_URL}/inventory/reserve`,
        expect.objectContaining({
          method: 'POST',
          body: JSON.stringify({ items }),
          headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
        })
      )
      expect(result.isSuccess).toBe(true)
    })
  })
})

ความต่างสำคัญของ mock strategy ระหว่างสองตัว

InventoryRemoteAxiosClientInventoryRemoteFetchClient
mock ที่ไหนjest.mock('axios') + axios as jest.Mocked<...>global.fetch = jest.fn()
4xx/5xx testmockRejectedValueOnce — axios throw อัตโนมัติmockResolvedValueOnce ที่ ok: false — fetch ไม่ throw
timeout testjest.useFakeTimers() + mockImplementationOnce ที่ delayเหมือนกัน
ตรวจ JSON bodyดูจาก call argument ตรง ๆดูจาก body: JSON.stringify(...) ใน call argument

ทางเลือกที่ดีกว่า — MSW (Mock Service Worker)

MSW interceptor ทำงานที่ Network level ไม่ใช่ axios หรือ fetch level ทำให้ test เดิมใช้ได้ทันทีถ้าเปลี่ยน HTTP library โดยไม่ต้องแก้ test แม้แต่บรรทัดเดียว — นี่คือ proof ที่ชัดที่สุดว่า UseCase ไม่รู้จัก HTTP library จริง ๆ

// msw/handlers.ts — นิยาม handler ครั้งเดียว ใช้ได้กับทั้ง axios และ fetch

import { http, HttpResponse } from 'msw'
import type { DataResponse } from '@inh-lib/common'
import type { StockLevel } from '@system/share-core/shared/service-client/inventoryServiceClient.type'

const mockSuccess = <T>(dataResult: T): DataResponse<T> => ({
  statusCode: 200,
  isSuccess: true,
  resultCode: 'SYS_001',
  resultDesc: 'OK',
  dataResult,
})

export const inventoryHandlers = [
  http.get('http://inventory-service/inventory/stock/:sku', ({ params }) => {
    const stockLevel: StockLevel = {
      sku: params.sku as string,
      available: true,
      quantity: 50,
    }
    return HttpResponse.json(mockSuccess(stockLevel))
  }),

  http.post('http://inventory-service/inventory/reserve', async ({ request }) => {
    const body = await request.json() as { items: Array<{ sku: string; quantity: number }> }
    return HttpResponse.json(mockSuccess({
      reservationId: 'rsv-001',
      expiresAt: new Date('2025-12-31'),
    }))
  }),
]
// test ด้วย MSW — library-agnostic อย่างแท้จริง

import { setupServer } from 'msw/node'
import { inventoryHandlers } from '../../msw/handlers'
import { InventoryRemoteAxiosClient } from '../inventoryRemoteAxiosClient'
import { InventoryRemoteFetchClient } from '../inventoryRemoteFetchClient'

const server = setupServer(...inventoryHandlers)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

// test เดียวกัน ใช้ได้กับทั้งสอง implementation โดยไม่แก้อะไร
describe.each([
  ['InventoryRemoteAxiosClient', () => new InventoryRemoteAxiosClient('http://inventory-service')],
  ['InventoryRemoteFetchClient', () => new InventoryRemoteFetchClient('http://inventory-service')],
])('%s', (name, createClient) => {
  it('checkStock returns DataResponse pass-through', async () => {
    const client = createClient()
    const result = await client.checkStock('SKU-001')

    expect(result.isSuccess).toBe(true)
    expect(result.dataResult?.sku).toBe('SKU-001')
    expect(result.dataResult?.available).toBe(true)
  })
})

ถ้า test ผ่านกับทั้ง InventoryRemoteAxiosClient และ InventoryRemoteFetchClient โดยไม่ต้องแก้ test เลย — นั่นคือหลักฐานว่า swap ทำงานจริง


Decision Guide — เลือก axios หรือ fetch

Criteriaเลือก axiosเลือก fetch
Node.js backend ปกติ
Edge Runtime (Cloudflare Workers, Vercel Edge)
ต้องการ Streaming (SSE, chunked download)⚠️ Node.js เท่านั้น
ต้องการ request interceptor❌ ต้อง wrap function เอง
ลด bundle size / cold start✅ (0KB สำหรับ Node.js 18+)
ทีมคุ้นเคยและ codebase มี axios อยู่แล้ว
Node.js 16 หรือต่ำกว่า⚠️ ต้อง polyfill
อยากทำ timeout แบบ simple❌ ต้อง AbortController

สำหรับ Monolith Backend บน Node.js 18+ ทั่วไป — ทั้งคู่ใช้ได้ ถ้าทีมคุ้นเคย axios ไม่มีเหตุผลต้องเปลี่ยน

ถ้าต้องการ Edge Runtime หรือ Streaming — เลือก InventoryRemoteFetchClient ตั้งแต่แรกจะง่ายกว่า migrate ทีหลัง

ถ้าต้องการทั้งสอง — implement ทั้ง InventoryRemoteAxiosClient และ InventoryRemoteFetchClient แล้วเลือกที่ Composition Root ตาม environment


Swap ที่ Composition Root — code จริง

// apps/api/src/composition-root.ts

import { InventoryRemoteAxiosClient } from '@system/share-client/inventory-api'
import { InventoryRemoteFetchClient } from '@system/share-client/inventory-api'

const INVENTORY_URL = process.env.INVENTORY_SERVICE_URL ?? 'http://inventory-service'

// ✅ ใช้ axios — default สำหรับ Node.js backend ทั่วไป
const inventoryClient = new InventoryRemoteAxiosClient(INVENTORY_URL)

// ✅ เปลี่ยนเป็น fetch — บรรทัดเดียว ไม่มีอะไรเปลี่ยนนอกจากบรรทัดนี้
// const inventoryClient = new InventoryRemoteFetchClient(INVENTORY_URL)

// ✅ fetch พร้อม custom timeout
// const inventoryClient = new InventoryRemoteFetchClient(INVENTORY_URL, 3000)

// inject เข้า UseCase ผ่าน deps — UseCase รู้จักแค่ InventoryServiceClient interface
const placeOrderEndpoint = makePlaceOrderEndpoint({
  inventoryClient,
  orderRepo,
})

UseCase code makePlaceOrderEndpoint ไม่เปลี่ยนบรรทัดเดียว เพราะมันรู้จักแค่ Pick<InventoryServiceClient, 'reserve' | 'checkStock'> — ไม่รู้เลยว่า implementation เบื้องหลังเป็น axios หรือ fetch

// share-service/src/order-api/command/place-order/endpoint/endpoint.config.ts
// ← ไม่มีการเปลี่ยนแปลงแม้แต่บรรทัดเดียวหลัง swap

type Deps = {
  inventoryClient: Pick<InventoryServiceClient, 'reserve' | 'checkStock'>
  orderRepo: Pick<OrderRepository, 'save' | 'findById'>
}

export const makePlaceOrderEndpoint = (deps: Deps) => async (input: PlaceOrderInput) => {
  const stockResponse = await deps.inventoryClient.checkStock(input.sku)
  if (!stockResponse.isSuccess) {
    return Result.fail(new CommonFailures.GetFail('INVENTORY_UNAVAILABLE'))
  }
  // ...
}

หมายเหตุ — got และ ky

got และ ky ใช้ pattern เดียวกับ fetch (Web Standards, Promise-based) แต่ให้ boilerplate น้อยกว่า

got เหมาะสำหรับ Node.js backend มี retry built-in, stream support ดีกว่า fetch และ TypeScript support ชัดเจน

ky เหมาะสำหรับ browser และ Edge Runtime เพราะสร้างบน fetch native, bundle size น้อยกว่า axios มาก

ถ้าต้องการ implement InventoryRemoteGotClient หรือ InventoryRemoteKyClient — โครงสร้างเหมือนกับ InventoryRemoteFetchClient ทุกอย่าง แค่เปลี่ยน safeFetch เป็น call จาก library นั้นแทน UseCase ไม่รู้เลยว่าเปลี่ยน


สรุป

Bonus นี้แสดงให้เห็นสองสิ่ง

หนึ่ง — สิ่งที่ต้องเพิ่มเมื่อ swap จาก axios เป็น fetch

safeFetch helper ที่ตรวจ response.ok, จัดการ timeout ด้วย AbortController และ set Content-Type header เป็น boilerplate ที่ขาดไม่ได้ — แต่เขียนครั้งเดียวใน method เดียว ทุก method ใน class ใช้ safeFetch ได้ทันทีโดยไม่ต้อง repeat

สอง — swap ทำงานจริง เพราะ Architecture ถูก

InventoryRemoteAxiosClient และ InventoryRemoteFetchClient implement interface เดียวกันจาก share-core เปลี่ยนที่ Composition Root บรรทัดเดียว — UseCase, test ของ UseCase และ business logic ทั้งหมดไม่ถูกแตะเลย ตาม Dependency Rule ของ Clean Architecture และ Secondary Adapter boundary ของ Hexagonal Architecture

ย้อนกลับไปอ่าน Part 6 — share-client เพื่อดู folder structure, export strategy, swap ระดับ ServiceClient (HTTP ↔ Internal) และ testing overview ที่สมบูรณ์


FAQ

Q: ความต่างสำคัญที่สุดระหว่าง fetch และ axios คืออะไร?

ความต่างที่ทำให้เกิด bug บ่อยที่สุดคือ fetch ไม่ throw เมื่อ HTTP status เป็น 4xx หรือ 5xx — axios throw อัตโนมัติ แต่ fetch ถือว่า “สำเร็จ” ตราบใดที่รับ response กลับมาได้ ต้องเช็ค response.ok เองเสมอ ถ้าลืมเช็ค response.ok UseCase จะได้รับ DataResponse<T> ที่ response.json() อาจ parse error body ออกมาเป็น success โดยไม่มีอะไรบอก


Q: ควรใช้ fetch หรือ axios สำหรับ Node.js backend?

สำหรับ Node.js backend ปกติที่รัน Express, Fastify หรือ NestJS บน server จริง — ทั้งคู่ทำงานได้ดีพอ ๆ กัน ถ้าทีมคุ้นเคย axios และ codebase ใช้ axios อยู่แล้ว ไม่มีเหตุผลต้องเปลี่ยน

เลือก fetch เมื่อ: ต้องการ deploy บน Edge Runtime, ต้องการ Streaming Response, หรือต้องการลด dependency สำหรับ Lambda Function ที่ cold start time สำคัญ


Q: Edge Runtime คืออะไรและทำไม fetch ถึงเป็น default?

Edge Runtime คือ JavaScript runtime ที่รันใกล้ผู้ใช้มากขึ้นในลักษณะ distributed เช่น Cloudflare Workers, Vercel Edge Functions และ Deno Deploy — เป็น restricted environment ที่ไม่มี Node.js API เต็มรูปแบบ ไม่มี fs, path และ Node.js built-in modules อื่น ๆ

fetch เป็น Web Standard ที่ถูกนิยามใน WHATWG Fetch Specification และ implement ในทุก modern JavaScript environment รวมถึง Edge Runtime ทุกตัว ไม่เหมือน axios ที่ depend บน Node.js http module ทำให้รันไม่ได้ใน Edge


Q: DataResponse pass-through ดีกว่า throw/catch อย่างไร?

throw/catch สร้าง hidden contract — caller ไม่รู้ล่วงหน้าว่า function จะ throw เมื่อไหร่และ throw อะไร TypeScript ไม่มี checked exceptions ทำให้ไม่มีอะไรบังคับให้ handle ทุก error case

DataResponse<T> เป็น explicit contract — Promise<DataResponse<StockLevel>> บอก caller ทุกอย่างที่อาจเกิดขึ้น: success มี dataResult: StockLevel, fail มี isSuccess: false และ resultCode ที่อธิบายเหตุผล TypeScript enforce ให้ handle ทุก case ผ่าน type system

สำคัญกว่านั้น UseCase ของแต่ละ BC map resultCode เป็น error ของตัวเองได้ตาม business context ของ BC นั้น แทนที่จะ re-throw generic error ที่ไม่บอกว่า “หมายความว่าอะไรสำหรับ BC นี้”


Q: ข้อผิดพลาดที่พบบ่อยที่สุดเมื่อ swap จาก axios เป็น fetch คืออะไร?

ลืมเช็ค response.ok คือข้อผิดพลาดที่พบบ่อยที่สุด เมื่อ provider ตอบ 404 axios throw ให้ catch จับ แต่ fetch return response ที่ ok: false ถ้าเรียก response.json() ตรง ๆ โดยไม่เช็ค ok ก่อน UseCase อาจได้รับ error body ที่ parse เป็น DataResponse<T> ที่ไม่ถูกต้องแทนที่จะได้ fail response

วิธีป้องกันคือห่อ logic ทั้งหมดใน safeFetch helper เหมือนที่ InventoryRemoteFetchClient ทำ ทุก method ใช้ safeFetch แทน fetch โดยตรง ไม่มีโอกาสลืมเช็ค response.ok

Supawut Thomas

Supawut Thomas

Software Developer

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