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

Transaction Script

ใช้ flow ตรงไปตรงมากับ business logic เรียบง่าย พร้อมรู้สัญญาณว่าเมื่อใด pattern เริ่มรับภาระไม่ไหว

Transaction Script

บางทีมได้ยินว่า anemic model เป็น anti-pattern แล้วเปลี่ยน use case ตั้งค่า notification 3 เงื่อนไขให้มี Entity, Factory, Repository interface, Domain Event และ event handler 10 ไฟล์ การเพิ่ม abstraction ไม่ได้เพิ่มความชัด เพราะกฎจริงยังเป็น validate แล้ว update Transaction Script เป็น pattern ที่เหมาะสมเมื่อ flow ตรงและ boundary ชัด

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

เขียน Transaction Script ที่ใช้ภาษาธุรกิจ แยก transport concern ออกเท่าที่จำเป็น รักษา transaction และ authorization boundary และอ่านสัญญาณ duplication/branching/ invariant leakage เพื่อรู้ว่าเมื่อใดควร evolve

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

ความหมายของ Pattern

Martin Fowler อธิบาย Transaction Script ว่า logic ของแต่ละ transaction/use case ถูกจัดเป็น procedure เดียว ที่อ่านข้อมูล ตัดสินใจ และเขียนผล มันไม่เท่ากับ “โค้ดมั่วใน controller” และไม่ห้ามใช้ Ubiquitous Language

flow พื้นฐานคือ:

authenticate boundary → authorize use case → validate input
→ read authoritative state → decide → write atomically → return outcome

คำว่า transaction ในชื่อ pattern หมายถึง business request หนึ่ง ไม่จำเป็นต้องเท่ากับ database transaction เสมอ แต่ถ้า read/decide/write ต้อง atomic ให้ใช้กลไก datastore อย่างถูกต้อง

Business Slice ขนาดเล็กใน Go

ตัวอย่างตั้งค่า marketing email สำหรับ tenant:

package preference

type Command struct {
	TenantID string
	UserID   string
	Enabled  bool
}

type Store interface {
	UpsertMarketingEmail(ctx context.Context, tenantID, userID string, enabled bool) error
}

func SetMarketingEmail(ctx context.Context, actor Principal, cmd Command, store Store) error {
	if actor.TenantID != cmd.TenantID || !actor.Can("preference:write") {
		return ErrForbidden
	}
	if cmd.TenantID == "" || cmd.UserID == "" {
		return ErrInvalidCommand
	}
	return store.UpsertMarketingEmail(ctx, cmd.TenantID, cmd.UserID, cmd.Enabled)
}

Interface อยู่ฝั่ง use case ที่ consume persistence capability และมี method เท่าที่ต้องใช้ หากมี implementation เดียวและ test ได้ด้วย local database การรับ concrete store ก็อาจง่ายกว่า อย่าสร้าง interface เพราะ template สั่ง

ชื่อ SetMarketingEmail ดีกว่า UpdatePreference เพราะบอก language และ policy scope อย่ารวม security alert ไว้ใน boolean เดียว หากธุรกิจบอกว่า security alert ปิดไม่ได้ มันเป็นคนละ use case/permission แม้ table จะเก็บใกล้กัน

รักษา Script ให้ตรงไปตรงมา

Transaction Script ที่ดีมีขอบเขตอ่านจบ:

  • transport แปลง HTTP เป็น Command และ map error เท่านั้น
  • authorization ตรวจ use-case permission และ tenant scope ก่อน query
  • query มี tenant predicate ไม่โหลดข้าม tenant แล้ว filter ใน memory
  • database operation ที่ต้อง atomic อยู่ใน transaction
  • error แยก invalid, forbidden, conflict และ unavailable ตาม contract
  • log ไม่มี token หรือ sensitive payload และมี correlation ที่ปลอดภัย

Authorization ต้องอยู่ก่อน side effect

ตัวอย่าง permission เป็นเพียงตำแหน่งของ control ชื่อ role/scope และ policy จริงต้องมาจาก security/business owner พร้อม version/date การมี function ชื่อ Can ไม่ได้พิสูจน์ว่า tenant isolation หรือ deny-by-default ถูกต้อง ต้องมี boundary/integration tests

เมื่อ Script เริ่มรับภาระเกินไป

สัญญาณต่อไปนี้บอกว่าควรทบทวน ไม่ได้แปลว่าต้อง rewrite ทันที:

  1. rule เดียวถูก copy ในหลาย script และแก้ไม่พร้อมกัน
  2. state transition มี branch/correction/reversal จนอ่าน flow ไม่จบ
  3. invariant ครอบหลาย operation แต่แต่ละ script ป้องกันไม่เหมือนกัน
  4. parameter list กลายเป็น data bag และ field บางชุดใช้ได้เฉพาะบาง state
  5. test ต้องสร้าง database state ซับซ้อนเพื่อพิสูจน์ decision เล็ก ๆ
  6. external DTO/error กระจายเข้าหลาย use case

เริ่ม refactor จาก concept ที่มีแรงจริง เช่น Value Object สำหรับ Channel, policy function สำหรับ rule ที่ share หรือ Entity ที่ปกป้อง state transition ไม่ต้องย้ายทุกอย่างไป domain/ พร้อมกัน

Transaction และ Concurrency

สมมติ preference มี version เพื่อกัน lost update script สามารถใช้ optimistic check:

UPDATE notification_preferences
SET marketing_email = ?, version = version + 1
WHERE tenant_id = ? AND user_id = ? AND version = ?;

หาก affected rows = 0 ให้คืน conflict และ client อ่านใหม่ วิธีนี้เป็น datastore control ไม่ใช่ Domain Model การเลือก pessimistic/optimistic ต้องมาจาก contention และ failure behavior จริง

Transaction Script ที่เรียก provider ภายนอกไม่ควรเปิด MySQL transaction ค้างระหว่าง network call โดยอัตโนมัติ แยก local state transition, idempotency และ recovery process ตาม boundary ซึ่งจะลงรายละเอียดในบท workflow

แบบฝึกปฏิบัติ: Preference Slice

สร้าง slice ขนาดเล็กที่มี:

internal/preference/
  set_marketing_email.go
  mysql.go
  http.go
  set_marketing_email_test.go

เขียน test อย่างน้อย 5 กรณี: allowed, forbidden, cross-tenant, invalid identity และ storage conflict จากนั้นเพิ่ม requirement ว่า security alert ปิดไม่ได้ ให้ตัดสินว่าจะเพิ่ม condition ใน script หรือแยก command โดยอธิบาย language

ให้ทำ change-impact review: ถ้าเพิ่ม SMS แล้วแก้ไฟล์กี่จุด rule ใดเริ่ม duplicate และ exit criteria จาก Chapter 6 ถูก trigger หรือยัง

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

  • Script ตั้งชื่อตาม use case ไม่ใช่ generic service method
  • Transport ไม่ถือ business rule
  • Permission และ tenant scope มาก่อน side effect
  • Atomicity/concurrency ถูกกำหนดจาก datastore behavior
  • ไม่มี abstraction ที่ยังไม่มี consumer force
  • มีรายการสัญญาณสำหรับ evolve แทนการรอไฟลุก

สรุปบทนี้

Transaction Script เป็น architecture ที่ตั้งใจเลือกได้ ไม่ใช่ความล้มเหลวของ DDD มันเหมาะเมื่อ flow และ rule เรียบง่าย ใช้ Ubiquitous Language ได้เต็มที่ และ evolve แบบเฉพาะจุดเมื่อ duplication, state coupling หรือ invariant density เพิ่มขึ้น

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