Rules Engine
จัดการกฎธุรกิจซับซ้อนไว้นอก code หลักของ app
ระบบธุรกิจจริงมักมีกฎจำนวนมากที่เปลี่ยนบ่อยกว่า code ส่วนอื่น เช่น เกณฑ์ให้ส่วนลด กฎอนุมัติสินเชื่อ กฎคำนวณค่าคอมมิชชัน หรือกฎกรองอีเมล ถ้าเขียนกฎเหล่านี้เป็น if/else หรือ switch ซ้อนกันใน code หลัก จะเกิดปัญหาสองอย่างพร้อมกัน — code ยิ่งเพิ่มกฎก็ยิ่งซับซ้อนจนอ่านไม่ออก (ดู Conditional Complexity) และทุกครั้งที่กฎธุรกิจเปลี่ยน นักพัฒนาต้องแก้ แก้ compile แล้ว deploy ใหม่ ทั้งที่ตัวตรรกะการไหลของโปรแกรมไม่ได้เปลี่ยนเลย
Rules Engine แก้ปัญหานี้ด้วยการ แยกการนิยามกฎ (what) ออกจากตัวประเมิน (how) engine ทำหน้าที่เดียวคือรับ input มาวนถามกฎแต่ละข้อว่า “ใช้ไหม” แล้วรวมผลลัพธ์ ตัวกฎแต่ละข้อถูกห่อหุ้มเป็น object อิสระที่เพิ่ม ลบ แก้ไขได้โดยไม่ต้องแตะ engine เลย — engine ไม่จำเป็นต้องรู้จักกฎแต่ละข้อเป็นรายตัว จึงสอดคล้องกับ Open-Closed Principle โดยตรง: เปิดให้ขยาย (เพิ่มกฎใหม่) แต่ปิดการแก้ไข (engine เดิมไม่ต้องเปลี่ยน)
แนวคิดนี้มีรากมาจาก Production Rule System ในสาย AI/expert system แบบดั้งเดิม — ชุดกฎรูปแบบ IF condition THEN action ที่ inference engine เลือกลำดับการประเมินเอง (มักเป็น forward chaining) ไม่ใช่ลำดับที่โปรแกรมเมอร์เขียนไว้ตายตัว เอนจินระดับ enterprise อย่าง Drools หรือ Rete algorithm ใช้แนวคิดนี้เพื่อให้จับคู่กฎกับ fact ได้เร็วแม้มีกฎหลายพันข้อ แต่ในระดับ application ทั่วไป มักพอแค่ทำ Rules Engine แบบเรียบง่าย — วนกฎทีละข้อแบบ imperative ธรรมดา ไม่ต้องมี inference engine เต็มรูปแบบ
โครงสร้าง
หัวข้อที่มีชื่อว่า “โครงสร้าง”Rules Engine มีสามองค์ประกอบหลัก: RulesEngine (ตัวประสาน), Rule (สัญญาของกฎแต่ละข้อ พร้อม concrete rule หลายตัว), และ Input/Context (ข้อมูลที่กฎถูกนำไปใช้ประเมิน)
classDiagram
class Input {
+data
}
class IRule {
+Applies(Input) bool
+Apply(Input) Result
}
class RulesEngine {
-List~IRule~ rules
+Execute(Input) Result
}
class DiscountRule {
+Applies(Input) bool
+Apply(Input) Result
}
class LoyaltyRule {
+Applies(Input) bool
+Apply(Input) Result
}
class ShippingRule {
+Applies(Input) bool
+Apply(Input) Result
}
RulesEngine o-- IRule
RulesEngine ..> Input
IRule <|.. DiscountRule
IRule <|.. LoyaltyRule
IRule <|.. ShippingRule
จุดสำคัญของโครงสร้างนี้คือ RulesEngine ผูกกับ IRule ผ่าน interface เดียว ไม่ผูกกับ DiscountRule, LoyaltyRule, ShippingRule โดยตรง — เพิ่มกฎใหม่คือเพิ่ม class ที่ implement IRule แล้วลงทะเบียนเข้า collection เท่านั้น (มักผ่าน Dependency Injection)
การทำงาน
หัวข้อที่มีชื่อว่า “การทำงาน”ลำดับการทำงานมาตรฐานคือ: caller ส่ง input เข้า engine → engine วน iterate กฎทุกข้อ → ถามแต่ละกฎว่า Applies กับ input นี้หรือไม่ → ถ้าใช่ ให้กฎนั้นประมวลผลและคืนค่า/ผลข้างเคียง → engine รวมผลลัพธ์ทั้งหมดแล้วส่งกลับ
sequenceDiagram
participant Client
participant Engine as RulesEngine
participant R1 as Rule A
participant R2 as Rule B
Client->>Engine: Execute(input)
Engine->>R1: Applies(input)
R1-->>Engine: true
Engine->>R1: Apply(input)
R1-->>Engine: result A
Engine->>R2: Applies(input)
R2-->>Engine: false
Engine-->>Client: รวมผลลัพธ์ (result A)
สังเกตว่ากฎ B ถูกถามแต่ไม่ถูกเรียกใช้เพราะ Applies คืน false — engine ไม่จำเป็นต้องรู้เหตุผลว่าทำไมกฎแต่ละข้อใช้หรือไม่ใช้ นี่คือจุดที่ทำให้เพิ่ม/ลบ/สลับกฎทำได้อย่างอิสระ ข้อควรระวังตามที่ Martin Fowler เตือนไว้คือ rule chaining — ถ้ากฎข้อหนึ่งแก้ state แล้วไปเปลี่ยนเงื่อนไขของกฎข้ออื่นที่ยังไม่ถูกประเมิน จะเกิด “implicit program flow” ที่ตามรอยยาก ทางแก้ทั่วไปคือจำกัดให้กฎแต่ละข้อเป็นอิสระต่อกัน (ไม่แก้ input ระหว่างทาง) หรือกำหนดลำดับความสำคัญ (priority) ชัดเจนถ้าจำเป็นต้องมีลำดับ
ตัวอย่าง code
หัวข้อที่มีชื่อว่า “ตัวอย่าง code”ตัวอย่างนี้ขยายจากกฎส่วนลดออเดอร์ธรรมดา ให้เห็นทั้ง interface ของกฎ, กฎรูปธรรมหลายข้อ, และ engine ที่ประกอบกฎเข้าด้วยกันผ่าน DI:
// สัญญาของกฎหนึ่งข้อ: ตรวจว่าใช้ได้ไหม (Applies) และคำนวณผล (Discount)public interface IDiscountRule{ bool Applies(Order order); decimal Discount(Order order); string Reason { get; }}
// กฎ: ออเดอร์เกิน 1,000 บาท ลด 5%public class BulkOrderDiscountRule : IDiscountRule{ public string Reason => "ส่วนลดยอดสั่งซื้อขั้นต่ำ";
public bool Applies(Order order) => order.Total >= 1000m;
public decimal Discount(Order order) => order.Total * 0.05m;}
// กฎ: ลูกค้าระดับ VIP ลดเพิ่มคงที่ 100 บาทpublic class VipCustomerDiscountRule : IDiscountRule{ public string Reason => "ส่วนลดลูกค้า VIP";
public bool Applies(Order order) => order.Customer.Tier == CustomerTier.Vip;
public decimal Discount(Order order) => 100m;}
// กฎ: ช่วงเทศกาล ลดเพิ่ม 10% แต่ไม่เกิน 300 บาทpublic class SeasonalPromotionRule : IDiscountRule{ private readonly IClock _clock; public SeasonalPromotionRule(IClock clock) => _clock = clock;
public string Reason => "โปรโมชันเทศกาล";
public bool Applies(Order order) => _clock.Today is { Month: 12 };
public decimal Discount(Order order) => Math.Min(order.Total * 0.10m, 300m);}
// ผลลัพธ์ที่รวมส่วนลดพร้อมเหตุผล เพื่อ audit ย้อนหลังได้public record DiscountResult(decimal TotalDiscount, IReadOnlyList<string> AppliedReasons);
// RulesEngine ไม่รู้จักกฎแต่ละข้อ รู้แค่ว่ามันคือ IEnumerable<IDiscountRule>public class DiscountRulesEngine{ private readonly IEnumerable<IDiscountRule> _rules;
public DiscountRulesEngine(IEnumerable<IDiscountRule> rules) => _rules = rules;
public DiscountResult Evaluate(Order order) { var applicable = _rules.Where(r => r.Applies(order)).ToList();
var total = applicable.Sum(r => r.Discount(order)); var reasons = applicable.Select(r => r.Reason).ToList();
return new DiscountResult(total, reasons); }}
// การประกอบ (composition root) — เพิ่มกฎใหม่คือเพิ่มบรรทัดเดียวตรงนี้ ไม่แตะ engineservices.AddScoped<IDiscountRule, BulkOrderDiscountRule>();services.AddScoped<IDiscountRule, VipCustomerDiscountRule>();services.AddScoped<IDiscountRule, SeasonalPromotionRule>();services.AddScoped<DiscountRulesEngine>();เมื่อไหร่ควรใช้
หัวข้อที่มีชื่อว่า “เมื่อไหร่ควรใช้”- มีกฎธุรกิจจำนวนมากที่เปลี่ยนบ่อยกว่า code ส่วนอื่น และแต่ละกฎอิสระต่อกันได้ชัดเจน
- ต้องการเพิ่ม/ปิดกฎโดยไม่แตะหรือ deploy code หลักซ้ำ (เช่น เปิด/ปิดโปรโมชันตามฤดูกาล)
- code conditional เดิมเริ่มมีกลิ่น Conditional Complexity หรือ Combinatorial Explosion จากการรวมเงื่อนไขหลายมิติ
- ต้องการ audit trail ว่ากฎข้อไหนถูกใช้กับข้อมูลไหน (เช่น เหตุผลการอนุมัติสินเชื่อ)
- ทีมมี regression test ครอบกฎแต่ละข้อ ทำให้มั่นใจได้ว่าเพิ่มกฎใหม่ไม่กระทบกฎเดิม
เมื่อไหร่ไม่ควรใช้
หัวข้อที่มีชื่อว่า “เมื่อไหร่ไม่ควรใช้”- มีกฎเพียงไม่กี่ข้อ (2-3 ข้อ) และไม่ค่อยเปลี่ยน —
if/elseธรรมดาอ่านง่ายกว่าและไม่ต้องมี layer เพิ่ม (ดู YAGNI และ KISS) - กฎมีลำดับการทำงานที่ต้องพึ่งพากันชัดเจน (กฎ B ต้องรันหลังกฎ A เสมอ) — ควรพิจารณา Chain of Responsibility หรือ pipeline แทน เพราะ Rules Engine สื่อว่ากฎเป็นอิสระต่อกัน
- ต้องการนำเข้าเครื่องมือ commercial rules engine เต็มรูปแบบทั้งที่ปัญหาจริงแคบมาก — Fowler เตือนว่าคำมั่นสัญญา “ให้ business user เขียนกฎเองได้” มักไม่เป็นจริงในทางปฏิบัติ
- ทีมไม่มีวินัยเขียน test ครอบกฎแต่ละข้อ เพราะ engine ที่ประเมินกฎแบบไดนามิกจะ debug ยากกว่า code imperative ธรรมดาเมื่อไม่มี test คุ้มกัน
ข้อดีและข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีและข้อเสีย”| ด้าน | รายละเอียด |
|---|---|
| ข้อดี | สนับสนุน OCP — เพิ่มกฎโดยไม่แก้ engine |
| ข้อดี | แต่ละกฎ test แยกหน่วยได้ (TDD) และอ่านเจตนาชัดเจนกว่า conditional ซ้อนลึก |
| ข้อดี | ลดการเปลี่ยนแปลงกระจาย (แก้กฎเดียว ไม่ต้องไล่แก้หลายจุด) เทียบกับ Shotgun Surgery |
| ข้อเสีย | เพิ่ม indirection — ตามรอยว่ากฎไหนทำงานเมื่อไหร่ยากกว่าอ่าน if/else ตรง ๆ |
| ข้อเสีย | ถ้ากฎ chain กันหรือมีผลข้างเคียงเปลี่ยน state จะเกิด implicit flow ที่ debug ยาก |
| ข้อเสีย | ถ้าใช้ commercial rules engine เต็มรูปแบบ มีต้นทุนเรียนรู้ ต้นทุน infrastructure และความคาดหวังผิด ๆ ว่า business user จะเขียนกฎเองได้ |
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Specification
- Strategy
- Chain of Responsibility
- Open-Closed Principle
- Conditional Complexity
- Dependency Injection
แหล่งอ้างอิง
หัวข้อที่มีชื่อว่า “แหล่งอ้างอิง”- ที่มา · deviq.com/design-patterns/rules-engine-pattern
- Rules Engine — Martin Fowler’s Bliki
- Business rules engine — Wikipedia
- Forward chaining — Wikipedia
- C# Design Patterns: Rules Engine Pattern — Geetha, Medium
- Using the Specification Pattern to Build a Data-Driven Rules Engine — Jon Blankenship, Medium