ออกแบบ payroll engine ให้เป็นกราฟของ resolver
ทำไม pipeline ที่ mutate state ทำให้บั๊ก payroll ไล่ยาก และการมองการรันเป็น slot ที่มีชื่อและแก้ไม่ได้ ให้ทั้งการรันขนานและการไล่ย้อนพร้อมกัน
ปัญหาของ pipeline แบบ sequential
Payroll engine มักเริ่มเหมือนกันหมด — รันเป็นลำดับ pass ดึง input, คิด base, บวก OT, หัก, คิดภาษี, ใส่ benefits, save โดยทุก pass mutate record ที่ใช้ร่วมกัน
มันใช้ได้ จนถึงจุดหนึ่งก็ใช้ไม่ได้ และจากที่ผมเจอ สิ่งที่พังก่อนไม่ใช่เวลาประมวลผล แต่คือ ความสามารถในการไล่ย้อนกลับ
Mutation ทำให้หาสาเหตุไม่เจอ
พอสลิปออกมาผิด ต้องมีคนเดินย้อนทุก pass เพื่อหาว่า pass ไหนใส่เลขเพี้ยน งานนี้ยากกว่าที่ฟัง เพราะ mutation ทำลายหลักฐานที่ต้องใช้:
- ถ้า pass 3 อ่านค่าผิด บอกไม่ได้ว่า pass 2 เขียนผิด หรือ pass 1 เขียนผิดแล้ว pass 2 ไม่ได้เขียนทับ สถานะสุดท้ายหน้าตาเหมือนกัน
- Reproduce บั๊กต้องรันใหม่ทั้ง pipeline เพราะไม่มีค่ากลางให้ freeze ไว้ตรวจ
ยังมีต้นทุนอีกข้อที่เงียบกว่า — pass ที่ไม่ได้ขึ้นต่อกันจริงก็ยังรันเรียงกัน เพราะ record ที่ mutate ร่วมกันบังคับลำดับที่โดเมนไม่เคยต้องการ
มองการรันเป็นกราฟ
รูปแบบที่ผมใช้คือมอง payroll run เป็น pure function จาก input ไปเป็น resolved record และมอง pipeline เป็นกราฟของ resolver ที่มีชื่อ แต่ละตัว publish ลง slot ที่มี type
inputs ─┐
├─► baseResolver ──► slot:base
├─► overtimeResolver ──► slot:overtime
├─► leaveResolver ──► slot:leave
slot:base ──┐
slot:overtime ──┼─► taxResolver ──► slot:tax
slot:leave ──┘คุณสมบัติ 3 ข้อตามมาเอง:
- Resolver ประกาศ input ของตัวเอง ไม่ล้วงเข้าไปใน object ร่วม แต่ขอ
slot:baseจาก engine แล้วรอจนกว่า slot นั้นจะถูก publish กราฟ dependency เลยชัดและ topological sort ได้ - Slot เขียนแล้วแก้ไม่ได้ ไม่มี resolver ตัวไหนทับผลของตัวอื่นได้ ถ้ามี resolver 2 ตัวจอง slot เดียวกัน มันคือ error ตอน registration ไม่ใช่เซอร์ไพรส์ตอนโปรดักชัน
- Resolver ที่ไม่ขึ้นต่อกันรันขนานได้ engine สั่งรันทุกตัวที่ input พร้อม แทนที่จะไล่ตามลำดับที่โครงสร้างข้อมูลบังคับไว้
ทำไม stream เข้ากับรูปนี้
Reactive stream เป็นเครื่องมือที่ผิดสำหรับ business logic ส่วนใหญ่ และคนที่ระแวงมันก็ถูกแล้ว แต่เคสนี้เข้ากันด้วยเหตุผลเฉพาะ — engine นี้คือการคำนวณแบบ fan-out / fan-in บน stream ของการเปลี่ยนแปลง
เวลามีคนแก้ input ตัวเดียว เช่น override OT หรือเบิกค่าใช้จ่ายย้อนหลัง เราไม่อยากคำนวณใหม่ทั้งชุด เราอยาก invalidate เฉพาะ slot ที่อยู่ใต้ input นั้น คำนวณใหม่ แล้วส่งต่อ ถ้า model slot เป็น multicast subject และ resolver เป็น operator ที่ subscribe input ที่ประกาศไว้ ก็ได้พฤติกรรมนี้มาโดยไม่ต้องเขียนตัวติดตาม invalidation เอง
หลุมคือ hot กับ cold แต่ละ slot ต้อง hot — multicast, ค่าล่าสุดชนะ ส่วน resolver เป็น cold จนกว่า engine จะ subscribe สลับสองอย่างนี้แล้วจะได้ replay ต่อ record ซึ่งพังแบบเห็นชัด หรือได้ค่าค้าง ซึ่งแย่กว่าเพราะเงียบ
หน้าตาของ resolver
Resolver คือ pure function ที่ประกาศ dependency เป็นข้อมูล:
@Injectable()
export class OvertimeResolver implements Resolver<OvertimeInputs, Money> {
readonly slot = "overtime" as const;
readonly inputs = ["base", "shifts", "policy"] as const;
resolve({ base, shifts, policy }: OvertimeInputs): Money {
if (!policy.enabled) return Money.zero(base.currency);
const hours = shifts.reduce(
(total, shift) => total + Math.max(0, shift.hours - policy.threshold),
0,
);
return base.rate.times(hours).times(policy.rate);
}
}ไม่มี database ไม่มีนาฬิกา ไม่มี auth context input เข้า เงินออก บั๊กทุกตัวเลย reproduce ได้จาก object ที่ freeze ไว้ แปลว่ามันกลายเป็น unit test ไม่ใช่งานสืบสวน
สิ่งที่ควรทำตั้งแต่วันแรก
ใส่เวอร์ชันให้ slot structural typing จะปล่อยให้ resolver ปลายทางอ่าน shape เก่าต่อไปหลังคุณขยาย shape และจะไม่มีอะไรเตือนจนกว่าตัวเลขจะผิด ให้ brand แต่ละ slot ด้วย version literal แล้วขยับเลขเมื่อ shape เปลี่ยน
ทำเครื่องมือ replay ตั้งแต่แรก CLI เล็ก ๆ ที่โหลด run ที่ freeze ไว้จาก JSON รัน engine แล้ว diff กับผลที่คาดไว้ คุ้มเร็วมาก การ reproduce บั๊กการคำนวณไม่ควรต้องยกแอปทั้งตัวขึ้นมา
ส่วนที่สำคัญจริง
ไม่ใช่เฟรมเวิร์ก ไม่ใช่กราฟ สิ่งที่สำคัญคือการย้ายจาก mutate state ร่วมกัน ไปเป็น publish ลง slot ที่มีชื่อ พอการไล่หาสาเหตุถูกลง ทั้งการรันขนาน การไล่ย้อน และการคำนวณใหม่บางส่วนก็ตามมาเอง
ถ้าคุณกำลังสร้าง domain engine ที่คนใช้สนใจ ทำไมตัวเลขถึงเป็นแบบนี้ มากกว่ามันมาเร็วแค่ไหน ให้ optimize ข้อนั้นก่อน ที่เหลือตามมาทีหลัง