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

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 ตัวอย่าง (กำลัง​จัด​ทำ)

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 — บางเท่าเดิมจากบท 5
public 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 — ซึ่ง​พา​เรา​มา​ถึง​หัวข้อ​ถัด​ไป​พอดี

ตอน​ที่ 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 ทีหลัง:

❌ version ดิบ: state กับ outbox อยู่​คนละ SaveChanges = dual-write
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 ถูก​เขียน​คู่​ไป​ใน​ธุรกรรม​เดียวกัน​โดย​อัตโนมัติ:

FoodOrdering.Infrastructure/Persistence/FoodOrderingDbContext.cs
// ต่อยอดจาก Clean Architecture บท 4 — mapping ของ Order/OrderLine/Money (HasConversion,
// OwnsOne, HasMany + backing field _lines, private-ctor rehydration) มีครบแล้ว ไม่ทวนซ้ำ
// บทนี้เพิ่มแค่ DbSet<OutboxMessage> กับ override SaveChangesAsync
public 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 ทั้งหมด:

FoodOrdering.Infrastructure/Persistence/OutboxMessage.cs
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 ให้​ไม่​ได้

ถ้า SaveChanges หนึ่ง​ครั้ง​คือ​หนึ่ง​หน่วย​งาน คำถาม​ถัด​มา​คือ instance ของ DbContext ควร​มีอายุ​ยาว​แค่​ไหน คำ​ตอบ​คือ สั้น — หนึ่ง​ตัว​ต่อ1 request แล้ว​ทิ้ง ใน ASP.NET Core เรา​ลง​ทะเบียน​แบบ​นี้​ครั้ง​เดียว​ตอน bootstrap ซึ่ง AddDbContext ให้ lifetime เป็น scoped โดย​ปริยาย​อยู่​แล้ว:

Program.cs
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 พร้อม​กัน

เพราะ 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

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

  • Unit of Work — pattern ที่​รวบ​การ​เปลี่ยน​หลาย​อย่าง​ไว้ commit พร้อม​กัน​เป็น transaction เดียว ซึ่ง DbContext เป็น​ให้​อยู่​แล้ว​ใน​ตัว
  • Aggregate — หน่วย​ความ​สอดคล้อง​ที่​มี root เดียว และ​เป็น​หน่วย​ของ transaction: 1 aggregate ต่อ1 SaveChanges
  • Domain Events — สิ่ง​ที่ Order บันทึก​ไว้​ตอน Place แล้ว​เรา​แปลง​เป็น​แถว outbox เพื่อ​ส่ง​ข้าม​ขอบเขต​อย่าง​เชื่อถือ​ได้

เช็กความเข้าใจ — บทที่ 6

ข้อ 1 / 3

1 SaveChanges (1 transaction) ควรครอบกี่ aggregate?