16.6 Bounded context extraction playbook
บทนี้เป็น playbook สำหรับแยก bounded context ออกจาก vtrc-api monolith — ใช้ได้กับทุก context ใน Wave 2
ภาพรวมกระบวนการ
1. Domain modeling (1 สัปดาห์)
│
▼
2. Define contract (1 สัปดาห์)
│
▼
3. Strangle route (1 สัปดาห์)
│
▼
4. Build new service (2-4 สัปดาห์)
│
▼
5. Dual-write + verify (1-2 สัปดาห์)
│
▼
6. Cutover (1-4 สัปดาห์)
│
▼
7. Decommission (1 สัปดาห์)ขั้นที่ 1 · Domain modeling
เป้าหมาย
เข้าใจขอบเขตของ context ที่จะแยก ก่อนเขียน code
กิจกรรม
Event storming (แบบ lightweight)
รวมทีม 2-4 คน ทำในห้องประชุมหรือ Miro/FigJam 1-2 ชั่วโมง
List domain events — อะไรเกิดขึ้นใน context นี้บ้าง
- ตัวอย่าง (leave):
LeaveRequested,LeaveApproved,LeaveRejected,LeaveCancelled,BalanceUpdated
- ตัวอย่าง (leave):
List commands — อะไรทำให้ event เกิด
- ตัวอย่าง:
SubmitLeave,ApproveLeave,RejectLeave,CancelLeave
- ตัวอย่าง:
List entities — entity อะไรอยู่ใน context
- ตัวอย่าง:
LeaveRequest,LeaveBalance,LeaveType,Approval
- ตัวอย่าง:
List external dependencies — context นี้ต้องการอะไรจาก context อื่น
- ตัวอย่าง: leave ต้องการ
User(จาก identity),Holiday(จาก organization),ApprovalChain(จาก configuration)
- ตัวอย่าง: leave ต้องการ
ผลลัพธ์
เอกสาร 1 หน้า:
- ชื่อ context
- entity list
- สรุป event/command
- dependency กับ context อื่น
กับดักที่พบบ่อย
- Context ใหญ่เกินไป — แบ่งย่อยได้
- Context เล็กเกินไป — รวมกับ context อื่น
- Boundary ไม่ชัด — ลองใช้ "if X change, does Y need to change?" เป็นเกณฑ์
ขั้นที่ 2 · Define contract
เป้าหมาย
กำหนด API ของ service ใหม่ ก่อนเขียน implementation
วิธี
ระบุ input/output ของทุก operation
ตัวอย่าง context leave:
SubmitLeave(input: SubmitLeaveInput): LeaveRequest
ApproveLeave(input: ApproveInput): LeaveRequest
GetLeaveRequest(id: ID): LeaveRequest
ListLeaveRequests(filter: LeaveFilter): [LeaveRequest]
GetLeaveBalance(userId: ID): LeaveBalanceเลือก transport
| Transport | เหมาะกับ | เหตุผล |
|---|---|---|
| REST | internal service | simple, cache-friendly |
| gRPC | internal service, high-volume | type-safe, fast |
| GraphQL | service ที่ client ใช้ตรง | flexible query |
แนะนำ: internal service ใช้ REST หรือ gRPC ใน VTRC ใช้ REST ก่อน (ทีมคุ้น)
เขียน OpenAPI spec
openapi: 3.0.0
info:
title: Leave Service
version: 1.0.0
paths:
/leaves:
post:
summary: Submit leave request
requestBody:
# ...
responses:
'201':
description: Createdใช้ spec generate code
- Server stub (Go):
oapi-codegen - Client (Go ใน vtrc-api):
oapi-codegen - TypeScript type (frontend):
openapi-typescript
กับดัก
- API เลียนแบบ database schema — ไม่ดี (leaky abstraction)
- API มี endpoint มากเกินไป — ทำให้ client ต้องเรียกหลายครั้ง
- ลืม idempotency — duplicate request สร้างข้อมูลซ้ำ
ขั้นที่ 3 · Strangle route
เป้าหมาย
เพิ่ม "tap" ใน vtrc-api ที่ route traffic ไป service ใหม่ ถ้า feature flag เปิด
รูปแบบ code ใน vtrc-api
// ตัวอย่างใน resolver
async function submitLeave(parent, args, context) {
if (await featureFlags.isEnabled('leave-service-v2', context.user.id)) {
return await leaveServiceClient.submitLeave(args.input);
}
// code เดิม
return await legacySubmitLeave(args.input);
}หลักการ
- ใช้ feature flag ที่สามารถเปิด/ปิดต่อ user ได้ (gradual rollout)
- ทุก error ใน service ใหม่ต้อง log + metric (อย่า swallow)
- ถ้า service ใหม่ timeout, fallback ไป legacy (defense)
ขั้นที่ 4 · Build new service
โครงสร้าง (Hexagonal)
leave-service/
├── cmd/
│ └── server/main.go
├── internal/
│ ├── domain/ # entity, value object, domain service
│ │ ├── leave.go
│ │ ├── balance.go
│ │ └── approval.go
│ ├── port/ # interface (input + output port)
│ │ ├── repository.go # LeaveRepository interface
│ │ ├── notifier.go # Notifier interface
│ │ └── api.go # API interface
│ ├── adapter/
│ │ ├── http/ # REST handler (ใช้ port.API)
│ │ ├── mysql/ # repository implementation
│ │ ├── notifier/ # notification adapter
│ │ └── eventbus/ # event publisher
│ └── usecase/ # application logic
│ ├── submit_leave.go
│ └── approve_leave.go
├── migrations/
├── go.mod
├── Dockerfile
└── docker-compose.ymlหลักการ Hexagonal (Ports & Adapters)
domainไม่พึ่งพาอะไรภายนอก (no import adapter)portนิยาม interface ที่ adapter ต้อง implementadapterimplement port และเรียกใช้ usecase ผ่าน portusecaseทำ business logic โดยเรียกผ่าน port
ตัวอย่าง — SubmitLeave usecase
// internal/domain/leave.go
type LeaveRequest struct {
ID string
UserID string
Type LeaveType
StartDate time.Time
EndDate time.Time
Status LeaveStatus
ApproverID string
}
// internal/port/repository.go
type LeaveRepository interface {
Save(ctx context.Context, leave *LeaveRequest) error
FindByID(ctx context.Context, id string) (*LeaveRequest, error)
}
// internal/usecase/submit_leave.go
type SubmitLeaveUseCase struct {
repo port.LeaveRepository
balance port.BalanceService
}
func (uc *SubmitLeaveUseCase) Execute(ctx context.Context, cmd SubmitLeaveCommand) (*LeaveRequest, error) {
balance, err := uc.balance.Get(ctx, cmd.UserID)
if err != nil {
return nil, err
}
if balance.RemainingDays < cmd.DurationDays() {
return nil, ErrInsufficientBalance
}
leave := &LeaveRequest{
ID: uuid.New().String(),
UserID: cmd.UserID,
Type: cmd.Type,
StartDate: cmd.StartDate,
EndDate: cmd.EndDate,
Status: StatusPending,
}
if err := uc.repo.Save(ctx, leave); err != nil {
return nil, err
}
return leave, nil
}ทดสอบ
- Unit test — usecase ทดสอบด้วย mock port
- Integration test — adapter จริง (test DB)
- Contract test — ผลลัพธ์ต้องตรงกับ vtrc-api (ขั้นที่ 5)
ขั้นที่ 5 · Dual-write + verify
เป้าหมาย
เขียนข้อมูลทั้ง schema เก่าและใหม่ในช่วง transition เพื่อ verify ว่า service ใหม่ให้ผลเหมือนเดิม
รูปแบบ
Request
│
▼
vtrc-api
│
├──► legacy write (schema A)
│
└──► new service write (schema B) [async, ถ้า fail ไม่กระทบผู้ใช้]
│
▼
verifier (cron)
│
▼
เปรียบเทียบ A กับ B
│
▼
alert ถ้าแตกต่างระยะเวลา
รัน dual-write อย่างน้อย 1-2 สัปดาห์ ถ้า verifier ไม่พบความแตกต่าง → มั่นใจพอที่จะ cutover
ข้อควรระวัง
- dual-write ทำให้ write ช้าลงเล็กน้อย (เพิ่ม 1 network call)
- async write ของ service ใหม่อาจล้าหลัง — verifier ต้อง tolerate
- บาง operation (เช่น delete) อาจไม่ dual-write ได้ — ต้องใช้วิธีอื่น
ขั้นที่ 6 · Cutover
เป้าหมาย
สลับ traffic จาก legacy ไป service ใหม่ทีละน้อย
ขั้นตอน
| Week | % traffic | สิ่งที่สังเกต |
|---|---|---|
| 1 | 5% (internal test) | error rate, latency |
| 2 | 25% | complaint จากผู้ใช้, support ticket |
| 3 | 50% | — |
| 4 | 100% | — |
หากมีปัญหา
- ปิด feature flag → traffic กลับ legacy ทันที
- เก็บ log ของ service ใหม่เพื่อ debug
หลัง cutover 100% ≥ 2 สัปดาห์
ไปขั้นที่ 7 (decommission)
ขั้นที่ 7 · Decommission
เป้าหมาย
ลบ code legacy ออกจาก vtrc-api
ขั้นตอน
- ลบ resolver ที่ใช้ code legacy
- ลบ model ที่ไม่ใช้ (ระวัง — อาจยังใช้ใน query)
- ลบ service, repository, helper ที่เกี่ยวข้อง
- อัปเดต schema GraphQL — ถ้ามี breaking change ต้อง coordinate กับ frontend
- ลบ dependency ที่ไม่ใช้
กับดัก
- ลบ model ที่ยังถูกอ้างใน cross-context query — ตรวจก่อน
- ลบ field ใน GraphQL schema โดยไม่แจ้ง frontend — breaking change
Checklist สำหรับแต่ละ context
ก่อนประกาศ context หนึ่งแยกเสร็จ ตอบ "ใช่" ทุกข้อ:
- [ ] service ใหม่รับ 100% production traffic ≥ 2 สัปดาห์
- [ ] error rate < 0.1%
- [ ] latency p95 < target (เทียบกับ legacy)
- [ ] contract test ผ่าน 100%
- [ ] dual-write verifier ไม่พบความแตกต่าง ≥ 2 สัปดาห์
- [ ] code legacy ใน vtrc-api ถูกลบแล้ว
- [ ] runbook (Volume 13) อัปเดต
- [ ] dashboard monitoring ครอบคลุม
สรุป
Playbook นี้เป็น template — แต่ละ context จะมีรายละเอียดเฉพาะ ปรับตามบริบท แต่หลักการ (strangler, contract test, gradual cutover) ใช้ได้ทุก context
บทถัดไปจะเจาะ data migration (ส่วนสำคัญของการแยก context)