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

Unit of Work

ติดตาม​การ​เปลี่ยนแปลง​ทั้งหมด​แล้ว​บันทึก​เป็น transaction เดียว

เวลา​ที่ business transaction หนึ่ง​ครั้ง​ต้อง​แตะ object หลาย​ตัว — สร้าง​ใหม่​บ้าง แก้ไข​บ้าง ลบ​บ้าง — ถ้า​เขียน code แบบ​เรียก Save() ทุก​ครั้ง​ที่​มี​การ​เปลี่ยนแปลง​เกิด​ขึ้น จะ​เจอ​ปัญหา​สอง​อย่าง​พร้อม​กัน หนึ่ง​คือ round-trip ไป​ฐาน​ข้อมูล​มาก​เกิน​จำเป็น (ช้า) สอง​คือ​ถ้า​การ​เขียน​บาง​ส่วน​สำเร็จ​แต่​บาง​ส่วน​ล้มเหลว ข้อมูล​จะ​อยู่​ใน​สถานะ​ไม่​สอดคล้อง​กัน (inconsistent)

Martin Fowler นิยาม Unit of Work ไว้​ใน Patterns of Enterprise Application Architecture ว่า​เป็น​สิ่ง​ที่ “maintains a list of objects affected by a business transaction and coordinates the writing out of changes and the resolution of concurrency problems” — พูด​ง่าย ๆ คือ​มัน​เป็น​ตัวกลาง​ที่ จำ ว่า​มี object ไหน​ถูก​สร้าง แก้ หรือ​ลบ​ไป​บ้าง​ระหว่าง​ทำงาน แล้ว​เมื่อ​ถึง​จุด​ที่​ต้องการ commit ก็​รวบรวม​การ​เปลี่ยนแปลง​ทั้งหมด​ส่ง​ลง​ฐาน​ข้อมูล​เป็น การ​ดำเนิน​การ atomic เดียว (สำเร็จ​พร้อม​กัน​หรือ​ล้มเหลว​พร้อม​กัน)

แนวคิด​หลัก​คือ แยก​การ​เปลี่ยน object ใน​หน่วย​ความ​จำ ออก​จาก​การ​เขียน​ลง​ฐาน​ข้อมูล​จริง repository หรือ business logic ทำ​หน้าที่​แค่ “บอก” Unit of Work ว่า object นี้​ใหม่/ถูก​แก้/ถูกลบ ส่วน Unit of Work เป็น​คน​ตัดสิน​ใจ​ว่า​จะ flush การ​เปลี่ยนแปลง​ลง​ฐาน​ข้อมูล​เมื่อไหร่​และ​อย่างไร ซึ่ง​มัก​ทำงาน​คู่​กับ Repository — repository จัดการ​การ​เข้าถึง object ทีละ aggregate ส่วน Unit of Work ประสาน transaction ข้าม repository เหล่า​นั้น

classDiagram
    class Client
    class IUnitOfWork["IUnitOfWork «interface»"] {
      +RegisterNew(entity)
      +RegisterDirty(entity)
      +RegisterRemoved(entity)
      +Commit()
    }
    class EfUnitOfWork {
      -DbContext context
      +RegisterNew(entity)
      +RegisterDirty(entity)
      +RegisterRemoved(entity)
      +Commit()
    }
    class OrderRepository {
      -IUnitOfWork unitOfWork
      +Add(order)
      +Update(order)
    }
    class CustomerRepository {
      -IUnitOfWork unitOfWork
      +Add(customer)
    }
    Client --> IUnitOfWork
    IUnitOfWork <|.. EfUnitOfWork
    OrderRepository --> IUnitOfWork
    CustomerRepository --> IUnitOfWork
  • IUnitOfWork — สัญญา​กลาง เปิดเผย method ให้​ลง​ทะเบียน​การ​เปลี่ยนแปลง (RegisterNew / RegisterDirty / RegisterRemoved) และ method Commit() เพื่อ flush ทุก​อย่าง​เป็น transaction เดียว
  • EfUnitOfWork — ตัว implementation จริง​ที่​ครอบ DbContext ของ EF Core (ใน EF Core, DbContext เอง​ก็​ทำ​หน้าที่​ตัว change tracker + Unit of Work อยู่​แล้ว​ใน​ตัว)
  • OrderRepository / CustomerRepository — repository แต่ละ​ตัว​ไม่​เขียน​ลง​ฐาน​ข้อมูล​เอง แต่​แจ้ง Unit of Work ที่​ใช้​ร่วม​กัน (shared instance) ให้ track การ​เปลี่ยนแปลง​แทน

ลำดับ​การ​ทำงาน​ทั่วไป​คือ: client เรียก repository เพื่อ​เพิ่ม/แก้ไข object → repository แจ้ง Unit of Work ให้ track การ​เปลี่ยนแปลง​นั้น​ไว้ (ยัง​ไม่​เขียน​ฐาน​ข้อมูล) → เมื่อ business transaction เสร็จ​สมบูรณ์ client เรียก Commit() ครั้ง​เดียว → Unit of Work รวบรวม​ทุก​การ​เปลี่ยนแปลง​ที่ track ไว้ แล้ว​ส่ง​เป็น SQL statement ชุด​เดียว​ภายใน transaction เดียว

sequenceDiagram
    participant C as Client
    participant OR as OrderRepository
    participant CR as CustomerRepository
    participant UOW as UnitOfWork
    participant DB as Database
    C->>OR: Add order
    OR->>UOW: RegisterNew order
    C->>CR: Update customer
    CR->>UOW: RegisterDirty customer
    C->>UOW: Commit
    UOW->>DB: BEGIN TRANSACTION
    UOW->>DB: INSERT order
    UOW->>DB: UPDATE customer
    UOW->>DB: COMMIT

จุด​สำคัญ​คือ การ track ไม่​เท่ากับ​การ​เขียน — ถ้า Commit() ไม่​ถูก​เรียก การ​เปลี่ยนแปลง​ทั้งหมด​จะ​หาย​ไป​พร้อม request/scope นั้น และ​ถ้า​ขั้นตอน​ใด​ใน Commit() ล้มเหลว (เช่น constraint violation) ฐาน​ข้อมูล​จะ rollback ทั้งหมด ไม่มี​การ​เขียน​ครึ่ง ๆ กลาง ๆ นอกจาก​นี้ Unit of Work ยัง​เป็น​จุด​ธรรมชาติ​สำหรับ​จัดการ concurrency conflict (เช่น​ตรวจ row version ก่อน commit) เพราะ​มัน​เห็น​ภาพ​รวม​ของ​ทุก object ที่​เปลี่ยน​ใน transaction นั้น

// สัญญากลาง — เปิดเผยแค่สิ่งที่ business logic ต้องรู้: track อะไรบ้าง แล้ว commit เมื่อไหร่
public interface IUnitOfWork
{
void RegisterNew(object entity);
void RegisterDirty(object entity);
void RegisterRemoved(object entity);
Task<int> CommitAsync(CancellationToken ct = default);
}
// EF Core: DbContext ติดตาม state ของ entity ให้อยู่แล้ว (Added/Modified/Deleted)
// เราแค่ห่อ DbContext ไว้เป็น Unit of Work เพื่อไม่ให้ repository ต้องรู้จัก DbContext ตรง ๆ
public sealed class EfUnitOfWork : IUnitOfWork, IDisposable
{
private readonly AppDbContext _context;
public EfUnitOfWork(AppDbContext context) => _context = context;
public void RegisterNew(object entity) => _context.Add(entity);
public void RegisterDirty(object entity) => _context.Update(entity);
public void RegisterRemoved(object entity) => _context.Remove(entity);
// เขียนการเปลี่ยนแปลงทั้งหมดที่ track ไว้เป็น transaction เดียว
public Task<int> CommitAsync(CancellationToken ct = default) =>
_context.SaveChangesAsync(ct);
public void Dispose() => _context.Dispose();
}
// Repository ไม่เขียนฐานข้อมูลเอง แค่แจ้ง Unit of Work ที่ถูก inject มาให้ track
public sealed class OrderRepository
{
private readonly IUnitOfWork _unitOfWork;
private readonly AppDbContext _context;
public OrderRepository(IUnitOfWork unitOfWork, AppDbContext context)
{
_unitOfWork = unitOfWork;
_context = context;
}
public Task<Order?> FindAsync(Guid id) =>
_context.Orders.FirstOrDefaultAsync(o => o.Id == id);
public void Add(Order order) => _unitOfWork.RegisterNew(order);
}
// เช่นเดียวกับ OrderRepository — ไม่เขียนฐานข้อมูลเอง แค่แจ้ง Unit of Work ให้ track
public sealed class CustomerRepository
{
private readonly IUnitOfWork _unitOfWork;
private readonly AppDbContext _context;
public CustomerRepository(IUnitOfWork unitOfWork, AppDbContext context)
{
_unitOfWork = unitOfWork;
_context = context;
}
public Task<Customer?> FindAsync(Guid id) =>
_context.Customers.FirstOrDefaultAsync(c => c.Id == id);
public void MarkDirty(Customer customer) => _unitOfWork.RegisterDirty(customer);
}
// Application service — เป็นจุดที่กำหนดขอบเขต business transaction จริง ๆ
// สังเกตว่า Commit() ถูกเรียก "ครั้งเดียว" หลังจากแตะหลาย aggregate
public sealed class PlaceOrderHandler
{
private readonly OrderRepository _orders;
private readonly CustomerRepository _customers;
private readonly IUnitOfWork _unitOfWork;
public PlaceOrderHandler(
OrderRepository orders,
CustomerRepository customers,
IUnitOfWork unitOfWork)
{
_orders = orders;
_customers = customers;
_unitOfWork = unitOfWork;
}
public async Task HandleAsync(PlaceOrderCommand command, CancellationToken ct)
{
var customer = await _customers.FindAsync(command.CustomerId)
?? throw new InvalidOperationException("ไม่พบลูกค้า");
var order = Order.Create(customer.Id, command.Lines);
_orders.Add(order);
customer.RegisterLoyaltyPoints(order.Total);
_customers.MarkDirty(customer);
// ทุกการเปลี่ยนแปลงข้างบน (Order ใหม่ + Customer ที่แก้) ถูกเขียนพร้อมกันที่นี่
await _unitOfWork.CommitAsync(ct);
}
}

ใน project ที่​ใช้ EF Core ตรง ๆ (ไม่มี custom repository layer) DbContext เอง​ทำ​หน้าที่ Unit of Work อยู่​แล้ว — เรียก context.Add(...), context.Update(...), context.Remove(...) เพื่อ track แล้ว​จบ​ด้วย SaveChangesAsync() ครั้ง​เดียว ก็ได้​พฤติกรรม​เดียว​กับ​ตัวอย่าง​ข้าง​บน​โดย​ไม่​ต้อง​เขียน interface เพิ่ม

  • เมื่อ business transaction หนึ่ง​ครั้ง​ต้อง​แก้ไข object หลาย​ตัว/หลาย aggregate ที่​ต้อง “สำเร็จ​หรือ​ล้มเหลว​ไป​ด้วย​กัน”
  • เมื่อ​ใช้ repository หลาย​ตัว​ร่วม​กัน และ​ต้องการ​ให้​ทุก repository เขียน​ผ่าน transaction เดียวกัน ไม่ใช่​คนละ connection/context
  • เมื่อ​ต้องการ​ลด​จำนวน round-trip ไป​ฐาน​ข้อมูล โดย​รวบ​การ​เปลี่ยนแปลง​ไว้​ก่อน​แล้ว​ค่อย flush ครั้ง​เดียว
  • เมื่อ​ต้องการ​จุดศูนย์กลาง​สำหรับ​จัดการ concurrency (optimistic concurrency check) ก่อน​เขียน​จริง
  • app เล็ก ๆ ที่​ใช้ ORM อย่าง EF Core ตรง ๆ อยู่​แล้ว — DbContext ทำ​หน้าที่​นี้​ให้​ใน​ตัว การ​ห่อ interface เพิ่ม​มัก​เป็นการ​เพิ่ม layer โดย​ไม่​ได้​ประโยชน์ (ดู YAGNI)
  • เมื่อ​แต่ละ​การ​เขียน​เป็น​อิสระ​จาก​กัน​จริง ๆ ไม่มี​ความ​จำเป็น​ต้อง atomic ร่วม​กัน — การ​บังคับ​ให้​อยู่ transaction เดียว​จะ​ยิ่ง​เพิ่ม lock contention โดย​ไม่​จำเป็น
  • เมื่อ​ระบบ​เป็น distributed / cross-service transaction — Unit of Work แบบ local transaction ใช้​ไม่​ได้ ต้อง​มอง​หา pattern อื่น เช่น Saga หรือ eventual consistency แทน
  • เมื่อ scope ของ transaction คลุมเครือ​จน​ทำให้ Unit of Work มีอายุ​ยาว​เกิน​ไป (เก็บ object ไว้ track ข้าม request หลาย​รอบ) ซึ่ง​จะ​ทำให้ debug ยาก​และ​เสี่ยง memory/lock ค้าง
ด้านรายละเอียด
ข้อดีรับประกัน atomicity ข้าม repository/aggregate หลาย​ตัว​ใน transaction เดียว
ข้อดีลด​จำนวน round-trip ไป​ฐาน​ข้อมูล เพราะ​รวม​การ​เขียน​ไว้​ก่อน
ข้อดีเป็น​จุดศูนย์กลาง​จัดการ concurrency conflict ได้​ใน​ที่​เดียว
ข้อ​เสียเพิ่ม abstraction อีก​ชั้น ถ้า ORM มี change tracking ให้​อยู่​แล้ว​อาจ​ซ้ำซ้อน
ข้อ​เสียถ้า scope/lifetime ของ Unit of Work ถูก​จัดการ​ผิด (เช่น share ข้าม request) จะ​เกิด bug ที่​ตาม​ยาก
ข้อ​เสียทำงาน​ได้​ดี​เฉพาะ​ภายใน transaction boundary เดียว ไม่​รองรับ distributed transaction โดย​ธรรมชาติ
  • Repository — ใช้​คู่​กัน​บ่อย​ที่สุด: repository อ่าน/เขียน​ที​ละ aggregate ส่วน Unit of Work ประสาน transaction ข้าม repository
  • Aggregate — ขอบเขต​ความ​สอดคล้อง​กัน​ที่ Unit of Work มัก​ใช้​เป็น​หน่วย​ใน​การ track การ​เปลี่ยนแปลง
  • Domain Events — มัก​ถูก​ยิง​ออก​หลัง Commit() สำเร็จ เพื่อ​แจ้ง​ผลลัพธ์​ของ transaction ให้​ส่วน​อื่น​ของ​ระบบ​รู้
  • CQRS — ฝั่ง command มัก​ใช้ Unit of Work เพื่อ​รับประกัน atomicity ของ​การ​เขียน ต่าง​จาก​ฝั่ง query ที่​มัก​อ่าน​ตรง​ไม่​ผ่าน tracking
  • Dependency Inversion Principle — เหตุผล​ที่ business logic ควร​พึ่งพา IUnitOfWork (interface) แทนที่​จะ​ผูก​กับ DbContext โดยตรง
  • YAGNI — เกณฑ์​ตัดสิน​ว่า​จำเป็น​ต้อง​ห่อ Unit of Work เอง​ไหม ใน​เมื่อ ORM หลาย​ตัว​มี​ให้​อยู่​แล้ว