Skip to content

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 ชั่วโมง

  1. List domain events — อะไรเกิดขึ้นใน context นี้บ้าง

    • ตัวอย่าง (leave): LeaveRequested, LeaveApproved, LeaveRejected, LeaveCancelled, BalanceUpdated
  2. List commands — อะไรทำให้ event เกิด

    • ตัวอย่าง: SubmitLeave, ApproveLeave, RejectLeave, CancelLeave
  3. List entities — entity อะไรอยู่ใน context

    • ตัวอย่าง: LeaveRequest, LeaveBalance, LeaveType, Approval
  4. List external dependencies — context นี้ต้องการอะไรจาก context อื่น

    • ตัวอย่าง: leave ต้องการ User (จาก identity), Holiday (จาก organization), ApprovalChain (จาก configuration)

ผลลัพธ์

เอกสาร 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เหมาะกับเหตุผล
RESTinternal servicesimple, cache-friendly
gRPCinternal service, high-volumetype-safe, fast
GraphQLservice ที่ client ใช้ตรงflexible query

แนะนำ: internal service ใช้ REST หรือ gRPC ใน VTRC ใช้ REST ก่อน (ทีมคุ้น)

เขียน OpenAPI spec

yaml
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

javascript
// ตัวอย่างใน 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 ต้อง implement
  • adapter implement port และเรียกใช้ usecase ผ่าน port
  • usecase ทำ business logic โดยเรียกผ่าน port

ตัวอย่าง — SubmitLeave usecase

go
// 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สิ่งที่สังเกต
15% (internal test)error rate, latency
225%complaint จากผู้ใช้, support ticket
350%
4100%

หากมีปัญหา

  • ปิด feature flag → traffic กลับ legacy ทันที
  • เก็บ log ของ service ใหม่เพื่อ debug

หลัง cutover 100% ≥ 2 สัปดาห์

ไปขั้นที่ 7 (decommission)

ขั้นที่ 7 · Decommission

เป้าหมาย

ลบ code legacy ออกจาก vtrc-api

ขั้นตอน

  1. ลบ resolver ที่ใช้ code legacy
  2. ลบ model ที่ไม่ใช้ (ระวัง — อาจยังใช้ใน query)
  3. ลบ service, repository, helper ที่เกี่ยวข้อง
  4. อัปเดต schema GraphQL — ถ้ามี breaking change ต้อง coordinate กับ frontend
  5. ลบ 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)