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

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

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — file migration และ Model Snapshot ของ​บท​นี้​อยู่​ที่ path FoodOrdering.Infrastructure/Persistence/Migrations/ โดย FoodOrderingDbContext (DbContext ก้อน​ใหญ่​ก้อน​เดียว​จาก Clean Architecture บท 4) เป็น context ที่ EF ใช้ diff

เริ่ม​จาก​การ​เปลี่ยน 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 ยัง​ไม่มี — จะ​ปิด​ช่องว่าง​นี้​ยังไง​ให้​ปลอดภัย

คำ​สั่ง​เดียว​เปลี่ยน “model ใน code กับ schema ใน​ฐาน​ข้อมูล​ไม่​ตรง​กัน” ให้​กลาย​เป็น file ที่​รีวิว​ได้:

Terminal window
dotnet ef migrations add AddOrderStatus --context FoodOrderingDbContext

EF 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 เพียวๆ ไม่​ได้​เล่า​ความ​จริง​ของ​ข้อมูล​เก่า เดี๋ยว​จะ​เห็น​ว่า​ทำไม

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

จุด​ที่​หลาย​ทีม​พลาด​คือ​คิด​ว่า 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 เดียวกัน

file Up() เป็น C# ไม่ใช่ SQL — สิ่ง​ที่​จะ​ไป​โดน​ฐาน​ข้อมูล​จริง​คือ SQL ที่ EF แปล​ออก​มา ก่อน​ขึ้น prod ให้​ดู​มัน​ด้วย​ตา​ตัวเอง​เสมอ โดย​เฉพาะ​ก้าว breaking:

Terminal window
# 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 จริง​บน production ทาง​ที่​ปลอดภัย​คือ​ทำให้​มัน​เป็น step แยก​ที่​ควบคุม​ได้ ก่อน roll out app ไม่ใช่​ผล​ข้าง​เคียง​ตอน app start วิธี​ที่​แนะนำ​ที่สุด​คือ migration bundle — compile migration ทั้งหมด​เป็น executable ตัว​เดียว​ที่​ไม่​ต้อง​มี SDK หรือ source บน​เครื่อง prod:

Terminal window
# ขั้น 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 ที่​รู้​ว่า​อัน​ไหน​ลง​ไป​แล้ว

❌ version ดิบ: EnsureCreated() ตอน startup บน prod
// Program.cs — อย่าทำแบบนี้บน production
using 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 / 3

Model Snapshot ของ EF Core คืออะไร และทำไมต้อง commit คู่กับ file migration เสมอ?