บทที่ 8 · Part 2 — Choosing Code Architecture

Layered Architecture

แยก transport, application และ persistence ตาม responsibility โดยไม่สร้าง global technical layer ที่ผูกทุก feature

Layered Architecture

codebase แบบ controllers/, services/, repositories/, models/ ดูเป็น Layered Architecture แต่ feature Customer registration 1 ครั้งกระจายอยู่ 4 directory และ business rule บางส่วนอยู่ controller บางส่วนอยู่ SQL การมีชื่อ layer ไม่รับประกัน dependency direction หรือ separation of concerns

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

แยก driving adapter, application logic และ infrastructure ตาม responsibility เปรียบเทียบ global technical layers กับ layers ภายใน business slice และออกแบบ Customer registration ที่ใช้ MySQL โดยสร้าง Repository/Service Layer เฉพาะเมื่อมีคุณค่าจริง

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

Layer คือขอบเขตความรับผิดชอบ

Layer ช่วยกำหนดว่า code รู้เรื่องอะไร:

  • Driving adapter รู้ HTTP/JSON/status code และแปลง request เป็น application command
  • Application layer orchestrate use case, authorization, transaction และ dependency
  • Domain logic ตัดสิน business rule โดยไม่รู้ transport/database
  • Infrastructure รู้ SQL, MySQL driver, external SDK และ serialization

module เล็กไม่จำเป็นต้องแยกทุก responsibility เป็น package บางครั้งไฟล์ 3 ไฟล์ใน package เดียวเพียงพอ สิ่งสำคัญคือ business decision ไม่ถูกผูกกับ HTTP หรือ row shape

Global Layer กับ Local Layer

global layout:

internal/
  controllers/
  services/
  repositories/
  models/

ทำให้ technical role หาได้ง่าย แต่ change ต่อ feature กระจาย และ package กลายเป็น collection ใหญ่ที่ทุก capability import กัน Local layers ภายใน slice รักษา locality:

internal/customer/
  register.go
  http.go
  mysql.go

เมื่อ Customer rich ขึ้นค่อยเพิ่ม domain.go หรือ subpackage persistence จุดตัดสินไม่ใช่ จำนวน layer ใน diagram แต่เป็น cohesion, stable dependency และ cognitive load

ตัวอย่างการลงทะเบียน Customer

Application command ไม่ควรถือ http.Request หรือ MySQL row:

type RegisterCustomer struct {
	TenantID string
	Email    string
	Name     string
}

type CustomerWriter interface {
	EmailExists(ctx context.Context, tenantID, normalizedEmail string) (bool, error)
	Insert(ctx context.Context, customer Customer) error
}

func Register(ctx context.Context, actor Principal, cmd RegisterCustomer, db CustomerWriter) (CustomerID, error) {
	if actor.TenantID != cmd.TenantID || !actor.Can("customer:create") {
		return "", ErrForbidden
	}
	email, err := NormalizeEmail(cmd.Email)
	if err != nil {
		return "", err
	}
	exists, err := db.EmailExists(ctx, cmd.TenantID, email)
	if err != nil {
		return "", err
	}
	if exists {
		return "", ErrEmailAlreadyUsed
	}
	customer := NewCustomer(NewCustomerID(), cmd.TenantID, email, cmd.Name)
	return customer.ID, db.Insert(ctx, customer)
}

ตัวอย่างนี้ยังมี race ระหว่าง EmailExists กับ Insert จึงต้องมี unique constraint (tenant_id, normalized_email) และ map duplicate-key เป็น domain/application conflict การเช็กก่อน insert มีไว้ให้ outcome ชัด แต่ database constraint เป็น final concurrency guard

Layer ไม่สร้าง atomicity ให้เอง

หาก use case เปลี่ยนหลาย record ต้องกำหนด MySQL transaction boundary และใช้ constraint/locking/optimistic version ตาม failure mode อย่าเชื่อว่าการเรียก Repository ผ่าน Service ทำให้ invariant ปลอดภัยโดยอัตโนมัติ

Repository เพิ่มคุณค่าเมื่อใด

Fowler Repository เป็น collection-like boundary ระหว่าง domain กับ data mapping มีประโยชน์เมื่อ domain model/query coordination ซับซ้อน แต่ CRUD module อาจใช้ focused store method หรือ SQL implementation ใกล้ use case ได้โดยตรง

สร้าง Repository เมื่อมัน:

  • ปกป้อง aggregate persistence และ mapping ที่มีความหมาย
  • รวม tenant/soft-delete/version rules อย่างสม่ำเสมอ
  • ให้ application ใช้ domain-oriented operation
  • มีหลาย query/use cases ที่ได้ประโยชน์จาก contract เดียว

อย่าสร้าง generic Repository[T] ที่มี Save, FindAll, Delete เพียงเพราะ template มันอาจเปิด operation ที่ domain ไม่อนุญาตและซ่อน query capability ของ MySQL

Mapping ระหว่าง Transport กับ Error

HTTP adapter ทำเพียง decode, basic syntax validation, principal extraction, application call และ response mapping:

func (h Handler) Register(w http.ResponseWriter, r *http.Request) {
	principal := PrincipalFromContext(r.Context())
	var body registerRequest
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		writeProblem(w, http.StatusBadRequest, "invalid_json")
		return
	}
	id, err := h.register(r.Context(), principal, body.Command())
	writeRegisterResult(w, id, err)
}

JWT claim name, HTTP status และ JSON field อยู่ที่ adapter Principal เป็น application-owned representation ที่มี tenant/subject/assurance/context เท่าที่ use case ต้องการ Domain object ไม่ควรรับ *http.Request

แบบฝึกปฏิบัติ: ทบทวน Local Layer

สร้าง Customer registration slice พร้อม migration ที่มี tenant-scoped unique constraint และ tests 3 ระดับ:

  1. application tests สำหรับ invalid/forbidden/conflict
  2. MySQL integration test สำหรับ constraint และ error mapping
  3. HTTP test สำหรับ JSON/status โดยไม่ retest business rule ทุก branch

จากนั้นตอบว่า CustomerWriter ควรเป็น interface หรือ concrete type หากมี consumer เดียว และ test ใช้ MySQL ได้เร็ว ให้บันทึก trade-off ไม่ต้องรักษา interface เพื่อ mock อย่างเดียว

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

  • Layer แยกตาม knowledge/responsibility ไม่ใช่ชื่อ directory
  • Business rule ไม่อยู่ใน HTTP หรือ SQL mapper
  • Slice รักษา locality ของ feature
  • Repository มีความซับซ้อนที่ควรปกป้องจริง
  • Database constraint ปิด race ที่ application pre-check ปิดไม่ได้
  • Error ถูกแปลที่ boundary โดยไม่รั่ว driver/vendor type

สรุปบทนี้

Layered Architecture มีประโยชน์เมื่อช่วยแยกความรู้และ dependency แต่ global technical folders อาจลด cohesion ได้ เริ่มจาก business slice แล้ววาง roles ภายในเท่าที่จำเป็น MySQL transaction/constraint เป็น correctness mechanism ส่วน layer เป็น code organization

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