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) และ methodCommit()เพื่อ 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 นั้น
ตัวอย่าง code
หัวข้อที่มีชื่อว่า “ตัวอย่าง code”// สัญญากลาง — เปิดเผยแค่สิ่งที่ 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 มาให้ trackpublic 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 ให้ trackpublic 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() ถูกเรียก "ครั้งเดียว" หลังจากแตะหลาย aggregatepublic 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 หลายตัวมีให้อยู่แล้ว
แหล่งอ้างอิง
หัวข้อที่มีชื่อว่า “แหล่งอ้างอิง”- ที่มา · deviq.com/design-patterns/unit-of-work-pattern
- Unit of Work — Martin Fowler, Patterns of Enterprise Application Architecture
- Unit of work — Wikipedia
- Implementing the Repository and Unit of Work Patterns in an ASP.NET MVC Application — Microsoft Learn
- Patterns of Enterprise Application Architecture — Martin Fowler