Implementing Simple Business Logic
องค์กรประกอบด้วยหน่วยงานย่อย ๆ หลากหลายส่วน หากนิยามหน่วยงานย่อย ๆ เหล่านั่นว่า “Subdomain” การที่องค์กรจะทำงานเพื่อให้บรรลุผลสำเร็จตามเป้าหมาย ต้องอาคัยแรงผลักดันจาก Subdomain ดังนั้น Subdomain จึงมี Strategic Implementation และ Complexity ที่แตกต่างกัน

ในเชิงของการพัฒนา Software ควรเลือก Patterns ให้เหมาะสมกับกลยุทธ์และความซับซ้อนของงาน บางระบบทำหน้าที่ Supporting คอยสนับสนุนการทำงานของ Core ไม่มีความซับซ้อนอะไรมากมาย เช่น ระบบให้แอดมิน สร้าง ตรวจสอบ อัพเดทหรือลบข้อมูลลูกค้า (CRUD) จะใช้ Patterns อย่าง Service Layer, Unit Of Work Pattern หรือ Aggregate Pattern ก็ดูจะหาคนทำงานด้วยยากไปหน่อย หนึ่งใน Domain Logic Patterns ที่ได้รับความนิยมในการนำมาปรับใช้กับ Simple Business Logic คือ Transaction Script ลุยโลดดด!!
Transaction Script
การทำงานกับฐานข้อมูลต้องทำผ่านภาษา SQL ยกตัวอย่าง ระบบฝากเงิน ลูกค้ากรอกข้อมูลบัญชีและใส่จำนวนเงินที่ต้องการนำฝาก กดปุ่มยืนยัน ระบบ SELECT ข้อมูล Account เพื่อตรวจสอบเงื่อนไขต่าง ๆ หลังจากนั่นทำการ UPDATE จำนวนเงินและ INSERT ประวัติการทำรายการ คำสั่ง SELECT, INSERT, UPDATE และ DELETE คือภาษา SQL เพื่อให้แน่นใจว่าจะไม่เกิดเหตุการณ์ที่ระบบทำงานผิดพลาดจนส่งผลกระทบให้มีประวัติการทำรายการ แต่ยอดเงินลูกค้าไม่เพิ่ม เราจำเป็นต้องรวมคำสั่ง SQL ทั้งหมดเข้าด้วยกันและเรียกสิ่งนี้ว่า Transaction หลักการของ Transaction คือ หากเกิดปัญหาในขั้นตอนใดขั้นตอนหนึ่งจะต้อง Undo Change ทั้งหมด ภาษาชาวบ้านเรียกว่า เอาทั้งหมดหรือไม่เอาเลย (all or nothing)
Transaction Script (TS) คือรูปแบบที่นำหลักการของ Transaction มา Implementation ในระดับของการเขียนโค้ดผ่านภาษาหรือ Framework ต่างๆ ถ้าย้อนกลับไปที่ระบบฝากเงิน ขั้นตอนทั้งหมดของการฝากเงินตั้งแต่การเข้าถึงข้อมูลไปจนถึงการตรวจสอบ Business Rule เช่น การตรวจสอบสถานะบัญชี Active หรือ InActive ตรวจสอบยอดเงินนำฝากเกินวงเงินในการทำรายการสูงสุดต่อวันหรือไม่ UPDATE และ INSERT ข้อมูลหลังผ่านเงื่อนไขที่ทางธนาคารกำหนด ข้อดีของ TS คือความเรียบง่าย การจัดระเบียบโค้ดทางธุรกิจให้เป็น ขั้นตอนตามลำดับ (procedural) เป็นเหมือนสคริปต์หนึ่งชุดที่จัดการทุกอย่างตั้งแต่ต้นจนจบ
func (h *Handler) Deposit(c *gin.Context) {
var req DepositRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"message": "Account no and ammount is required.",
})
return
}
customerId, _ := c.Get("customer_id")
account := pg.Account{}
if err := h.D.Postgresql.FindOne(&account, "account_no = ? and customer_id = ?", req.AccountNo, customerId); err != nil {
c.JSON(http.StatusForbidden, gin.H{
"message": "Account not found.",
})
return
}
if account.Status != "active" {
c.JSON(http.StatusForbidden, gin.H{
"message": "Account not allowed to deposit.",
})
return
}
if err := h.D.Postgresql.Transaction(func(tx pg.IPostgresql) error {
account.Balance += req.Amount
if err := tx.Updates(&account); err != nil {
return err
}
transaction := pg.Transaction{
Type: "deposit",
CustomerId: account.CustomerId,
Data: datatypes.JSON([]byte(
`{"account_no": "` + req.AccountNo + `", "amount": ` + fmt.Sprintf("%.2f", req.Amount) + `}`,
)),
}
if err := tx.Create(&transaction); err != nil {
return err
}
return nil
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"message": "Internal server error.",
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Deposit successful.",
})
}
อย่างไรก็ตาม เมื่อ Business Rule มีความซับซ้อนมากขึ้น การจะทำให้ตรรกะทางธุรกิจอยู่ในสถานะที่ได้รับการออกแบบอย่างดีก็จะยากขึ้นเรื่อยๆ ระบบธนาคารไม่ได้มีแค่การฝากเงิน ยังมีส่วนของถอนเงิน โอนเงิน นั่นหมายความว่าถ้าเราใช้ TS จะเกิด Dupication กระจายไปตามส่วนต่างๆ ของระบบ เป็น Trade Off ที่แลกมากับความเรียบง่าย ยกตัวอย่างส่วนที่เห็นได้ชัด คือการดึงข้อมูลและการตรวจสอบสถานะของ Account ขั้นตอนการเพิ่มลดจำนวนเงินใน Balance และการสร้างประวัติการทำรายการ
สำหรับวิธีในการลด Duplication มีหลากหลาย Patterns ที่สามารถนำมาแก้ปัญหาได้ เช่น Data Mapper, Repository pattern หรือ Domain Model เนื่องจากบทความนี้เราพูดถึง Simple Business Logic ขอยกตัวอย่างวิธีที่ Simple อย่าง Active Record แล้วกัน
Active Record/ Table Module
Active Record Pattern คือการสร้าง Object ที่ใช้อ้างอิงถึง Row ของ Table ใน Database แต่ละ Object แทน 1 Row จากนั่นจะย้ายหรือเพิ่ม Logic เช่น Validation หรือ Business Rule ต่างๆ เข้าไปใน Object แทน
กลับมาที่โค้ดตัวอย่างการฝากเงิน ระบบต้องดึงข้อมูล Account เพื่อตรวจสอบ Account Status ก่อนจะดำเนินการทำรายงาน หาก Account ที่ดึงมามีสถานะ InActive ก็จะไม่สามารถทำรายการได้
if account.Status != "active" {
c.JSON(http.StatusForbidden, gin.H{
"message": "Account not allowed to deposit.",
})
return
}
เราสามารถสร้าง Object ขึ้นมาอ้างอิงถึง Table Account จากนั่น ทำการย้ายเงื่อนไขการตรวจสอบสถานะและ Behavior อื่น ๆ ที่เกี่ยวข้องกับ Account เข้าไปเก็บไว้ใน Object เพื่อ Encapsulates หรืออีกแนวทางหนึ่งที่นิยมในปัจจุบันคือ ใช้ Library หรือ Framework ที่รองรับ Object Relational Mapping (ORM) ก็เป็นอีกหนึ่งที่เลือกในการ Refactoring ในตัวอย่างคือภาษา Go ที่ใช้ Library ชื่อว่า Gorm https://gorm.io/
func (a *Account) IsNotActive() bool {
return a.Status != "active"
}
// deposit and withdraw
if account.IsNotActive() {
.....
return
}
// transfer
if fromAccount.IsNotActive() {
...
return
}
if toAccount.IsNotActive() {
....
return
}
ส่วนนี้เป็นแค่ตัวอย่างง่าย ๆ ของการ Refactor code จาก Transaction Script ไปเป็น Active Record หากคุณทำงานกับรูปแบบ TS ความท้าทายก่อนจะ Refractor คือ คุณจะแน่ใจได้อย่างไรว่า Behavior ก่อนและหลัง Refactor จะเหมือนเดิม สิ่งที่สำคัญกว่าหากระบบที่คุณดูแลไม่มี Automate testing คุณควรเริ่มด้วยการเขียน Test ก่อน Refactor
Conclusion
- Transaction script: เรียบง่าย ทำงานเป็นขั้นตอนและตรงไปตรงมาเหมาะสำหรับแอปพลิเคชันขนาดเล็กที่ไม่ได้ใช้ตรรกะที่ซับซ้อนใด ๆ ช่วยให้แน่ใจว่า Read, Write, Update หรือ Delete หากเกิดปัญหาในขั้นตอนใดขั้นตอนหนึ่งจะต้อง Undo Change
- Active record: การใช้รูปแบบ TS จะมีปัญหา เช่น โค้ดและฟังก์ชัน Dupication ระหว่างขั้นตอนต่างๆ การร่วม Behavior และเงื่อนไขต่างๆ ที่เกี่ยวข้องกับ Record ที่อ้างอิงถึง Table ของฐานข้อมูล ไว้ใน Object สามารถลดปัญหาเหล่านี้ได้
ทั้ง 2 Patterns มุ่งเน้นไปที่กรณีของ Simple Business Logic ในทางกลับกันหากเป็นกรณีของ Complex Business อาจจะลองศึกษา Patterns อื่นๆ เพิ่มเติมเพื่อให้เหมาะสมกับระบบงานของคุณ
สำหรับใครที่สนใจ Clone Project ไปดูเล่นๆ สามารถเข้าไปที่ Github หรือทำตามขั้นตอนด้านล่างได้เลย
```bash
0. git clone https://github.com/heyboonsong/transaction-script-and-active-record-example.git
1. docker-compose up -d
2. git checkout <branch> // transaction-script or active-record
3. go test -v ./...
4. go run main.go
อ้างอิง
[Patterns of Enterprise Application Architecture](https://learning.oreilly.com/library/view/patterns-of-enterprise/0321127420/)
[Learning Domain-Driven Design](https://learning.oreilly.com/library/view/learning-domain-driven-design/9781098100124/)