บทที่ 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 ทันที:
- rule เดียวถูก copy ในหลาย script และแก้ไม่พร้อมกัน
- state transition มี branch/correction/reversal จนอ่าน flow ไม่จบ
- invariant ครอบหลาย operation แต่แต่ละ script ป้องกันไม่เหมือนกัน
- parameter list กลายเป็น data bag และ field บางชุดใช้ได้เฉพาะบาง state
- test ต้องสร้าง database state ซับซ้อนเพื่อพิสูจน์ decision เล็ก ๆ
- 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 เพิ่มขึ้น
อ่านเพิ่มเติม
- Transaction Script — catalog โดย Martin Fowler
- Domain Logic and SQL — discussion เรื่องต้นทุนของ domain logic patterns
- Go Code Review Comments: Interfaces — consumer-side interface guidance
- Executing transactions — official Go database transaction guide