บทที่ 13 · Part 3 — Organizing the Codebase

Rich Single-Module Codebase

วิวัฒน์ business slice เดียวให้รองรับ domain rules, use cases, ports และ adapters โดยไม่แตก package เกินแรงจริง

Rich Single-Module Codebase

Customer module เดิมเริ่มจาก CRUD แต่ต่อมามี verification lifecycle, eligibility policy, status transitions, duplicate identity handling และหลาย driving adapters หากยังวางทุกอย่าง ใน service.go flow จะหนาแน่นเกินไป แต่การแยกเป็น microservice หรือ 10 packages ก็ยังไม่มี เหตุผล Rich single-module structure ช่วยเพิ่ม internal design โดยรักษา business boundary เดิม

จบบทนี้คุณจะ

วิวัฒน์ slice เดียวให้มี domain rules, application use cases, consumed ports, driving adapters และ driven implementations เฉพาะจุด รักษา package cohesion และ public API โดยไม่ให้ directory mirror conceptual layer ทุกอัน

[!IMPORTANT] วิธีอ่านหลักฐานในบทนี้ คำที่เป็น Definition/Pattern มีแหล่งต้นทางอยู่ใกล้ claim ส่วน decision table, starting structure, checklist และ lab เป็น Heuristic/Trade-off หรือ Course Convention สำหรับฝึกตัดสินใจ ไม่ใช่มาตรฐานสากล เว้นแต่บทจะระบุแหล่งและขอบเขตไว้ชัดเจน

ขยายโครงสร้างโดยไม่เปลี่ยนชื่อธุรกิจ

โครงเริ่มต้น:

internal/charge/
  service.go
  http.go
  mysql.go

เมื่อ Charge มี lifecycle และ provider boundary:

internal/charge/
  domain.go
  money.go
  ports.go
  create.go
  refund.go
  http.go
  webhook.go
  repository/mysql/

outer name ยังเป็น charge เพราะ Ubiquitous Language และ ownership ไม่เปลี่ยน การเพิ่ม role ข้างในเป็น response ต่อ complexity ไม่ใช่สร้าง business boundary ใหม่

Visibility เป็นเครื่องมือออกแบบ

ใน Go package เดียว unexported types/functions จำกัด surface ได้ ให้ export เฉพาะ commands, outcomes หรือ constructors ที่ adapter/composition root ต้องใช้

type RefundCommand struct {
	ChargeID   ChargeID
	Amount     Money
	OperationID string
}

type Gateway interface {
	Refund(ctx context.Context, cmd GatewayRefund) (GatewayOutcome, error)
}

type Service struct {
	charges ChargeRepository
	gateway Gateway
}

ไม่ต้อง export aggregate fields เพื่อให้ MySQL mapper เข้าถึง วิธีหนึ่งคือ repository อยู่ subpackage และใช้ persistence DTO กับ explicit reconstitution constructor ที่ตรวจ trusted persisted state แยกจาก public creation

หลีกเลี่ยง cyclic imports โดยให้ domain/application contract อยู่ package owner adapter subpackage import inward Composition root import ทั้ง 2เพื่อ wire

แยกตามแรงกดดัน ไม่ใช่ความสมมาตร

ไฟล์แต่ละประเภทมีเหตุผล:

  • domain.go เมื่อ state transition/invariant ต้องทดสอบโดยไม่ผ่าน database
  • ports.go เมื่อ external/persistence conversations มากพอให้เห็น contract
  • use-case files เมื่อ orchestration มีหลาย command ต่างกัน
  • http.go/webhook.go เมื่อ driving schemas และ auth ต่างกัน
  • repository/mysql เมื่อ mapping/transaction/integration tests มีรายละเอียดมาก

อย่าสร้าง entities/, valueobjects/, aggregates/, services/ เป็น directory ต่อ tactical pattern มันเพิ่ม navigation และอาจเกิด package cycle ให้ concept ที่ร่วม invariant อยู่ใกล้กัน

แยก Domain กับ Application ให้ชัด

Domain ตัดสิน CanRefund และ transition ภายใน Charge Application ตัดสินลำดับ:

  1. authorize actor/use case
  2. load Charge ด้วย authoritative state
  3. เรียก domain method เพื่อ reserve/approve refund
  4. persist local state ด้วย concurrency control
  5. เรียก provider หรือบันทึก intent สำหรับ workflow ตาม failure design
  6. map outcome และ schedule reconciliation หาก unknown

Provider call, transaction boundary และ idempotency เป็น application/infrastructure concern แม้ใช้ domain terms อย่าย้าย network call เข้า Entity method

Aggregate boundary กับ provider boundary ไม่เหมือนกัน

Charge invariant อาจใช้ MySQL transaction สั้น แต่ provider operation ข้าม network ไม่ควรอยู่ใน lock เดียว ต้องมี explicit intermediate state, operation ID และ recovery เพื่อไม่ double-refund เมื่อ crash

Public Contract ของ Module

Module ไม่ควรเปิดทุก struct ให้ peer module import ให้สร้าง entry points เช่น:

type RefundUseCase interface {
	RequestRefund(context.Context, Principal, RefundCommand) (RefundResult, error)
}

type PaymentFact struct {
	OperationID string
	Status      PaymentFactStatus
}

ใน process เดียว caller อาจเรียก concrete service method โดยตรง Interface จำเป็นเมื่อ consumer ต้องการ contract หรือเพื่อ enforce boundary ไม่ใช่เพื่อให้ทุก service mock ได้ Integration events/public DTO ควรแยกจาก internal Aggregate representation

แบบฝึกปฏิบัติ: ขยาย Charge Module

นำ small Charge module ที่มี Create/Capture มารับ requirements:

  • partial refund พร้อม cumulative invariant
  • HTTP และ message-driven capture confirmation
  • Stripe adapter และ fake gateway สำหรับ application tests
  • MySQL optimistic version

วาด before/after tree แล้วระบุเหตุผลต่อไฟล์/package ทุกอัน สร้าง import rules:

repository/mysql may import charge
charge must not import repository/mysql
stripe adapter may import charge port types
charge must not import Stripe packages
peer modules call exported use cases/contracts only

คะแนน:

  • ผ่าน เมื่อ business name เดิมและ import direction ชัด
  • ดี เมื่อ roles แยกเฉพาะ pressure และ public surface เล็ก
  • ควรทำใหม่ เมื่อทุก concept เป็น package หรือ adapter ตัดสิน domain policy

รายการตรวจสอบ

  • Business capability เป็น outer slice
  • Unexported visibility ลด surface โดยไม่ต้องเพิ่ม package
  • Domain rule แยกจาก application orchestration
  • Port เป็น consumer conversation และ adapter import inward
  • Persistence mapping/concurrency มี integration tests
  • Public contract ไม่เผย Aggregate/vendor schema โดยไม่จำเป็น
  • Network workflow ไม่เปิด database transaction ค้าง

สรุปบทนี้

Rich single-module ไม่ใช่ monolith ที่ไร้ขอบเขตและไม่ใช่ microservice ขนาดจิ๋ว มันรักษา capability เป็น slice เดียว แล้วใช้ files, visibility, ports และ subpackages เฉพาะจุดเพื่อรับ complexity โครงสร้างโตตามแรงโดยไม่เปลี่ยน Ubiquitous Language

อ่านเพิ่มเติม