Transactions & Unit of Work
บทก่อนๆ ทำให้เห็นแล้วว่า DbContext เป็น unit of work อยู่แล้วในตัว — เราจึงไม่ต้องเขียน class ห่อซ้ำ และ repository ที่เราใช้มาตลอดก็บางแค่ห่อ DbSet<Order> ไว้ บทนี้เจาะลึกลงไปอีกชั้นว่า “หน่วยงาน” ที่ว่านั้นกว้างแค่ไหน: การเรียก SaveChanges หนึ่งครั้งครอบอะไรบ้าง, ทำไมกฎ “1 aggregate ต่อ1 transaction” ถึงเป็นเส้นแบ่งที่ต้องรักษา และจะเขียนแถว outbox ให้ไปนั่งอยู่ใน transaction เดียวกับการเปลี่ยน Order ได้อย่างไร โดยไม่ตกหลุม dual-write ที่คอร์ส Bounded Contexts เคยเตือนไว้
คำถามหลักของบทนี้มีคำตอบเดียวที่ร้อยทุกหัวข้อเข้าด้วยกัน: SaveChanges หนึ่งครั้ง = 1 transaction ในฐานข้อมูล = ทุกอย่างในนั้น commit พร้อมกันหรือ rollback พร้อมกัน ถ้าเข้าใจประโยคนี้จริง เรื่อง outbox, อายุของ DbContext และคำถามว่าเมื่อไรต้องเปิด transaction เองจะตามมาเป็นผลลัพธ์ ไม่ใช่กฎที่ต้องท่องแยกกัน
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้ต่อยอด FoodOrderingDbContext ตัวเดิมจาก Clean Architecture บท 4 โดยเพิ่ม DbSet<OutboxMessage> กับ override SaveChangesAsync code อยู่ที่ path FoodOrdering.Infrastructure/Persistence/
SaveChanges เดียวคือหน่วยงานที่สำเร็จหรือล้มพร้อมกัน
หัวข้อที่มีชื่อว่า “SaveChanges เดียวคือหน่วยงานที่สำเร็จหรือล้มพร้อมกัน”EF Core รับประกันไว้อย่างหนึ่งที่เป็นรากของทั้งบท: การเปลี่ยนแปลงทุกอย่างในการเรียก SaveChanges ครั้งเดียว จะถูก apply ภายใน transaction เดียวของฐานข้อมูล ถ้าบรรทัดใดบรรทัดหนึ่งล้ม (constraint ชน, deadlock, disk เต็ม) transaction ทั้งก้อนถูก rollback — ไม่มีการเปลี่ยนแปลงไหนหลุดลง DB เลยแม้แต่รายการเดียว นี่คือความหมายที่จับต้องได้ของคำว่า Unit of WorkUnit of Workpattern ที่รวบรวมการเปลี่ยนแปลงหลาย object ไว้เป็นชุดเดียวแล้ว commit พร้อมกันเป็น transaction เดียว — DbContext ของ EF Core เป็น unit of work อยู่ในตัวแล้ว ไม่ต้องเขียน class ห่อซ้ำArchitecture: รวบการเปลี่ยนหลายอย่างไว้เป็นชุดเดียว แล้ว commit พร้อมกัน ซึ่ง DbContextDbContextจุดเข้าหลักของ EF Core ที่รวม connection, change tracker และ unit of work ไว้ในตัวเดียว มี DbSet<T> ต่อ Aggregate หนึ่งตัว — อายุสั้น (scoped ต่อ request) ไม่ใช่ singleton ที่ใช้ข้าม requestArchitecture ทำให้เราฟรีอยู่แล้วผ่าน change tracker — เราไม่ต้องเปิด/ปิด transaction เองในเคสปกติ
ผลที่ตามมาคือ repository ยังบางเท่าเดิมจากบทที่แล้ว มันแค่ Add แล้ว SaveChangesAsync ครั้งเดียว เท่านี้ Order กับ OrderLine ทุกบรรทัดก็ลง DB เป็นก้อนเดียวที่แยกกันไม่ได้:
// FoodOrdering.Infrastructure/Persistence/EfOrderRepository.cs — บางเท่าเดิมจากบท 5public sealed class EfOrderRepository : IOrderRepository{ private readonly FoodOrderingDbContext _db; public EfOrderRepository(FoodOrderingDbContext db) => _db = db;
public async Task SaveAsync(Order order, CancellationToken ct) { _db.Orders.Add(order); await _db.SaveChangesAsync(ct); // SaveChanges เดียว = 1 transaction = หนึ่งหน่วยงาน }
public async Task<Order?> FindAsync(OrderId id, CancellationToken ct) => await _db.Orders.Include(o => o.Lines).FirstOrDefaultAsync(o => o.Id == id, ct);}จุดที่ต้องรักษาไว้ให้ได้คือ ขอบเขต ของหน่วยงานนี้ — หนึ่ง SaveChanges ควรครอบ หนึ่ง AggregateAggregateกลุ่มของ Entity และ Value Object ที่ถือเป็นหนึ่งหน่วยความสอดคล้อง มี root เดียวเป็นประตูเข้าและเป็นหน่วยของ transaction เช่น Order ที่คุม OrderLine — งานของบทนี้คือ map Aggregate ทั้งก้อนลง SQL โดยไม่ทำลาย invariant ที่ root รักษาไว้Tactical Design เท่านั้น นี่คือ Transaction BoundaryTransaction Boundaryขอบเขตที่กำหนดว่างานชิ้นไหนต้องสำเร็จหรือล้มเหลวพร้อมกันทั้งหมด — ปกติคือ1 Aggregate ต่อ1 SaveChanges เดียว ข้าม Aggregate ให้ใช้ eventual consistency แทนการยัดหลาย aggregate ไว้ใน transaction เดียวกันArchitecture ที่ DDD ยืนยันมาตลอด: Order ทั้งใบ (root + OrderLine ทุกบรรทัด + Total ที่ฝังใน owned type) คือหน่วยความสอดคล้องเดียว มันต้องสำเร็จหรือล้มพร้อมกันเพราะ invariant ของมันผูกกันภายในก้อนเดียว แต่ถ้าวันหนึ่งเราอยากเซฟ Order กับ aggregate อื่น (เช่น Restaurant หรือ Rider) ให้ atomic กันใน1 transaction — นั่นคือสัญญาณเตือน ไม่ใช่เป้าหมาย การยัด2 aggregate ไว้ใน transaction เดียวมัดสองหน่วยความสอดคล้องที่ควรอิสระต่อกันเข้าด้วยกัน ทำให้ระบ block กว้างและขยายยาก คำตอบที่ถูกต้องคือให้แต่ละ aggregate มี transaction ของตัวเอง แล้วประสานข้าม aggregate ด้วย eventual consistency ผ่าน domain event — ซึ่งพาเรามาถึงหัวข้อถัดไปพอดี
เขียนแถว outbox ให้อยู่ใน SaveChanges เดียวกัน
หัวข้อที่มีชื่อว่า “เขียนแถว outbox ให้อยู่ใน SaveChanges เดียวกัน”ตอนที่ Order.Place(...) ทำงาน มันไม่ได้แค่สร้างออเดอร์ — มันบันทึกความจริงที่เพิ่งเกิดขึ้นเป็น domain event (OrderPlaced) ไว้ใน _domainEvents ด้วย งานที่เหลือคือส่งเหตุการณ์นั้นออกไปให้ context อื่น (Kitchen ไปเปิดตั๋ว, ฝ่ายบัญชีไปตั้งยอด) อย่างเชื่อถือได้ ปัญหาคลาสสิกตรงนี้คือ dual-write: ถ้าเราเซฟ Order ลง DB ก่อน แล้วค่อยไปยิง event ออก broker เป็นการเขียนสองที่ที่ไม่ได้อยู่ใน transaction เดียวกัน โพรเซสล่มคั่นกลางเมื่อไร state กับข้อความก็หลุดจากกันทันที
ทางแก้คือ Transactional OutboxTransactional Outboxpattern ที่เขียนแถว event ลงตาราง outbox ใน transaction เดียวกับข้อมูล Aggregate ผ่าน SaveChanges ครั้งเดียว แล้วให้ worker แยกต่างหากอ่าน/ส่ง event ทีหลัง — กัน dual-write problem ระหว่างฐานข้อมูลกับ message brokerArchitecture — คอร์ส Bounded Contexts เพิ่งพิสูจน์ pattern นี้ให้เห็นแล้วในบท 6 เรื่อง outbox โดยใช้ IDbContextOutbox ของ WolverineFx บน OrderingDbContext (ตัวที่เกิดหลังแตก context แล้ว) บทนี้เรากลับมาที่โลกก่อนแตก — โมโนลิธที่มี FoodOrderingDbContext ตัวเดียวจากบท 4 — และจะเห็นว่ากลไก “แถว outbox อยู่ใน transaction เดียวกับ state” นั้นแท้จริงแล้วเป็นแค่คุณสมบัติของ SaveChanges ที่ EF Core ให้เราอยู่แล้ว ไม่ต้องพึ่ง framework ใดๆ ก็ได้
วิธีที่ ผิด คือแยกการเขียนออกเป็นสอง SaveChanges — เซฟ state ก่อน แล้วค่อยเซฟแถว outbox ทีหลัง:
public async Task SaveAsync(Order order, CancellationToken ct){ _db.Orders.Add(order); await _db.SaveChangesAsync(ct); // (1) commit #1: Order + OrderLines ลง DB แล้ว
// 💥 ถ้าโพรเซสล่มตรงนี้: DB มี Order แต่ไม่มีแถว outbox — OrderPlaced หายถาวร foreach (var e in order.DomainEvents) _db.Outbox.Add(OutboxMessage.From(e)); await _db.SaveChangesAsync(ct); // (2) commit #2: transaction แยกจาก (1) โดยสิ้นเชิง}สอง SaveChanges คือ2 transaction เสมอ ไม่ว่าจะเรียงลำดับไหน สลับให้เขียน outbox ก่อนก็แค่ย้ายว่าจะพังทางไหน — ปัญหาไม่ได้อยู่ที่ลำดับ แต่อยู่ที่การขาด atomicity ระหว่างการเปลี่ยน state กับการบันทึกความตั้งใจจะส่ง event
วิธีที่ ถูก คือย้ายการเก็บ domain event ไปแปลงเป็นแถว outbox ไว้ ข้างใน SaveChangesAsync ของ DbContext เอง เพื่อให้ทั้งหมดเกิดใน SaveChanges ครั้งเดียว ผลคือ repository ยังบางเท่าเดิม (เรียก SaveChangesAsync ครั้งเดียวเหมือน code หัวข้อแรก) แต่ทุกครั้งที่มันเซฟ แถว outbox ถูกเขียนคู่ไปในธุรกรรมเดียวกันโดยอัตโนมัติ:
// ต่อยอดจาก Clean Architecture บท 4 — mapping ของ Order/OrderLine/Money (HasConversion,// OwnsOne, HasMany + backing field _lines, private-ctor rehydration) มีครบแล้ว ไม่ทวนซ้ำ// บทนี้เพิ่มแค่ DbSet<OutboxMessage> กับ override SaveChangesAsyncpublic sealed class FoodOrderingDbContext : DbContext{ public FoodOrderingDbContext(DbContextOptions<FoodOrderingDbContext> options) : base(options) { }
public DbSet<Order> Orders => Set<Order>(); public DbSet<OutboxMessage> Outbox => Set<OutboxMessage>();
public override async Task<int> SaveChangesAsync(CancellationToken ct = default) { // 1) เก็บ domain event จากทุก Order ที่ change tracker กำลัง track อยู่ตอนนี้ var events = ChangeTracker.Entries<Order>() .SelectMany(entry => entry.Entity.DomainEvents) .ToList();
// 2) แปลงเป็นแถว outbox แล้ว Add ลง DbContext เดียวกัน (ยังไม่ commit) foreach (var domainEvent in events) Outbox.Add(OutboxMessage.From(domainEvent));
// 3) SaveChanges ครั้งเดียว = Orders + OrderLines + Outbox ทั้งหมด // อยู่ใน transaction เดียวกัน commit พร้อมกันหรือ rollback พร้อมกัน return await base.SaveChangesAsync(ct); }}แถว outbox เป็นแค่ระเบียนแบนๆ ที่ serialize domain event ไว้ให้ worker แยกต่างหากมาอ่านแล้วส่งต่อทีหลัง — domain ไม่ต้องรู้จักมันเลย มันอยู่ฝั่ง Infrastructure ทั้งหมด:
using System.Text.Json;
public sealed class OutboxMessage{ public Guid Id { get; init; } = Guid.NewGuid(); public string Type { get; init; } = default!; public string Payload { get; init; } = default!; public DateTimeOffset OccurredOn { get; init; } = DateTimeOffset.UtcNow;
public static OutboxMessage From(IDomainEvent domainEvent) => new() { Type = domainEvent.GetType().Name, Payload = JsonSerializer.Serialize(domainEvent, domainEvent.GetType()), };}ทั้งหมดนี้ทำงานได้เพราะ Order เก็บ domain event ของตัวเองไว้ตั้งแต่ตอนสร้าง — นี่คือรูป domain ที่เราเก็บลง SQL มาทั้งคอร์ส ไม่มีการ re-model ใหม่ (ทวนจาก Course A บทที่ 2 + Course B บทที่ 2–3 — OrderLine เคยเป็น record ใน Course A แล้วถูกยกระดับเป็น Entity<OrderLineId> ใน Course B; นี่คือรูปสุดท้ายที่เรา map):
// FoodOrdering.Domain/Orders/public interface IDomainEvent { }
public sealed record OrderId(Guid Value);public sealed record OrderLineId(Guid Value); // ประกาศก่อนใช้ใน Entity<OrderLineId>public sealed record ProductId(Guid Value);
public sealed record Money(decimal Amount, string Currency){ public static Money Thb(decimal amount) => new(amount, "THB");}
public enum OrderStatus { Placed, Confirmed, Preparing, PickedUp, Delivered, Rejected, Cancelled }
public sealed record OrderPlaced(OrderId OrderId, Money Total, DateTimeOffset OccurredOn) : IDomainEvent;
public sealed class OrderLine : Entity<OrderLineId> // entity class ไม่ใช่ record แล้ว{ public ProductId ProductId { get; } public Money UnitPrice { get; } // ...}
public sealed class Order{ private readonly List<OrderLine> _lines = new(); private readonly List<IDomainEvent> _domainEvents = new();
public OrderId Id { get; } // strongly-typed id public OrderStatus Status { get; private set; } public Money Total { get; private set; } // คำนวณ ไม่ใช่ set อิสระ public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly(); public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
private Order(OrderId id, IEnumerable<OrderLine> lines) { /* คำนวณ Total, รักษา invariant */ }
public static Order Place(OrderId id, IReadOnlyList<OrderLine> items) // ประตูเดียว { if (items.Count == 0) throw new InvalidOperationException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้"); // ... รักษา invariant, คำนวณ Total, แล้วบันทึกเหตุการณ์: // order._domainEvents.Add(new OrderPlaced(order.Id, order.Total, DateTimeOffset.UtcNow)); }}flowchart TB
H["PlaceOrderHandler เรียก Order.Place(...)<br/>แล้ว repo.SaveAsync(order)"]
H --> SC["SaveChangesAsync ครั้งเดียว"]
SC --> HARVEST["override เก็บ DomainEvents ของ Order ที่ถูก track<br/>แปลงเป็นแถว OutboxMessage แล้ว Add ลง DbContext เดิม"]
subgraph TX["1 DB transaction — commit หรือ rollback พร้อมกัน"]
direction TB
A["INSERT/UPDATE Orders + OrderLines<br/>การเปลี่ยนของ Order aggregate"]
O["INSERT แถว Outbox<br/>OrderPlaced ที่ harvest มาจาก DomainEvents"]
A --> O
end
HARVEST --> TX
TX ==>|"COMMIT สำเร็จ"| OK["state + แถว outbox อยู่ครบใน DB<br/>worker ค่อยอ่าน outbox ไปส่ง event ทีหลัง"]
TX -.->|"ล่มก่อน COMMIT: rollback ทั้งก้อน"| NONE["ไม่เหลือทั้ง Order และแถว outbox<br/>ไม่มีสภาพครึ่ง ๆ กลาง ๆ ให้ตามแก้"]
คำบรรยายภาพ: SaveChangesAsync ที่ override ไว้ทำสองงานก่อนส่งต่อ base — เก็บ DomainEvents จาก Order ทุกใบที่ change tracker ถืออยู่ แล้วแปลงเป็นแถว OutboxMessage เติมลง DbContext ตัวเดิม พอเรียก base.SaveChangesAsync ครั้งเดียว การ INSERT/UPDATE Orders/OrderLines กับการ INSERT แถว outbox จึงอยู่ในกล่อง transaction เดียวกัน (กล่องกลาง) — commit พร้อมกันหรือ rollback พร้อมกัน ถ้าล่มก่อน COMMIT (ลูกศรประ) ก็ไม่เหลือทั้งคู่ ระบบไม่เคยตกอยู่ในสภาพ “state เปลี่ยนแล้วแต่ event หาย” ซึ่งคือหัวใจที่ dual-write version สอง SaveChanges ให้ไม่ได้
DbContext อายุสั้น — scoped ต่อ1 request
หัวข้อที่มีชื่อว่า “DbContext อายุสั้น — scoped ต่อ1 request”ถ้า SaveChanges หนึ่งครั้งคือหนึ่งหน่วยงาน คำถามถัดมาคือ instance ของ DbContext ควรมีอายุยาวแค่ไหน คำตอบคือ สั้น — หนึ่งตัวต่อ1 request แล้วทิ้ง ใน ASP.NET Core เราลงทะเบียนแบบนี้ครั้งเดียวตอน bootstrap ซึ่ง AddDbContext ให้ lifetime เป็น scoped โดยปริยายอยู่แล้ว:
builder.Services.AddDbContext<FoodOrderingDbContext>(opts => opts.UseNpgsql(builder.Configuration.GetConnectionString("foodordering")));// AddDbContext ลงทะเบียนเป็น scoped ให้เอง: 1 instance ต่อ1 HTTP requestเหตุผลที่ต้องสั้นมีสองข้อที่ผูกกับทุกอย่างข้างบน หนึ่ง — DbContext ไม่ thread-safe ใช้ instance เดียวข้ามหลาย request ที่รันพร้อมกันคือการเรียกหา bug ที่ตามยาก สอง — change tracker ของมัน สะสม state ไปเรื่อยๆ ทุก entity ที่โหลดหรือ Add เข้ามาจะค้างอยู่ใน tracker จนกว่า context จะถูกทิ้ง instance ที่อยู่ยาวจึงบวมขึ้นเรื่อยๆ และเสี่ยงเอา entity ค้างจาก request ก่อนมาปนกับ request ใหม่ การผูกอายุ DbContext ไว้กับขอบเขตของ request จึงทำให้ “1 request = 1 unit of work ที่สะอาด” เริ่มจาก change tracker ว่างๆ ทุกครั้ง — ห้ามลงทะเบียนเป็น singleton เด็ดขาด นั่นคือการทำลายทั้งความปลอดภัยของ thread และความสะอาดของ unit of work พร้อมกัน
เมื่อไรถึงต้องเรียก BeginTransaction เอง (ไม่บ่อย)
หัวข้อที่มีชื่อว่า “เมื่อไรถึงต้องเรียก BeginTransaction เอง (ไม่บ่อย)”เพราะ SaveChanges ห่อ transaction ให้อยู่แล้ว งานเกือบทั้งหมดในระบบที่ยึดกฎ “1 aggregate ต่อ1 transaction” จึง ไม่ต้อง เปิด transaction เอง จุดที่ต้องใช้ BeginTransaction จริงๆ มีน้อยมากและมักไม่ใช่งาน use case ปกติ — เช่นงานบำรุงรักษา/ย้ายข้อมูลที่ต้องรัน SaveChanges หลายรอบให้ atomic กัน หรือกรณีต้องผสม raw SQL กับการเปลี่ยนผ่าน EF ในธุรกรรมเดียว:
await using var tx = await _db.Database.BeginTransactionAsync(ct);try{ // ... งานพิเศษที่ต้องมีหลาย SaveChanges หรือผสม raw SQL ให้ atomic กันจริง ๆ await _db.SaveChangesAsync(ct); await _db.Database.ExecuteSqlRawAsync("/* คำสั่งบำรุงรักษาบางอย่าง */", ct); await tx.CommitAsync(ct);}catch{ await tx.RollbackAsync(ct); // ล้มที่ไหนก็ย้อนทั้งก้อน throw;}ข้อควรระวังสำคัญ: ถ้าเหตุผลที่เราจะเปิด BeginTransaction คือ “อยากเซฟ2 aggregate ให้สำเร็จพร้อมกัน” — นั่นคือสัญญาณว่าเส้นแบ่ง transaction ถูกวางผิด ไม่ใช่ว่าต้องการ transaction ที่ใหญ่ขึ้น กลับไปทบทวน boundary แล้วปล่อยให้แต่ละ aggregate มี SaveChanges ของตัวเอง ประสานข้ามกันด้วย outbox + eventual consistency แทน จะดีกว่าการมัดสองหน่วยความสอดคล้องเข้าด้วยกันด้วย transaction ก้อนใหญ่เสมอ
ทั้งบทหมุนรอบประโยคเดียว: SaveChanges หนึ่งครั้ง = 1 transaction = หนึ่งหน่วยงานที่ commit/rollback พร้อมกัน จากประโยคนี้เราได้กฎ “1 aggregate ต่อ1 transaction” เป็น Transaction Boundary ที่รักษาไว้, ได้วิธีเขียนแถว outbox ให้อยู่ใน SaveChanges เดียวกับ state โดย override SaveChangesAsync เพื่อกัน dual-write โดยที่ repository ยังบางเท่าเดิม, ได้เหตุผลว่าทำไม DbContext ต้อง scoped ต่อ request ไม่ใช่ singleton และได้เส้นว่าเมื่อไรเท่านั้นที่ควรเปิด BeginTransaction เอง — ซึ่งน้อยมาก และถ้าเปิดเพราะอยากมัด2 aggregate ก็ควรกลับไปทบทวน boundary ก่อน บทถัดไปเราจะไปต่อที่ด้าน query — ทำให้เส้นทางที่แค่ “อ่าน” ไม่กลายเป็น N+1
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Unit of Work — pattern ที่รวบการเปลี่ยนหลายอย่างไว้ commit พร้อมกันเป็น transaction เดียว ซึ่ง DbContext เป็นให้อยู่แล้วในตัว
- Aggregate — หน่วยความสอดคล้องที่มี root เดียว และเป็นหน่วยของ transaction: 1 aggregate ต่อ1 SaveChanges
- Domain Events — สิ่งที่ Order บันทึกไว้ตอน Place แล้วเราแปลงเป็นแถว outbox เพื่อส่งข้ามขอบเขตอย่างเชื่อถือได้
เช็กความเข้าใจ — บทที่ 6
ข้อ 1 / 31 SaveChanges (1 transaction) ควรครอบกี่ aggregate?