Repository
abstraction ของ persistence ที่มี interface คล้าย collection
code ที่คุยกับฐานข้อมูลตรง ๆ — เปิด connection เขียน SQL แล้วแปลงผลลัพธ์เป็น object เอง — มักกระจัดกระจายอยู่ทั่วชั้น application จนตรรกะ query ซ้ำกันหลายที่ (ผิดหลัก DRY) และ domain object ผูกติดกับเทคโนโลยีจัดเก็บข้อมูลจนทดสอบยาก แก้ยาก
Repository (เสนอครั้งแรกในหนังสือ DDD ของ Eric Evans ปี 2003 และปรากฏใน Patterns of Enterprise Application Architecture ของ Martin Fowler ด้วย) แก้ปัญหานี้ด้วยการให้ abstraction ของการเก็บข้อมูล ที่มี interface คล้าย collection ในหน่วยความจำ — เพิ่ม ลบ อัปเดต และเลือกข้อมูลผ่าน method ตรงไปตรงมา โดยไม่ต้องยุ่งกับ connection, command หรือ reader ของฐานข้อมูล Fowler นิยามไว้ว่า repository “ทำหน้าที่เป็นตัวกลางระหว่างชั้น domain กับชั้น data mapping โดยทำตัวคล้ายกับ collection ของ domain object ในหน่วยความจำ”
ผลคือเกิด loose coupling และทำให้ domain object เป็น persistence ignorant — domain ไม่จำเป็นต้องรู้ว่าข้อมูลถูกเก็บใน SQL Server, PostgreSQL, เอกสาร JSON หรือแม้แต่ file ในหน่วยความจำระหว่างเทส ในบริบท DDD repository มักทำงานที่ระดับ aggregate root เท่านั้น — 1 repository ต่อ1 aggregate ไม่ใช่หนึ่งต่อหนึ่งตาราง เพราะ aggregate root เป็นผู้ควบคุม invariant และ transactional consistency ของทั้งกลุ่ม
ข้อควรระวัง: อย่าปลอม repository เป็นเพียง wrapper บาง ๆ ที่ห่อ ORM type ตรง ๆ (เช่น Repository<TEntity> ที่ expose IQueryable ออกไปทั้งดุ้น) เพราะนั่นแค่ย้ายที่เขียน query ไม่ได้ซ่อน implementation detail ที่แท้จริง และยังทำให้ผู้เรียกสร้าง query ที่ทำลาย invariant ของ aggregate ได้
โครงสร้าง
หัวข้อที่มีชื่อว่า “โครงสร้าง”domain พึ่งพา interface คล้าย collection เท่านั้น — ไม่รู้จักฐานข้อมูล implementation ที่คุยกับ ORM หรือ SQL จริงอยู่ในชั้น infrastructure และ implement interface นั้นย้อนกลับ (ตาม Dependency Inversion Principle)
classDiagram
class Client {
+DoWork()
}
class IOrderRepository {
+GetById(id) Order
+Add(order)
+List(spec) List
}
class Order {
+Id
+Lines
}
class EfOrderRepository {
-DbContext context
+GetById(id) Order
+Add(order)
+List(spec) List
}
class ISpecification {
+IsSatisfiedBy(order) bool
}
Client --> IOrderRepository
IOrderRepository ..> Order
IOrderRepository <|.. EfOrderRepository
IOrderRepository ..> ISpecification
ผู้เข้าร่วมหลัก:
- Client — application service หรือ command handler ที่ต้องการโหลด/บันทึก aggregate
- IOrderRepository — interface คล้าย collection ที่ประกาศไว้ในชั้น domain (หรือ application layer) เห็นเฉพาะ method ที่มีความหมายทางธุรกิจ
- Order — aggregate root ที่ repository รับผิดชอบ
- EfOrderRepository — implementation จริงในชั้น infrastructure ที่คุยกับ Entity Framework หรือ ORM/driver อื่น
- ISpecification — (ทางเลือก) ใช้ร่วมกับ Specification เพื่อประกาศเงื่อนไข query แบบ declarative แทนการเปิด parameter เพิ่มเรื่อย ๆ
การทำงาน
หัวข้อที่มีชื่อว่า “การทำงาน”เมื่อ application service ต้องการเปลี่ยนแปลงข้อมูล มันจะไม่คุยกับฐานข้อมูลเอง แต่ขอ aggregate ผ่าน repository, แก้ไข in-memory object ตามตรรกะ domain แล้วส่งกลับให้ repository บันทึก ลำดับทั่วไปเป็นดังนี้:
sequenceDiagram
participant Client
participant Repo as IOrderRepository
participant Ctx as DbContext
participant DB as ฐานข้อมูล
Client->>Repo: GetById(orderId)
Repo->>Ctx: Query + Include aggregate
Ctx->>DB: SELECT ...
DB-->>Ctx: แถวข้อมูล
Ctx-->>Repo: Order entity
Repo-->>Client: Order aggregate
Client->>Client: order.Ship() (ตรรกะ domain)
Client->>Repo: Add หรือ Update(order)
Repo->>Ctx: บันทึกการเปลี่ยนแปลง
Ctx->>DB: INSERT/UPDATE (มักผ่าน Unit of Work)
จุดสำคัญคือ repository คืน domain object ที่โหลดครบ aggregate boundary (เช่น Order พร้อม OrderLines ทั้งหมด) ไม่ใช่ DTO บางส่วน และการบันทึกจริงลงฐานข้อมูลมักถูกเลื่อนออกไปจนกว่า Unit of Work จะ commit transaction เดียวสำหรับการเปลี่ยนแปลงทั้งหมดในคำสั่งนั้น — ทำให้ repository กับ unit of work ทำงานประกอบกันโดยธรรมชาติ ในหลาย ORM (เช่น EF Core DbContext) ทั้ง2 pattern ถูก implement ปนกันอยู่แล้วผ่าน change tracker และ SaveChanges
ตัวอย่าง code
หัวข้อที่มีชื่อว่า “ตัวอย่าง code”interface คล้าย collection ที่ประกาศไว้ใน domain ใช้ generic constraint บังคับว่าใช้ได้เฉพาะกับ aggregate root:
// อยู่ในชั้น domain — ไม่มีการอ้างอิงถึง EF หรือ ORM ใด ๆpublic interface IRepository<T> where T : IAggregateRoot{ Task<T> GetByIdAsync(int id); Task AddAsync(T entity); Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec);}
// สัญญาเฉพาะของ aggregate นี้ ต่อยอดจาก generic contractpublic interface IOrderRepository : IRepository<Order>{ Task<Order> GetByIdWithLinesAsync(int orderId);}implementation จริงอยู่ชั้น infrastructure คุยกับ Entity Framework:
public class EfOrderRepository : IOrderRepository{ private readonly AppDbContext _context;
public EfOrderRepository(AppDbContext context) => _context = context;
public async Task<Order> GetByIdAsync(int id) => await _context.Orders.FindAsync(id);
// โหลด aggregate ให้ครบขอบเขต — รวม child entity ที่จำเป็น public async Task<Order> GetByIdWithLinesAsync(int orderId) => await _context.Orders .Include(o => o.OrderLines) .FirstOrDefaultAsync(o => o.Id == orderId);
public async Task AddAsync(Order entity) { _context.Orders.Add(entity); await _context.SaveChangesAsync(); // ในระบบจริงมักปล่อยให้ Unit of Work จัดการ commit แทน }
public async Task<IReadOnlyList<Order>> ListAsync(ISpecification<Order> spec) => await SpecificationEvaluator.GetQuery(_context.Orders, spec).ToListAsync();}การใช้งานจาก application service — ไม่รู้จัก EF เลยแม้แต่น้อย:
public class ShipOrderHandler{ private readonly IOrderRepository _orders;
public ShipOrderHandler(IOrderRepository orders) => _orders = orders;
public async Task Handle(ShipOrderCommand command) { var order = await _orders.GetByIdWithLinesAsync(command.OrderId); order.Ship(); // ตรรกะ domain อยู่ใน aggregate root ไม่ใช่ใน handler await _orders.AddAsync(order); }}เมื่อไหร่ควรใช้
หัวข้อที่มีชื่อว่า “เมื่อไหร่ควรใช้”- domain model มีความซับซ้อน มี business rule จำนวนมากที่ควรแยกจากรายละเอียดการเข้าถึงข้อมูล
- ต้องการทดสอบ business logic แบบ unit test โดยไม่แตะฐานข้อมูลจริง (mock/fake repository แทน)
- มี query logic ซ้ำ ๆ กระจายอยู่หลายที่ ต้องการรวมศูนย์ไว้ที่เดียว
- ต้องการรองรับหลาย persistence mechanism (เช่น สลับจาก SQL Server ไป in-memory store สำหรับเทส) โดยไม่แตะ domain
- ทำงานแบบ DDD และต้องการบังคับว่าการเขียนข้อมูลทุกครั้งผ่าน aggregate root เท่านั้น
เมื่อไหร่ไม่ควรใช้
หัวข้อที่มีชื่อว่า “เมื่อไหร่ไม่ควรใช้”- application เล็ก มี CRUD ตรงไปตรงมา ไม่มี business rule ซับซ้อนพอจะคุ้มกับ abstraction เพิ่ม
- ฝั่ง query/read ที่ใช้แนวทาง CQRS อยู่แล้ว — การ query แบบอ่านอย่างเดียวมักคุ้มกว่าถ้ายิง SQL หรือ Dapper ตรง ๆ โดยไม่ผ่าน repository เพราะไม่กระทบ invariant
- ทีมใช้ ORM ที่มี abstraction ระดับ persistence-ignorant อยู่แล้ว (เช่น EF Core
DbContextทำหน้าที่ repository + unit of work ในตัว) และการเพิ่ม repository ชั้นเดียวซ้ำจะกลายเป็นแค่ wrapper ที่ไม่ได้ซ่อนอะไรเพิ่ม - ต้องการความยืดหยุ่นสูงในการ join/query ข้ามหลาย aggregate — repository ที่ผูกกับ aggregate เดียวจะจำกัดความสามารถนี้
ข้อดีและข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีและข้อเสีย”| ด้าน | รายละเอียด |
|---|---|
| ข้อดี | domain เป็น persistence ignorant ทดสอบง่ายด้วย fake/mock |
| ข้อดี | รวม0 query logic ลด code ซ้ำ |
| ข้อดี | สลับ persistence mechanism ได้โดยไม่กระทบ domain (ต่อยอด OCP) |
| ข้อดี | เปิดทาง cross-cutting concern อย่าง caching, logging ผ่าน Decorator/Proxy รอบ repository ได้ง่าย |
| ข้อเสีย | เพิ่มชั้น abstraction และ file ที่ต้องดูแล (interface + implementation) |
| ข้อเสีย | ถ้าออกแบบเป็น generic repository ที่ expose IQueryable ตรง ๆ จะกลายเป็น anti-pattern ที่ไม่ได้ซ่อนอะไรจริง |
| ข้อเสีย | อาจจำกัดความสามารถ query ที่ซับซ้อนข้าม aggregate — ต้องพึ่ง read model หรือ CQRS แยกต่างหาก |
| ข้อเสีย | นักพัฒนาบางสาย (เช่น Jimmy Bogard) มองว่า repository ซ้ำซ้อนกับความสามารถของ ORM สมัยใหม่และ MediatR/CQRS |