Abstraction Layer TypeScript: ออกแบบ Contract ที่ไม่มี Type ซ้ำ
Scale-Ready Architecture · Part 2 of 9
ออกแบบ Abstraction Layer ที่ไม่มี Type ซ้ำซ้อน
Scale-Ready Architecture Series สร้าง Modular Monolith บน Nx Monorepo ด้วยหลักการของ Hexagonal Architecture + Clean Architecture แบบ Functional ถ้ายังไม่ได้อ่าน Part 1 แนะนำให้เริ่มจากที่นั่นก่อน — Part นี้ต่อยอดจาก Dependency Graph โดยตรง
เมื่อ type กระจายอยู่ทุก package — ปัญหาที่ทุกทีมเจอ
สักวันหนึ่งคุณจะเปิดไฟล์แล้วเจอสิ่งนี้
// share-data/src/user-mng-api/command/create-user/createUser.repository.ts
interface CreateUserInput {
username: string
email: string
role: string
}
// share-service/src/user-mng-api/command/create-user/createUser.useCase.ts
interface CreateUserRequest {
username: string
email: string
role: string // <- type เดิม ซ้ำกันทุกตัว
}
// app-main/src/user-mng/createUser.controller.ts
interface CreateUserDto {
username: string
email: string
role: string // <- ซ้ำอีกแล้ว ครั้งที่สาม
}
สามไฟล์ สาม interface ข้อมูลเหมือนกันทุกตัว ยังไม่มีใครผิดนะ — แค่ไม่มีที่เดียวที่เป็น source of truth
พอ field role ต้องเปลี่ยนจาก string เป็น UserRole enum คุณต้องตามแก้ทุก package ด้วยมือ และถ้าพลาดแก้แค่สองจากสามที่ TypeScript ก็ไม่บอก เพราะแต่ละ interface เป็น structural type ที่แยกกันสมบูรณ์
นี่คือปัญหาที่ share-core แก้ตรง ๆ
share-core คือฐานของ Dependency Graph
ใน Part 1 เราวาง Dependency Graph ไว้แบบนี้
share-core <- ไม่ depend ใคร (pure contracts)
|
|-- share-data (depend core)
|-- share-client (depend core)
+-- share-service (depend core)
|
app-{name} <- depend ทุก package, wiring ที่นี่ที่เดียว
share-core นั่งอยู่ก้นสุดของ graph เพราะมันไม่ depend ใคร แต่ทุกคน depend มัน
หน้าที่ของ share-core มีสามข้อชัดเจน
- Repository Input/Output Types — shape ของ data ที่ส่งเข้าและออกจาก Data Layer
- Repository Interfaces — contract ที่
share-dataต้อง implement - Provider Contracts — interface ของ external service ที่
share-clientและshare-serviceต้อง implement
ไม่มี logic ใด ๆ — ไม่มี function ไม่มี class ไม่มี utility ทุกไฟล์ใน share-core เป็น .type.ts ล้วน ๆ
Folder Structure — จัดตาม Bounded Context และ CQRS
โครงสร้างของ share-core ออกแบบให้สะท้อน Bounded Context (BC) และแยก Command/Query ตามหลัก CQRS ในระดับ Use Case
src/
+-- shared/
| +-- service-client/
| +-- inventoryServiceClient.type.ts <- {bc}ServiceClient.type.ts
| +-- paymentServiceClient.type.ts
|
+-- {bc}-api/ <- {bc} = user-mng, order, inventory
+-- command/
| +-- {action}/ <- create-user, place-order, update-user-role
| +-- contract.type.ts <- ชื่อคงที่เสมอ ทุก action
| +-- index.ts
+-- query/
+-- {action}/ <- get-user, list-orders, get-order-status
+-- contract.type.ts
+-- index.ts
ตัวอย่างโครงสร้างจริงในระบบที่มี 2 BC
src/
+-- shared/
| +-- service-client/
| +-- inventoryServiceClient.type.ts
| +-- paymentServiceClient.type.ts
| +-- notificationServiceClient.type.ts
|
+-- user-mng-api/
| +-- command/
| | +-- create-user/
| | | +-- contract.type.ts
| | | +-- index.ts
| | +-- update-user-role/
| | +-- contract.type.ts
| | +-- index.ts
| +-- query/
| +-- get-user-detail/
| +-- contract.type.ts
| +-- index.ts
|
+-- order-api/
+-- command/
| +-- place-order/
| +-- contract.type.ts
| +-- index.ts
+-- query/
+-- get-order-status/
+-- contract.type.ts
+-- index.ts
contract.type.ts — ทุกอย่างของ action ในไฟล์เดียว
แต่ละ action folder มีแค่สองไฟล์ — contract.type.ts และ index.ts
contract.type.ts เก็บทั้ง Repository Interface และ Input/Output types ของทุก method ในไฟล์เดียว แบ่งด้วย comment separator
// share-core/src/user-mng-api/command/create-user/contract.type.ts
// --- Repository --------------------------------------------------
export interface Repository {
createUser(input: CreateUserInput): Promise<CreateUserOutput>
getDetailForSns(input: GetDetailForSnsInput): Promise<GetDetailForSnsOutput>
}
// --- createUser --------------------------------------------------
export interface CreateUserInput {
username: string
email: string
role: string
}
export interface CreateUserOutput {
id: string
createdAt: Date
}
// --- getDetailForSns ---------------------------------------------
export interface GetDetailForSnsInput {
userId: string
}
export interface GetDetailForSnsOutput {
displayName: string
avatarUrl: string
}
สังเกตว่า Repository interface อยู่บนสุดก่อนเสมอ — ทำให้เปิดไฟล์มาแล้วเห็นทันทีว่า action นี้มี method อะไรบ้าง Input/Output type ของแต่ละ method ตามมาด้านล่างตามลำดับ
index.ts ทำหน้าที่ re-export เพื่อให้ consumer import จาก action level ได้โดยตรง
// share-core/src/user-mng-api/command/create-user/index.ts
export type {
Repository,
CreateUserInput,
CreateUserOutput,
GetDetailForSnsInput,
GetDetailForSnsOutput,
} from './contract.type'
✅ ถูก vs ❌ ผิด — การเขียน contract
// ✅ ถูก — Repository interface อยู่บนสุด type ของแต่ละ method ตามลำดับ
// contract.type.ts
export interface Repository {
createUser(input: CreateUserInput): Promise<CreateUserOutput>
}
export interface CreateUserInput {
username: string
email: string
}
export interface CreateUserOutput {
id: string
createdAt: Date
}
// ❌ ผิด — type กระจายหลาย file ต้องเปิดหลายไฟล์เพื่อเห็นภาพรวม
// createUser.input.type.ts
export interface CreateUserInput { ... }
// createUser.output.type.ts
export interface CreateUserOutput { ... }
// createUser.repository.ts
import { CreateUserInput } from './createUser.input.type' // <- import วนเวียน
export interface Repository { ... }
// ❌ ผิด — มี logic ใน share-core
// contract.type.ts
export const validateCreateUserInput = (input: CreateUserInput) => {
// <- ห้ามมี logic ใด ๆ ใน share-core
if (!input.email.includes('@')) throw new Error('Invalid email')
}
shared/service-client — Provider Contract และ Pick inline
Provider Contract คือ interface ที่นิยามว่า external BC หรือ service นั้น expose method อะไรบ้างทั้งหมด ทั้ง
share-client(HTTP implementation) และshare-service/service-client(internal implementation) ต้อง implement ตาม contract เดียวกันนี้
เหตุผลที่ Provider Contract อยู่ที่ shared ไม่ใช่ภายใน BC
สมมติว่า order-api ต้องใช้ InventoryServiceClient และ payment-api ก็ใช้เหมือนกัน ถ้าแต่ละ BC ประกาศ interface แยก จะเกิด type ซ้ำซ้อนทันที และถ้า inventory เพิ่ม method ใหม่ ต้องไปตามแก้ interface ในทุก BC ที่ใช้
เก็บที่ shared/service-client ครั้งเดียว แล้วให้ consumer Pick<> เฉพาะ method ที่ต้องการ — source of truth ยังอยู่ที่ไฟล์เดียว
// share-core/src/shared/service-client/inventoryServiceClient.type.ts
export interface InventoryServiceClient {
reserve(items: ReserveInput[]): Promise<ReservationResult>
release(reservationId: string): Promise<void>
checkStock(sku: string): Promise<StockLevel>
hold(amount: Money): Promise<HoldResult>
}
Pick inline ที่ Deps type — ไม่สร้าง interface แยก
Consumer BC ไม่ประกาศ type แยก แต่ใช้ Pick<> inline ตรงที่ Deps type ของ UseCase เลย
// 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 type { Repository } from '@system/share-core/order-api/command/place-order'
// Pick<> inline ที่ Deps type — ไม่มี interface แยก
type Deps = {
orderRepo: Repository
inventoryClient: Pick<InventoryServiceClient, 'reserve' | 'checkStock'>
// ^ บอก UseCase ว่าต้องการแค่ 2 method นี้เท่านั้น
}
export const makePlaceOrderEndpoint = (deps: Deps) => async (input: PlaceOrderInput) => {
// implementation...
}
ใน test ก็ใช้ Pick<> แบบเดียวกัน — TypeScript บังคับแค่ method ที่ระบุ ไม่ต้อง mock ทั้ง interface
// share-service/src/order-api/command/place-order/endpoint/endpoint.config.test.ts
import type { InventoryServiceClient } from '@system/share-core/shared/service-client/inventoryServiceClient.type'
const mockInventoryClient: Pick<InventoryServiceClient, 'reserve' | 'checkStock'> = {
reserve: jest.fn().mockResolvedValue({ reservationId: 'rsv-001' }),
checkStock: jest.fn().mockResolvedValue({ available: true, quantity: 50 }),
// TypeScript บังคับแค่ 2 method นี้ ✅
// ไม่ต้อง mock release() และ hold() ที่ UseCase นี้ไม่ได้ใช้
}
✅ ถูก vs ❌ ผิด — การใช้ Provider Contract
// ✅ ถูก — Pick<> inline ที่ Deps type ตรง ๆ ไม่มี type แยก
import type { InventoryServiceClient } from '@system/share-core/shared/service-client/inventoryServiceClient.type'
type Deps = {
inventoryClient: Pick<InventoryServiceClient, 'reserve' | 'checkStock'>
}
// ❌ ผิด — ประกาศ interface แยกซ้ำ type ที่มีอยู่แล้ว
// ถ้า InventoryServiceClient เพิ่ม method ใหม่ type นี้ไม่รู้เลย
interface OrderInventoryClient {
reserve(items: ReserveInput[]): Promise<ReservationResult>
checkStock(sku: string): Promise<StockLevel>
}
type Deps = {
inventoryClient: OrderInventoryClient // <- ซ้ำซ้อน แยกออกจาก source of truth แล้ว
}
// ❌ ผิด — inject ทั้ง interface แม้ใช้แค่บางส่วน
// UseCase ต้องรู้เรื่อง release() และ hold() ทั้งที่ไม่ได้ใช้เลย
type Deps = {
inventoryClient: InventoryServiceClient // <- too broad, coupling มากเกินจำเป็น
}
Export Strategy — Action Level Export
share-core expose ที่ action level และ shared/service-client — ทุกอย่างเป็น public ไม่มีอะไรซ่อน
{
"exports": {
"./user-mng-api/command/create-user": {
"import": "./src/user-mng-api/command/create-user/index.ts"
},
"./user-mng-api/command/update-user-role": {
"import": "./src/user-mng-api/command/update-user-role/index.ts"
},
"./user-mng-api/query/get-user-detail": {
"import": "./src/user-mng-api/query/get-user-detail/index.ts"
},
"./order-api/command/place-order": {
"import": "./src/order-api/command/place-order/index.ts"
},
"./order-api/query/get-order-status": {
"import": "./src/order-api/query/get-order-status/index.ts"
},
"./shared/service-client/*": {
"import": "./src/shared/service-client/*.ts"
}
}
}
Import pattern ที่ถูกต้อง
// ✅ import จาก action level — ถูกต้อง
import type { Repository, CreateUserInput } from '@system/share-core/user-mng-api/command/create-user'
// ✅ import provider contract สำหรับ implement หรือ Pick<> inline
import type { InventoryServiceClient } from '@system/share-core/shared/service-client/inventoryServiceClient.type'
// ❌ import จาก root level — ผิด ไม่อยู่ใน exports field
import type { Repository } from '@system/share-core'
// ❌ import ลึกกว่า action level — ผิด และซ้ำซ้อน
import type { Repository } from '@system/share-core/user-mng-api/command/create-user/contract.type'
ทำไมถึง expose ที่ action level ไม่ใช่ BC level?
ถ้า expose ที่ BC level เช่น @system/share-core/user-mng-api consumer ต้องรู้ว่าต้องไปเอา Repository ของ create-user จากที่ไหนของ BC ทั้งหมด การ expose ที่ action level ทำให้ path ตรงตรงกับสิ่งที่ต้องการ — create-user อยู่ที่ .../create-user เสมอ
กฎที่ต้องถือปฏิบัติ
| กฎ | เหตุผล |
|---|---|
| ไม่มี logic ใด ๆ | share-core คือ contract ล้วน ๆ — logic อยู่ที่ share-service |
ใช้ contract.type.ts เสมอ 1 ไฟล์ต่อ action | navigate ง่าย เห็นภาพรวม method ทั้งหมดของ action ทันที |
| Repository interface อยู่บนสุดเสมอ | เปิดไฟล์มาแล้วเห็น API ของ action ก่อนเสมอ |
| ใช้ comment separator แบ่ง method | align กับ convention ของ data layer ทำให้ตามได้ข้ามไฟล์ |
ไฟล์นามสกุล .type.ts | SonarQube CPD ไม่นับ type file เป็น duplicate ป้องกัน false positive |
index.ts export ทุกอย่างออก | consumer import จาก action level โดยตรง ไม่ต้องรู้ internal structure |
Pick<> inline ที่ Deps type — ไม่สร้าง interface แยก | source of truth ยังอยู่ที่ Provider Contract ตัวเดียว ไม่มี type ซ้ำซ้อน |
dependencies เป็น {} เสมอ | บังคับ share-core ไม่ให้แอบ depend local package ใด ๆ |
สรุป
share-core แก้ปัญหา type กระจายด้วยการเป็น single source of truth ของทุก contract ในระบบ หลักการที่ต้องจำ
- หนึ่ง action หนึ่ง
contract.type.ts— เปิดไฟล์เดียวเห็นทุกอย่างของ action นั้น - Provider Contract อยู่ที่
shared/service-client— ทุก BC ที่ implement หรือ consume ใช้ source เดียวกัน Pick<>inline ที่ Deps type — coupling แค่ method ที่ใช้จริง ไม่ต้องประกาศ interface ซ้ำ- ไม่มี logic — ถ้าเริ่มเขียน logic ใน share-core แสดงว่า logic นั้นต้องย้ายไป share-service
Part ถัดไป — Part 3: Testing Overview — จะอธิบาย Test Matrix, Mock Strategy ของแต่ละ layer และหลักการแยก Fixture กับ Seed Data ก่อนที่เราจะลงลึกแต่ละ layer ใน Part 4 เป็นต้นไป
Article ถัดไปใน Series
| Part | หัวข้อ | สรุป |
|---|---|---|
| 3 | Testing Overview | Test Matrix, Mock Strategy, Fixture vs Seed Data ทั้ง Series |
| 4 | share-data — Data Layer | Action-Based Structure, Entry, Task, DAF |
| 5 | share-data — Test Strategy | Fixtures, Mock Level, Jest config, SonarQube |
| 6 | share-client — HTTP ServiceClient | 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 |
FAQ
Q: contract.type.ts ต่างจากการเขียน interface แยกหลาย file อย่างไร?
การเก็บทุกอย่างของ action ไว้ในไฟล์เดียวทำให้เห็นภาพรวม API ของ action นั้นทันทีที่เปิดไฟล์ — ไม่ต้อง navigate ข้ามหลายไฟล์เพื่อตามว่า method นี้รับ input อะไรและคืน output อะไร ยังช่วยให้ Nx affected graph ทำงานได้ precise ขึ้นเพราะการเปลี่ยน contract ของ action เดียวไม่กระทบ action อื่นใน BC เดียวกัน
Q: ทำไมถึงใช้ Pick<> inline แทนการสร้าง interface แยก?
Pick<> inline รักษา single source of truth ไว้ที่ Provider Contract ตัวเดียว ถ้า InventoryServiceClient เพิ่ม method reserveBatch() ขึ้นมา UseCase ที่ Pick แค่ reserve และ checkStock ไม่ได้รับผลกระทบ แต่ถ้าสร้าง interface แยก (OrderInventoryClient) ไว้ในแต่ละ BC จะต้องตามแก้ทุก interface ด้วยมือ และ TypeScript ก็ไม่บอกว่าพลาด เพราะมันเป็น structural type ที่แยกกันสมบูรณ์แล้ว
Q: share-core ควรมี package.json dependencies เป็น {} เสมอจริงไหม?
ใช่ และสำคัญมาก dependency ใด ๆ ที่แอบเข้ามาใน share-core จะกลายเป็น transitive dependency ของทุก package ที่ depend มัน เพราะทุก package depend share-core การที่ share-core clean ทำให้ Nx affected graph ทำงานถูกต้อง — การแก้ share-core ไม่ดึง external package ที่ไม่เกี่ยวมา re-build
Q: ทำไม Provider Contract ถึงอยู่ที่ shared/service-client ไม่ใช่ใน BC ของ consumer?
Provider Contract นิยามจากมุมมองของ provider ไม่ใช่ consumer — InventoryServiceClient บอกว่า inventory expose method อะไรบ้างทั้งหมด ถ้าย้าย contract เข้าไปใน consumer BC แต่ละ BC ที่ต้องการ inventory จะต้องนิยาม interface ของตัวเอง พอ inventory เปลี่ยน signature ต้องตามแก้ทุก consumer การเก็บที่ shared ที่เดียวทำให้ทุก consumer อ่านจาก source เดียวกันและ TypeScript จะ catch ความผิดพลาดทันทีที่ signature เปลี่ยน
Q: ข้อผิดพลาดที่พบบ่อยที่สุดเมื่อออกแบบ Abstraction Layer คืออะไร?
ข้อผิดพลาดที่พบบ่อยที่สุดคือเริ่มเพิ่ม logic เข้าไปใน share-core เช่น validation function, utility helper หรือ default value ที่ดูเหมือนเป็นส่วนหนึ่งของ type นั้น เมื่อ share-core มี logic มันจะเริ่มต้องการ dependency และ Dependency Graph ทั้งระบบจะพังทันที สัญญาณที่บอกว่าออกแบบผิดที่คือเมื่อพบว่ากำลังจะ import อะไรบางอย่างเข้ามาใน share-core — ถ้า dependencies ยังเป็น {} อยู่ แปลว่าทิศทางถูก