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

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

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 contract
public 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