Migrations ให้ปลอดภัย
บทที่ 2–3 จบงาน mapping ไปแล้ว: Value Converter ห่อ OrderId, OwnsOne ฝัง Money, HasMany ผูก _lines เข้ากับ backing field — Order ทั้งก้อนถูกอธิบายให้ EF Core รู้จักครบ แต่ mapping นั้นเป็นแค่ คำอธิบายรูปร่างของ model ใน code มันไม่ได้ไปแตะ PostgreSQL จริงสัก column ฐานข้อมูลตัวจริงยังต้องมีคนไปสร้างตาราง Orders/OrderLines ให้ และเมื่อ model โตขึ้น ก็ต้องมีคนพา schema ตามให้ทัน — นั่นคืองานของบทนี้
โจทย์ของ Persistence IgnorancePersistence Ignoranceหลักที่ว่า domain model (Order, OrderLine, Money) ไม่ควรรู้อะไรเลยเกี่ยวกับวิธีที่มันถูกบันทึก — ไม่มี attribute ของ EF Core ไม่สืบทอดจาก base class ใด ๆ ทุกอย่างเรื่อง mapping อยู่นอก domain ทั้งหมดArchitecture ทำให้เรื่องนี้ละเอียดอ่อนเป็นพิเศษ: model ไม่รู้จัก EF Core เลย ดังนั้น “model เปลี่ยน” กับ “schema เปลี่ยน” จึงเป็นคนละก้าว และมีเครื่องมือคั่นกลางชื่อ MigrationMigrationfile C# ที่ EF Core generate จาก diff ระหว่าง model ปัจจุบันกับ Model Snapshot ก่อนหน้า อธิบายการเปลี่ยน schema เป็น Up/Down ที่ควบคุม version ได้ และ apply กับฐานข้อมูลจริงตามลำดับArchitecture คอยแปลงก้าวแรกให้เป็นก้าวหลังอย่างมี version ตรวจสอบได้ และย้อนได้
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — file migration และ Model Snapshot ของบทนี้อยู่ที่ path FoodOrdering.Infrastructure/Persistence/Migrations/ โดย FoodOrderingDbContext (DbContext ก้อนใหญ่ก้อนเดียวจาก Clean Architecture บท 4) เป็น context ที่ EF ใช้ diff
model เปลี่ยนหนึ่งก้าว: เพิ่ม Status ให้ Order
หัวข้อที่มีชื่อว่า “model เปลี่ยนหนึ่งก้าว: เพิ่ม Status ให้ Order”เริ่มจากการเปลี่ยน model จริงหนึ่งก้าว เพื่อให้มีอะไรให้ migrate สมมติธุรกิจอยากรู้ว่าออเดอร์อยู่สถานะไหน เราจึงเพิ่ม OrderStatus เข้า Order — เป็นการแก้ domain ล้วนๆ ไม่มี attribute ของ EF Core มาเกี่ยวข้อง (นี่คือ Order รูปเดิมที่ทวนจาก Course A บท2 + Course B บท2–3 — OrderLine เริ่มชีวิตเป็น record ใน Course A บท2 แล้วถูกยกระดับเป็น Entity<OrderLineId> ใน Course B บท3):
// FoodOrdering.Domain/Orders/ — ประกาศ ID ก่อนใช้เสมอpublic sealed record OrderId(Guid Value);public sealed record OrderLineId(Guid Value);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 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; } public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly(); public Money Total { get; private set; } public OrderStatus Status { get; private set; } // ← field ใหม่ก้าวเดียวของบทนี้
public static Order Place(OrderId id, IReadOnlyList<OrderLine> lines) { // ... รักษา invariant เดิม (ตะกร้าห้ามว่าง, Total = ผลรวมทุกบรรทัด) // ออเดอร์ที่เพิ่งสร้างเริ่มที่ Placed }}mapping ของ Status แทบไม่ต้องเขียนอะไรเพิ่ม — EF Core map enum เป็น column integer ให้เองโดย convention ส่วน OwnsOne(Total)/HasMany(Lines)/backing field _lines/การ rehydrate ผ่าน private constructor นั้น บทที่ 2–3 วางไว้ครบแล้ว บทนี้จึงไม่ทวนซ้ำ เราสนใจแค่คำถามเดียว: ตอนนี้ code มี column Status แล้ว แต่ตาราง Orders ใน PostgreSQL ยังไม่มี — จะปิดช่องว่างนี้ยังไงให้ปลอดภัย
dotnet ef migrations add — แปลง diff เป็น file ที่ควบคุม version ได้
หัวข้อที่มีชื่อว่า “dotnet ef migrations add — แปลง diff เป็น file ที่ควบคุม version ได้”คำสั่งเดียวเปลี่ยน “model ใน code กับ schema ในฐานข้อมูลไม่ตรงกัน” ให้กลายเป็น file ที่รีวิวได้:
dotnet ef migrations add AddOrderStatus --context FoodOrderingDbContextEF Core ทำสองอย่างพร้อมกันเสมอ: (1) เทียบ model ปัจจุบันกับ Model Snapshot ก่อนหน้า แล้ว generate file migration ที่มี2 method Up() (เดินหน้า) กับ Down() (ถอยกลับ) และ (2) เขียนทับ file Model Snapshot ให้สะท้อน model ล่าสุด file migration ที่ได้สำหรับก้าวนี้:
// Migrations/20260717_AddOrderStatus.cs — generate จาก diff, แก้ด้วยมือได้public partial class AddOrderStatus : Migration{ protected override void Up(MigrationBuilder migrationBuilder) { // 1) expand — เพิ่ม column แบบ nullable ก่อน ออเดอร์เก่ายังไม่มีค่า ยอมให้ว่างชั่วคราว migrationBuilder.AddColumn<int>( name: "Status", table: "Orders", type: "integer", nullable: true);
// 2) data migration — เติมค่าให้แถวเดิม: ทุกใบที่มีอยู่ถือว่า Placed (0) migrationBuilder.Sql("UPDATE \"Orders\" SET \"Status\" = 0 WHERE \"Status\" IS NULL;");
// 3) contract — พอข้อมูลครบทุกแถวแล้วค่อยบังคับ NOT NULL migrationBuilder.AlterColumn<int>( name: "Status", table: "Orders", type: "integer", nullable: false, oldClrType: typeof(int), oldNullable: true); }
protected override void Down(MigrationBuilder migrationBuilder) => migrationBuilder.DropColumn(name: "Status", table: "Orders");}ส่วนที่ EF generate ให้ตรงๆ คือ AddColumn แบบ nullable: false พร้อม default — แต่เราแก้มันด้วยมือเป็นสามสเต็ป (สังเกตว่า migration เป็น file ธรรมดา แก้ได้) เพราะ default เพียวๆ ไม่ได้เล่าความจริงของข้อมูลเก่า เดี๋ยวจะเห็นว่าทำไม
Model Snapshot — บันทึก model ปัจจุบันที่ EF ใช้เป็นฐานเทียบ
หัวข้อที่มีชื่อว่า “Model Snapshot — บันทึก model ปัจจุบันที่ EF ใช้เป็นฐานเทียบ”file ที่คนมองข้ามบ่อยที่สุดคือ Model SnapshotModel Snapshotfile ที่ EF Core เก็บสถานะล่าสุดของ model หลัง migration ล่าสุดทุกครั้ง ใช้เป็นฐานเทียบตอนสร้าง migration ถัดไป — ต้อง commit คู่กับ file migration เสมอ ไม่งั้น diff ของ migration ถัดไปจะผิดArchitecture — file FoodOrderingDbContextModelSnapshot.cs ที่ EF Core เขียนทับใหม่ทุกครั้งที่ migrations add มันไม่ใช่ diff และไม่ใช่ประวัติ แต่เป็นภาพ model ทั้งก้อน ณ ปัจจุบัน ที่ถูกแปลเป็น code:
[DbContext(typeof(FoodOrderingDbContext))]partial class FoodOrderingDbContextModelSnapshot : ModelSnapshot{ protected override void BuildModel(ModelBuilder modelBuilder) { modelBuilder.Entity("FoodOrdering.Domain.Orders.Order", b => { b.Property<Guid>("Id"); b.Property<int>("Status"); // ← เพิ่งโผล่มาหลัง migration นี้ b.Property<decimal>("TotalAmount"); b.Property<string>("TotalCurrency"); // ... Lines, owned Money ฯลฯ }); }}หน้าที่ของมันมีอย่างเดียวแต่สำคัญมาก: เป็น ฐานเทียบ ตอนสร้าง migration ถัดไป EF ไม่ได้ไปอ่าน schema จริงในฐานข้อมูลเพื่อหา diff — มัน diff model ปัจจุบันใน code กับ file snapshot นี้ ผลที่ตามมาคือกฎเหล็ก: ต้อง commit file snapshot คู่กับ file migration เสมอ ถ้าเพื่อนร่วมทีมได้ file migration แต่ไม่ได้ snapshot ที่อัปเดตแล้ว migration ถัดไปที่เขา generate จะ diff จากฐานที่ผิด — EF จะนึกว่า column Status ยังไม่มี แล้วสั่ง AddColumn ซ้ำ กลายเป็น migration ที่ชนกันเองตอนขึ้น prod
Additive กับ breaking — รู้ก่อนว่าก้าวนี้อันตรายแค่ไหน
หัวข้อที่มีชื่อว่า “Additive กับ breaking — รู้ก่อนว่าก้าวนี้อันตรายแค่ไหน”migration ไม่ได้เท่ากันทุกอัน ก่อน apply ต้องแยกให้ออกว่ามันเป็นก้าวแบบไหน:
- Additive (ปลอดภัยเชิงโครงสร้าง) — เพิ่มของใหม่ที่ไม่แตะข้อมูลเดิม:
CreateTable,AddColumnแบบnullable, เพิ่ม index แถวเก่าทุกแถวยัง valid โดยไม่ต้องแก้อะไร app version เก่ากับ version ใหม่รันคู่กันระหว่าง deploy ได้ ไม่ต้อง downtime - Breaking (เสี่ยงข้อมูล/เสี่ยงล็อกตาราง) —
DropColumn,RenameColumn,AlterColumnเปลี่ยนชนิด หรือบังคับNOT NULLบนตารางที่ มีข้อมูลอยู่แล้ว ก้าวพวกนี้อาจทำข้อมูลหาย ล็อกตารางระหว่างรัน หรือทำให้ app version เก่าที่ยังวิ่งอยู่พังทันที
ก้าว AddOrderStatus ข้างบนคือกรณีก้ำกึ่งที่พบบ่อยที่สุด: ปลายทางเราอยาก NOT NULL (สถานะห้ามว่าง — เป็น invariant) แต่ถ้าสั่ง AddColumn(nullable: false) ตูมเดียวบนตารางที่มีออเดอร์เก่าเป็นล้านแถว ค่า default จะถูกยัดให้ทุกแถวโดยที่เราไม่ได้คิด นั่นคือเหตุผลที่เราแตกมันเป็น expand → migrate → contract สามสเต็ป ให้ช่วง nullable เป็นที่ว่างสำหรับ data migration ก่อนค่อยรัดเป็น NOT NULL
Data migration — schema กับข้อมูลต้องขยับพร้อมกัน
หัวข้อที่มีชื่อว่า “Data migration — schema กับข้อมูลต้องขยับพร้อมกัน”จุดที่หลายทีมพลาดคือคิดว่า migration = แก้ schema อย่างเดียว แต่บ่อยครั้งการเปลี่ยน schema ทำให้ ข้อมูลเดิมไม่สอดคล้อง จนต้องเขียน SQL ย้าย/เติมข้อมูลไปพร้อมกัน นั่นคือ data migration และ migrationBuilder.Sql(...) คือที่ของมัน
ในก้าวของเรา สเต็ปที่ 2 UPDATE "Orders" SET "Status" = 0 คือ data migration ตัวจริง: ออเดอร์เก่าทุกใบไม่มีสถานะ เราตัดสินใจ เชิงธุรกิจว่าถือเป็น Placed แล้วเติมค่าให้ ถ้าปล่อยให้ default ทำงานเงียบๆ เราจะไม่มีที่ให้ตัดสินใจแบบนี้เลย — และถ้าความจริงซับซ้อนกว่านั้น เช่น ออเดอร์ที่ยืนยันแล้วต้องเป็น Confirmed (1) data migration ก็คือที่เดียวที่เขียน logic นั้นได้ ตัวอย่างอีกรูปที่ตรงไปตรงมา: สมมติ schema รุ่นก่อนเก็บเงินเป็นจำนวนอย่างเดียวโดยถือว่าเป็นบาทโดยปริยาย ตอนเพิ่ม column TotalCurrency ก็ต้องเติมค่าให้แถวเก่าตามความจริงนั้น (Money.Thb):
migrationBuilder.Sql("UPDATE \"Orders\" SET \"TotalCurrency\" = 'THB' WHERE \"TotalCurrency\" = '';");data migration เขียนอยู่ใน file migration เดียวกัน จึง apply เป็นก้าวเดียวกับการเปลี่ยน schema — เดินหน้าพร้อมกัน ย้อนพร้อมกัน มี version เดียวกัน
ทบทวน SQL ที่จะรันจริง ก่อนแตะ prod
หัวข้อที่มีชื่อว่า “ทบทวน SQL ที่จะรันจริง ก่อนแตะ prod”file Up() เป็น C# ไม่ใช่ SQL — สิ่งที่จะไปโดนฐานข้อมูลจริงคือ SQL ที่ EF แปลออกมา ก่อนขึ้น prod ให้ดูมันด้วยตาตัวเองเสมอ โดยเฉพาะก้าว breaking:
# generate SQL ทั้งหมด (idempotent = ตรวจ __EFMigrationsHistory ก่อน จึงรันซ้ำได้ปลอดภัย)dotnet ef migrations script --idempotent \ --context FoodOrderingDbContext -o migrate.sqlเปิด migrate.sql แล้วมองหาคำสั่งอันตราย: DROP COLUMN (ข้อมูลหาย), ALTER COLUMN ... TYPE (อาจ rewrite ทั้งตารางและล็อกไว้นาน), หรือ ALTER COLUMN ... SET NOT NULL บนตารางใหญ่ SQL ที่ทบทวนแล้วนี้เอาเข้า code review หรือส่งให้ DBA อ่านได้ตรงๆ — นี่คือประโยชน์ที่ EnsureCreated ให้ไม่ได้เลย เพราะมันไม่เคยผลิต diff ที่รีวิวได้
flowchart TD
CH["1) แก้ model ใน code<br/>เพิ่ม Order.Status (domain เปลี่ยน)"]
ADD["2) dotnet ef migrations add AddOrderStatus"]
MIG["file Migration<br/>Up() / Down() (+ data migration)"]
SNAP["Model Snapshot อัปเดต<br/>สะท้อน model ล่าสุดทั้งก้อน"]
REV["3) ทบทวน SQL ที่จะรันจริง<br/>dotnet ef migrations script --idempotent"]
APPLY["4) apply เป็น step ใน pipeline<br/>migration bundle / Database.Migrate()"]
DB[("PostgreSQL<br/>schema version ใหม่")]
CH --> ADD
ADD --> MIG
ADD --> SNAP
MIG --> REV
SNAP -. "commit คู่กันเสมอ" .-> MIG
REV --> APPLY
APPLY --> DB
EC["version ดิบ: EnsureCreated()<br/>ข้ามทุกขั้นตอน ไม่มีประวัติ migration"]
CH -. "ทางลัดที่ห้ามใช้ใน prod" .-> EC
EC -. "สร้าง schema ทีเดียว แล้วตันต่อไม่ได้" .-> DB
คำบรรยายภาพ: เส้นทางปลอดภัยไล่จากซ้ายบนลงล่าง — แก้ model → migrations add (ผลิตทั้ง file migration และอัปเดต snapshot ที่ต้อง commit คู่กัน) → ทบทวน SQL → apply เป็น step แยกใน pipeline → schema version ใหม่ ส่วนลูกศรประจาก EnsureCreated() คือทางลัดที่ข้ามทั้งกระบวนการ พุ่งชน schema ทีเดียวโดยไม่ทิ้งประวัติไว้ให้ต่อยอด
apply บน prod — migration bundle เป็น step ใน pipeline
หัวข้อที่มีชื่อว่า “apply บน prod — migration bundle เป็น step ใน pipeline”ตอน apply จริงบน production ทางที่ปลอดภัยคือทำให้มันเป็น step แยกที่ควบคุมได้ ก่อน roll out app ไม่ใช่ผลข้างเคียงตอน app start วิธีที่แนะนำที่สุดคือ migration bundle — compile migration ทั้งหมดเป็น executable ตัวเดียวที่ไม่ต้องมี SDK หรือ source บนเครื่อง prod:
# ขั้น build — ได้ file efbundle ที่ apply migration ที่ยังไม่ได้ลงทั้งหมดdotnet ef migrations bundle --context FoodOrderingDbContext -o efbundle
# ขั้น deploy — รันเป็น step ก่อนปล่อย app version ใหม่ ใช้ connection string ของ prod./efbundle --connection "$PROD_CONNECTION_STRING"ทางเลือกอื่นที่ยอมรับได้: เรียก context.Database.Migrate() ใน job เริ่มระบบที่รันครั้งเดียว (ไม่ใช่ทุก replica พร้อมกัน) หรือเอา migrate.sql แบบ idempotent จากหัวข้อก่อนไปให้ DBA รันเอง ทั้งหมดนี้มีจุดร่วมเดียว: apply migration ที่มี version ตามลำดับ ผ่านตาราง __EFMigrationsHistory ที่รู้ว่าอันไหนลงไปแล้ว
// Program.cs — อย่าทำแบบนี้บน productionusing var scope = app.Services.CreateScope();var db = scope.ServiceProvider.GetRequiredService<FoodOrderingDbContext>();db.Database.EnsureCreated(); // สร้าง schema จาก model ปัจจุบันทีเดียว "ถ้ายังไม่มี DB"EnsureCreated() ข้ามระบบ migration ทั้งหมด: มันไม่สร้างตาราง __EFMigrationsHistory ไม่มีบันทึกว่าลง migration ไหนไปแล้ว และถ้าฐานข้อมูลมีอยู่แล้วมันจะ ไม่ทำอะไรเลย — column Status ที่เพิ่งเพิ่มจะไม่มีวันถูกสร้าง schema จึงค้างอยู่รุ่นแรกตลอดไป วิวัฒนาการต่อไม่ได้เลย มันออกแบบมาสำหรับ test และ prototype ที่สร้าง-ทิ้งฐานข้อมูลรัวๆ เท่านั้น บน prod ให้ใช้ migration bundle หรือ Database.Migrate() แทนเสมอ
สรุปก้าวเดียวที่ปลอดภัย
หัวข้อที่มีชื่อว่า “สรุปก้าวเดียวที่ปลอดภัย”บทนี้เดินครบหนึ่งรอบของการเปลี่ยน schema อย่างซื่อสัตย์ต่อ model: แก้ domain โดยที่มันยังไม่รู้จัก EF Core, migrations add ผลิต file migration คู่กับ Model Snapshot ที่ต้อง commit ด้วยกัน, แยกให้ออกว่าก้าวไหน additive ก้าวไหน breaking, ใส่ data migration ให้ข้อมูลเก่าสอดคล้อง, ทบทวน SQL ก่อน apply แล้ว apply เป็น step ใน pipeline ไม่ใช่ EnsureCreated บทถัดไปเราจะย้ายจากการ “เก็บและวิวัฒนาการ schema” ไปสู่คำถามว่า จะเกิดอะไรขึ้นเมื่อสองคำขอแก้ Order ใบเดียวกันพร้อมกัน — optimistic concurrency กับ concurrency token ที่กันไม่ให้ใครเขียนทับงานคนอื่นเงียบๆ
เช็กความเข้าใจ — บทที่ 4
ข้อ 1 / 3Model Snapshot ของ EF Core คืออะไร และทำไมต้อง commit คู่กับ file migration เสมอ?