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 บทบาทพร้อมกัน:
- Validation — ตรวจว่า object ที่มีอยู่แล้วถูกต้องตามกฎหรือไม่
- Selection / querying — เลือก object ที่ตรงเงื่อนไขออกจาก collection หรือแปลงเป็น query ต่อฐานข้อมูล
- 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: ผลลัพธ์ที่กรองแล้ว
ลำดับการทำงานโดยทั่วไป:
- Client สร้าง concrete specification หนึ่งตัวหรือมากกว่า (เช่น
ActiveUserSpec,PriceUnderSpec(100)) - ถ้าต้องการกฎซับซ้อนขึ้น ก็เรียก
.And()/.Or()/.Not()เพื่อประกอบเป็น specification ใหม่ — ตัวใหม่นี้ยังคง implementISpecification<T>เหมือนเดิม จึงส่งต่อได้เหมือน specification ธรรมดา - ส่ง specification ให้ repository (หรือเรียก
.Where(spec.IsSatisfiedBy)บน in-memory collection) - Repository ตัดสินใจว่าจะประเมิน specification แบบ in-memory (วนเช็คทีละ object) หรือแปลงเป็น expression tree เพื่อผลักลงไปเป็น
WHEREclause ในฐานข้อมูล — วิธีหลังมีประสิทธิภาพกว่ามากเมื่อข้อมูลมีจำนวนมาก เพราะไม่ต้องดึงทุกแถวขึ้นมาก่อนค่อยกรอง
ในทางปฏิบัติ library อย่าง Ardalis.Specification เพิ่มความสามารถ paging, sorting และ Include() เข้าไปใน specification เดียวกัน เพื่อให้ query ทั้งชุด (filter + sort + paging) เป็น object เดียวที่ทดสอบได้
ตัวอย่าง code
หัวข้อที่มีชื่อว่า “ตัวอย่าง code”// สัญญาพื้นฐาน — กฎทางธุรกิจข้อเดียวต้องตอบได้ว่า "รายการนี้ผ่านไหม"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);}
// กฎทางธุรกิจข้อเดียว ตั้งชื่อให้สื่อความหมายและอ่านออกเสียงได้เหมือนภาษา domainpublic 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 หรือแปลงเป็น queryvar 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 เลือก “เงื่อนไข”