ข้าม​ไป​ยัง​เนื้อหา

Specification

ห่อ query, sort, paging ไว้​ใน object ที่​ตั้ง​ชื่อ​และ​ทดสอบ​ได้

เวลา code ต้อง​ตัดสิน​ใจ​ว่า “object นี้​ผ่าน​เงื่อนไข​ทาง​ธุรกิจ​หรือ​ไม่” กฎ​เหล่า​นี้​มัก​กระจัดกระจาย​ไป​ทั่ว​ระบบ — เป็น if ซ้อน​กัน​ใน​หลาย service, เป็น LINQ query ที่ copy-paste กัน​ไป​มา, หรือ​ฝัง​อยู่​ใน stored procedure ที่​ไม่มี​ใคร​กล้า​แตะ ปัญหา​คือ​กฎ​เดียวกัน (เช่น “ลูกค้า​คน​นี้​นับ​เป็น active หรือ​ไม่”) ถูก​เขียน​ซ้ำ​ใน​หลาย​ที่​ด้วย​ถ้อยคำ​ต่าง​กัน​เล็กน้อย พอ​แก้​ที่​หนึ่ง​แล้ว​ลืม​แก้​อีก​ที่ ระบบ​ก็​เริ่ม​ขัดแย้ง​กันเอง

Specification pattern แก้​ปัญหา​นี้​ด้วย​การ​ห่อ​กฎ​ทาง​ธุรกิจ​แต่ละ​ข้อ​ไว้​ใน object ที่​มีชื่อ​และ​มี method เดียว​คือ​ถาม​ว่า “รายการ​นี้​ผ่าน​เงื่อนไข​ไหม” (IsSatisfiedBy) แนวคิด​นี้​มา​จาก Eric Evans และ Martin Fowler ใน​บทความ “Specifications” (2002) ซึ่ง​เสนอ​ว่า specification ที่​ดี​ควร​ใช้ได้ 3 บทบาท​พร้อม​กัน:

  1. Validation — ตรวจ​ว่า object ที่​มี​อยู่​แล้ว​ถูกต้อง​ตาม​กฎ​หรือ​ไม่
  2. Selection / querying — เลือก object ที่​ตรง​เงื่อนไข​ออก​จาก collection หรือ​แปลง​เป็น query ต่อ​ฐาน​ข้อมูล
  3. Construction-to-order — บอกว่า object ใหม่​ที่ “ตรง spec” ควร​มี​คุณสมบัติ​อย่างไร

เพราะ specification เป็น object ธรรมดา มัน​จึง​มีชื่อที่​อภิปราย​กับ domain expert ได้​ตรง​กับ​ภาษา Ubiquitous Language เขียน unit test แยก​ต่างหาก​ได้ และ​ที่​สำคัญ​คือประกอบ​กัน​ได้ด้วย​ตรรกะบู​ลีน (And, Or, Not) เพื่อ​สร้าง​กฎ​ที่​ซับซ้อน​ขึ้น​จาก​กฎ​ย่อย​ที่​เรียบ​ง่าย โดย​ไม่​ต้อง​เขียน​เงื่อนไข​ยาวเหยียด​ซ้ำ​ใหม่​ทุก​ครั้ง

หัวใจ​ของ pattern คือ interface เดียว​ที่​มี method เดียว ส่วน concrete specification แต่ละ​ตัว​ก็ implement method นั้น​ตาม​กฎ​ของ​ตัวเอง และ composite specification (AndSpecification, OrSpecification, NotSpecification) ก็ implement interface เดียวกัน — ทำให้​เรียก​ซ้อน​กัน​ได้​ไม่​จำกัด​ชั้น

classDiagram
    class ISpecification~T~ {
      <<interface>>
      +IsSatisfiedBy(T candidate) bool
      +And(ISpecification other) ISpecification
      +Or(ISpecification other) ISpecification
      +Not() ISpecification
    }
    class CompositeSpecification~T~ {
      <<abstract>>
      +And(ISpecification other) ISpecification
      +Or(ISpecification other) ISpecification
      +Not() ISpecification
    }
    class ActiveUserSpec {
      +IsSatisfiedBy(User u) bool
    }
    class PriceUnderSpec {
      -decimal limit
      +IsSatisfiedBy(Product p) bool
    }
    class AndSpecification~T~ {
      -ISpecification left
      -ISpecification right
      +IsSatisfiedBy(T candidate) bool
    }
    class Repository~T~ {
      +List(ISpecification spec) List~T~
    }
    ISpecification <|.. CompositeSpecification
    CompositeSpecification <|-- ActiveUserSpec
    CompositeSpecification <|-- PriceUnderSpec
    CompositeSpecification <|-- AndSpecification
    AndSpecification o-- ISpecification
    Repository o-- ISpecification

ผู้​เล่น​หลัก​ใน​โครงสร้าง:

  • ISpecification<T> — สัญญา​ว่า​จะ​ตอบ​คำถาม IsSatisfiedBy(T) ได้ และ​มัก​มี method ช่วย​ประกอบ (And/Or/Not)
  • Concrete specification (ActiveUserSpec, PriceUnderSpec) — กฎ​ทาง​ธุรกิจ​ข้อ​เดียว ตั้ง​ชื่อ​ให้​สื่อ​ความหมาย
  • Composite specification — ตัว​ห่อ​ที่​รวม specification ย่อย​สอง​ตัว​ขึ้น​ไป​ด้วย​ตรรกะบู​ลีน ทำให้​ต่อ​กฎ​เป็น​สาย​ยาว​ได้
  • ผู้​ใช้งาน (client) — มัก​เป็น Repository หรือ in-memory filter ที่​รับ specification เข้า​มา​แล้ว​นำ​ไป​คัด​กรอง​ข้อมูล

เวลา​ใช้งาน​จริง ฝั่ง caller จะ​ประกอบ specification ย่อย​เป็น​กฎ​เดียว แล้ว​ส่ง​ให้ repository หรือ collection ไป​ประเมิน​ที​ละ​รายการ (หรือ​แปลง​เป็น SQL/LINQ query ถ้า repository รองรับ expression-based specification)

sequenceDiagram
    participant Client
    participant Spec as CompositeSpecification
    participant Repo as Repository
    participant DB as Data Source

    Client->>Spec: activeSpec.And(highValueSpec)
    Client->>Repo: List(spec)
    Repo->>DB: แปลง spec เป็น query หรือวนตรวจทีละ item
    DB-->>Repo: รายการที่ผ่านเงื่อนไข
    Repo-->>Client: ผลลัพธ์ที่กรองแล้ว

ลำดับ​การ​ทำงาน​โดย​ทั่วไป:

  1. Client สร้าง concrete specification หนึ่ง​ตัว​หรือ​มากกว่า (เช่น ActiveUserSpec, PriceUnderSpec(100))
  2. ถ้า​ต้องการ​กฎ​ซับซ้อน​ขึ้น ก็​เรียก .And() / .Or() / .Not() เพื่อ​ประกอบ​เป็น specification ใหม่ — ตัว​ใหม่​นี้​ยัง​คง implement ISpecification<T> เหมือน​เดิม จึง​ส่ง​ต่อ​ได้​เหมือน specification ธรรมดา
  3. ส่ง specification ให้ repository (หรือ​เรียก .Where(spec.IsSatisfiedBy) บน in-memory collection)
  4. Repository ตัดสิน​ใจ​ว่า​จะ​ประเมิน specification แบบ in-memory (วน​เช็ค​ที​ละ object) หรือ​แปลง​เป็น expression tree เพื่อ​ผลัก​ลง​ไป​เป็น WHERE clause ใน​ฐาน​ข้อมูล — วิธี​หลัง​มี​ประสิทธิภาพ​กว่า​มาก​เมื่อ​ข้อมูล​มี​จำนวน​มาก เพราะ​ไม่​ต้อง​ดึง​ทุก​แถว​ขึ้น​มา​ก่อน​ค่อย​กรอง

ใน​ทาง​ปฏิบัติ library อย่าง Ardalis.Specification เพิ่ม​ความ​สามารถ paging, sorting และ Include() เข้าไป​ใน specification เดียวกัน เพื่อ​ให้ query ทั้ง​ชุด (filter + sort + paging) เป็น object เดียว​ที่​ทดสอบ​ได้

// สัญญาพื้นฐาน — กฎทางธุรกิจข้อเดียวต้องตอบได้ว่า "รายการนี้ผ่านไหม"
public interface ISpecification<T>
{
bool IsSatisfiedBy(T candidate);
ISpecification<T> And(ISpecification<T> other);
ISpecification<T> Or(ISpecification<T> other);
ISpecification<T> Not();
}
// abstract class กลาง ให้ concrete spec สืบทอด method ประกอบกฎไปฟรี
public abstract class CompositeSpecification<T> : ISpecification<T>
{
public abstract bool IsSatisfiedBy(T candidate);
public ISpecification<T> And(ISpecification<T> other) => new AndSpecification<T>(this, other);
public ISpecification<T> Or(ISpecification<T> other) => new OrSpecification<T>(this, other);
public ISpecification<T> Not() => new NotSpecification<T>(this);
}
public class AndSpecification<T> : CompositeSpecification<T>
{
private readonly ISpecification<T> _left;
private readonly ISpecification<T> _right;
public AndSpecification(ISpecification<T> left, ISpecification<T> right)
{
_left = left;
_right = right;
}
public override bool IsSatisfiedBy(T candidate) =>
_left.IsSatisfiedBy(candidate) && _right.IsSatisfiedBy(candidate);
}
public class OrSpecification<T> : CompositeSpecification<T>
{
private readonly ISpecification<T> _left;
private readonly ISpecification<T> _right;
public OrSpecification(ISpecification<T> left, ISpecification<T> right)
{
_left = left;
_right = right;
}
public override bool IsSatisfiedBy(T candidate) =>
_left.IsSatisfiedBy(candidate) || _right.IsSatisfiedBy(candidate);
}
public class NotSpecification<T> : CompositeSpecification<T>
{
private readonly ISpecification<T> _inner;
public NotSpecification(ISpecification<T> inner) => _inner = inner;
public override bool IsSatisfiedBy(T candidate) => !_inner.IsSatisfiedBy(candidate);
}
// กฎทางธุรกิจข้อเดียว ตั้งชื่อให้สื่อความหมายและอ่านออกเสียงได้เหมือนภาษา domain
public class ActiveUserSpec : CompositeSpecification<User>
{
public override bool IsSatisfiedBy(User u) => u.IsActive && !u.IsLocked;
}
public class SignedUpBeforeSpec : CompositeSpecification<User>
{
private readonly DateTime _cutoff;
public SignedUpBeforeSpec(DateTime cutoff) => _cutoff = cutoff;
public override bool IsSatisfiedBy(User u) => u.SignUpDate < _cutoff;
}
// ประกอบกฎย่อยเป็นกฎใหม่โดยไม่ต้องเขียนเงื่อนไขซ้ำ
var legacyActiveUsers = new ActiveUserSpec()
.And(new SignedUpBeforeSpec(new DateTime(2023, 1, 1)));
// repository รับ specification แล้วเลือกว่าจะกรอง in-memory หรือแปลงเป็น query
var users = repo.List(legacyActiveUsers);
// ใช้ทดสอบ (validate) object เดี่ยวได้เหมือนกัน โดยไม่ต้องผ่าน repository เลย
bool isEligible = legacyActiveUsers.IsSatisfiedBy(someUser);
  • กฎ​ทาง​ธุรกิจ​ถูก​ใช้​ซ้ำ​ใน​หลาย​ที่ (validation, การ​คัด​กรอง​รายการ, การ​ตัดสิน​ใจ​สร้าง object) และ​เริ่ม copy-paste เงื่อนไข​ไป​มา
  • ต้องการ​ให้​กฎ​มีชื่อที่ domain expert อ่าน​แล้ว​เข้าใจ ตรง​กับ Ubiquitous Language
  • ต้องการ unit test กฎ​แต่ละ​ข้อ​แยก​จาก​กัน โดย​ไม่​ต้อง​พึ่ง​ฐาน​ข้อมูล​จริง
  • ต้องการ​ประกอบ​กฎ​ย่อย​เป็น​กฎ​ซับซ้อน​แบบ​ไดนามิก (เช่น ตัว​กรอง​ค้นหา​ที่​ผู้​ใช้​เลือก​เงื่อนไข​เอง​ได้​หลาย​ข้อ)
  • ใช้​คู่​กับ Repository เพื่อ​แยก “กฎ​ว่า​อะไร​ถูก​เลือก” ออก​จาก “วิธี​ดึง​ข้อมูล”
  • เงื่อนไข​เรียบ​ง่าย ใช้​ที่​เดียว ไม่มี​แนวโน้ม​ถูก​นำ​ไป​ใช้​ซ้ำ — การ​สร้าง class เพิ่ม​จะ​เป็น​ภาระ​เกิน​ความ​จำเป็น (ระวัง Golden Hammer)
  • ทีม​ยัง​ไม่​คุ้น​กับ​การ​อ่าน code ที่​ประกอบ object ซ้อน​กัน​หลาย​ชั้น อาจ​ทำให้ debug ยาก​ขึ้น​ถ้า​ไม่มี​เครื่องมือ​ช่วย (เช่น logging ชื่อ specification ที่ fail)
  • ต้องการ query ประสิทธิภาพ​สูง​ที่​ปรับ​แต่ง​เฉพาะ​ทาง​มาก (index hints, raw SQL) การ​ห่อ​เป็น specification ทั่วไป​อาจ​บดบัง​การ optimize
  • project เล็ก​ที่ query ทั้งหมด​รวม​กัน​ไม่​กี่​จุด — เขียน LINQ ตรงๆ ใน repository method ก็​ชัดเจน​พอแล้ว
ด้านรายละเอียด
ข้อดีกฎ​มีชื่อ​ที่​สื่อ​ความหมาย ทดสอบ​แยก​ได้ ไม่​ต้อง​พึ่ง DB
ข้อดีประกอบ​กฎ​ซับซ้อน​จาก​กฎ​ย่อย​ด้วย And/Or/Not ลด​การ​เขียน​ซ้ำ (DRY)
ข้อดีใช้​กฎ​เดียวกัน​ได้​ทั้ง validation, query และ​การ​สร้าง object (สอดคล้อง Single Responsibility)
ข้อดีเพิ่ม​กฎ​ใหม่​ได้​โดย​ไม่​แก้​ของ​เดิม สอดคล้อง Open/Closed Principle
ข้อ​เสียเพิ่ม​จำนวน class และ layer of indirection ต้อง​มี​เหตุผล​คุ้ม​ค่า​ถึง​จะ​ใช้
ข้อ​เสียถ้า​แปลง​เป็น expression tree เพื่อ query ฐาน​ข้อมูล การ debug error จาก LINQ provider อาจ​ซับซ้อน​ขึ้น
ข้อ​เสียทีม​ต้อง​ตกลง convention การ​ตั้ง​ชื่อ​และ​จัด​วาง specification ไม่​งั้น​จะ​กระจัดกระจาย​เหมือน​ปัญหา​ที่ pattern นี้​พยายาม​แก้
  • Repository — ผู้บริโภค​หลัก​ของ specification เมื่อ​ต้อง​คัด​กรอง​ข้อมูล​จาก​แหล่ง​เก็บ
  • Domain Model — specification มัก​อยู่​คู่​กับ entity/value object ใน domain layer
  • Single Responsibility — กฎ​แต่ละ​ข้อ​ควร​มี​เหตุผล​เดียว​ใน​การ​เปลี่ยนแปลง
  • Open/Closed Principle — เพิ่ม​กฎ​ใหม่​ได้​โดย​ไม่​แก้ specification เดิม
  • DRY — ประกอบ​กฎ​ซ้ำ​ได้​แทน​การ copy เงื่อนไข
  • Strategy — pattern ญาติ​ใกล้​ชิด ต่าง​กัน​ตรง strategy เลือก “พฤติกรรม” ส่วน specification เลือก “เงื่อนไข”