Chuyển tới nội dung chính

3.14 — Ví dụ thực tế nhanh

Tóm tắt

Có một loại merge conflict mà cách xử lý thông thường luôn sai: conflict trong file ModelSnapshot.cs của EF Core. Với file code bình thường, bạn đọc hai bên rồi ghép thủ công. Với snapshot, ghép tay tạo ra một file trông hợp lệ, biên dịch được, nhưng không khớp với database thật — và lỗi chỉ lộ ra ở lần migration tiếp theo, thường là trên production. Cách đúng ngược với trực giác: đừng sửa file conflict, hãy xoá migration của mình, lấy bản của người khác, rồi sinh lại migration. Bài này giải thích vì sao, và đưa cách phòng ngừa để cả đội không gặp lại.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Nhận ra conflict trong file sinh tự động.
  • Xử lý conflict migration EF Core đúng cách.
  • Giải thích vì sao ghép tay snapshot là sai.
  • Giảm số conflict bằng quy ước làm việc.

Nội dung bài học​

3.14.1 — Tình huống​

An:   tạo migration "AddPhoneNumberToLead"
Bình: tạo migration "AddNoteTable"
Cả hai cùng xuất phát từ main, làm song song trong 2 ngày

An merge trước -> OK
Bình merge sau -> CONFLICT trong AppDbContextModelSnapshot.cs
<<<<<<< HEAD
b.Property<string>("PhoneNumber")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
=======
b.Property<string>("Note")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
>>>>>>> feature/note-table

Nhìn thì có vẻ dễ: giữ cả hai đoạn là xong.

3.14.2 — Vì sao ghép tay là sai​

ModelSnapshot.cs không phải code bạn viết — nó là ảnh chụp trạng thái hiện tại của mô hình dữ liệu, do EF Core sinh ra để so sánh khi tạo migration tiếp theo.

EF Core tạo migration bằng cách:
So sánh (model trong code) vs (ModelSnapshot.cs)
-> Sinh ra các lệnh để đưa database từ snapshot về model mới

Nếu snapshot bị ghép tay sai — thiếu một thuộc tính, sai thứ tự index, sót một ràng buộc — thì migration tiếp theo sẽ sinh ra lệnh sai. Và vì snapshot vẫn biên dịch được, không có gì cảnh báo bạn.

Hậu quả điển hình:

Migration tiếp theo sinh ra:
ALTER TABLE Leads ADD PhoneNumber nvarchar(20) <- cột ĐÃ TỒN TẠI

Chạy trên production -> lỗi "Column names in each table must be unique"

Hoặc tệ hơn theo hướng ngược lại: snapshot thừa một cột mà database không có, nên EF Core sinh lệnh DROP COLUMN cho một cột đang chứa dữ liệu thật.

Đây là lý do ModelSnapshot.cs cần được đối xử như file sinh tự động: đừng sửa tay, hãy sinh lại.

3.14.3 — Ba bước xử lý đúng​

Bước 1 — bỏ migration của mình:

# Huỷ merge đang dở
git merge --abort

# Xoá migration của mình (chưa ai dùng, an toàn)
dotnet ef migrations remove

dotnet ef migrations remove xoá cả file migration và hoàn tác phần tương ứng trong snapshot — đây là điều bạn không làm đúng được bằng tay.

Bước 2 — lấy bản mới nhất:

git checkout main
git pull
git checkout feature/note-table
git rebase main # giờ không còn conflict ở snapshot nữa

Bước 3 — sinh lại migration:

dotnet ef migrations add AddNoteTable

EF Core so sánh model hiện tại (đã có cả thay đổi của An và của Bình) với snapshot mới nhất, rồi sinh migration chỉ chứa phần của Bình. Snapshot được cập nhật đúng.

# Luôn kiểm tra file sinh ra trước khi commit
cat Migrations/*_AddNoteTable.cs

Đọc nó và xác nhận nó chỉ chứa những gì bạn mong đợi. Nếu thấy lệnh động tới bảng hay cột không liên quan, có gì đó sai.

3.14.4 — Khi migration đã lên production​

Ba bước trên chỉ áp dụng khi migration chưa được áp dụng ở đâu. Nếu nó đã chạy trên môi trường nào đó, migrations remove là sai — nó sẽ khiến lịch sử migration trong database lệch với mã nguồn.

# Kiểm tra migration nào đã được áp dụng
dotnet ef migrations list
20260920103012_AddPhoneNumberToLead (Applied)
20260921141522_AddNoteTable (Pending) <- chưa áp dụng, xoá được

Nếu migration của bạn đã Applied trên staging hoặc production, cách xử lý là thêm một migration mới sửa phần khác biệt, không xoá cái cũ (bài 13.5).

3.14.5 — Phòng ngừa​

1. Merge main thường xuyên. Conflict tỷ lệ với thời gian nhánh tồn tại:

# Mỗi sáng, trước khi bắt đầu làm
git checkout main && git pull
git checkout feature/my-branch && git rebase main

Nhánh sống 2 ngày hiếm khi có conflict nghiêm trọng; nhánh sống 3 tuần thì gần như chắc chắn có.

2. Quy ước: một migration mỗi PR. Nhiều migration trong một PR làm việc gỡ rối khó hơn nhiều lần, vì migrations remove chỉ xoá được cái cuối.

3. Thông báo trong kênh chung khi tạo migration. Một dòng "mình đang thêm migration cho bảng Note" đủ để người khác biết mà rebase trước.

4. Đánh dấu file sinh tự động trong .gitattributes:

*ModelSnapshot.cs linguist-generated=true

Nó không ngăn conflict nhưng báo cho GitHub biết đây là file sinh tự động, nên PR sẽ thu gọn phần diff đó lại — người review không bị nhiễu.

3.14.6 — Loại file khác cũng đừng ghép tay​

Cùng một nguyên tắc áp dụng cho mọi file sinh tự động:

FileCách xử lý conflict
*ModelSnapshot.csSinh lại bằng dotnet ef
package-lock.jsonLấy một bên rồi chạy lại npm install
*.Designer.csSinh lại từ công cụ
File .resx đã dịchThường ghép tay được, nhưng kiểm tra kỹ

Nguyên tắc chung: nếu một file được sinh ra bởi công cụ, hãy để công cụ sinh lại nó thay vì ghép tay. Ghép tay tạo ra trạng thái mà công cụ không bao giờ tự sinh ra được, và đó là nơi lỗi kỳ lạ xuất hiện.

3.14.7 — Rà lại code của bạn​

Danh sách rà soát conflict migration

  • •Không bao giờ ghép tay ModelSnapshot.cs.
  • •Dùng dotnet ef migrations remove thay vì xoá file thủ công.
  • •Đã kiểm tra migration có Applied ở đâu chưa trước khi xoá.
  • •Đọc lại file migration sinh ra trước khi commit.
  • •Rebase với main hàng ngày, không để nhánh sống quá lâu.
  • •Mỗi PR chỉ có một migration.
  • •Thông báo cho đội khi tạo migration mới.
  • •File sinh tự động được đánh dấu trong .gitattributes.

Bài tập áp dụng​

Bài 1 — Tái hiện conflict​

Tạo hai nhánh, mỗi nhánh thêm một migration, và merge chúng để thấy conflict trong snapshot.

Tiêu chí hoàn thành: bạn thấy conflict xuất hiện ở file snapshot chứ không phải ở hai file migration, và giải thích được vì sao chỉ mình snapshot bị conflict.

Gợi ý và lời giải — Bài 1

Gợi ý. Hai migration có tên file khác nhau nên Git không coi chúng là xung đột. Snapshot thì chỉ có một file duy nhất.

Lời giải — dựng tình huống:

git checkout -b feature/so-dien-thoai main
# thêm thuộc tính SoDienThoai vào Lead
dotnet ef migrations add ThemSoDienThoaiChoLead
git add -A && git commit -m "feat: thêm số điện thoại cho lead"

git checkout main
git checkout -b feature/bang-ghi-chu
# thêm entity GhiChu
dotnet ef migrations add ThemBangGhiChu
git add -A && git commit -m "feat: thêm bảng ghi chú"
git checkout feature/bang-ghi-chu
git merge feature/so-dien-thoai
Auto-merging src/Crm.Infrastructure/Migrations/CrmDbContextModelSnapshot.cs
CONFLICT (content): Merge conflict in src/Crm.Infrastructure/Migrations/CrmDbContextModelSnapshot.cs
Automatic merge failed; fix conflicts and then commit the result.
git status --short
A  src/Crm.Infrastructure/Migrations/20260921094512_ThemSoDienThoaiChoLead.cs
A src/Crm.Infrastructure/Migrations/20260921094512_ThemSoDienThoaiChoLead.Designer.cs
UU src/Crm.Infrastructure/Migrations/CrmDbContextModelSnapshot.cs

Chỉ một file bị conflict (UU), hai file migration được thêm sạch (A).

Vì sao chỉ snapshot bị conflict:

Mỗi migration = MỘT file mới, tên riêng có dấu thời gian
20260921094512_ThemSoDienThoaiChoLead.cs
20260921101233_ThemBangGhiChu.cs
-> hai file khác nhau -> Git ghép được, không có gì chồng lấn

Snapshot = MỘT file duy nhất mô tả TOÀN BỘ model hiện tại
-> cả hai nhánh cùng sửa nó -> Git phải chọn -> conflict

Nội dung conflict trông như sau:

modelBuilder.Entity("Crm.Domain.Lead", b =>
{
b.Property<int>("Id").ValueGeneratedOnAdd();
b.Property<string>("HoTen").IsRequired().HasMaxLength(200);
<<<<<<< HEAD
b.ToTable("Leads");
});

modelBuilder.Entity("Crm.Domain.GhiChu", b =>
{
b.Property<int>("Id").ValueGeneratedOnAdd();
b.Property<string>("NoiDung").IsRequired();
b.ToTable("GhiChu");
=======
b.Property<string>("SoDienThoai").HasMaxLength(20);
b.ToTable("Leads");
>>>>>>> feature/so-dien-thoai
});

Và đây là điều khiến file này khác mọi file conflict khác:

Snapshot KHÔNG phải mã nguồn do người viết.
Nó là mã SINH RA, mô tả trạng thái model tại một thời điểm.

-> ghép tay hai trạng thái không cho ra một trạng thái đúng,
nó cho ra một trạng thái mà EF Core chưa bao giờ sinh

Cùng lý do đó áp cho file .Designer.cs của mỗi migration: nó cũng chứa một bản sao snapshot tại thời điểm migration đó.

Một chi tiết trong git status đáng để ý:

Hai migration có dấu thời gian trong TÊN FILE.
Thứ tự chạy được quyết định bởi dấu thời gian đó, không phải bởi thứ tự merge.

-> nếu An tạo migration lúc 09:45 và Bình lúc 10:12,
migration của An luôn chạy trước, kể cả khi Bình merge trước.

Điều này thường đúng và vô hại. Nó chỉ thành vấn đề khi hai migration động tới cùng một bảng và thứ tự có ý nghĩa — lúc đó bước 3 của mục 3.14.3 (sinh lại migration) tự nhiên xử lý đúng, vì migration mới được sinh sau và mang dấu thời gian mới hơn.


Bài 2 — Xử lý đúng cách​

Áp dụng ba bước ở mục 3.14.3 và đọc file migration sinh ra để xác nhận nó chỉ chứa thay đổi của bạn.

Tiêu chí hoàn thành: migration mới chỉ chứa thay đổi của bạn, và bạn kiểm chứng được điều đó bằng cả mắt lẫn một lệnh.

Gợi ý và lời giải — Bài 2

Gợi ý. dotnet ef migrations remove không chỉ xoá file — nó còn hoàn tác phần tương ứng trong snapshot. Đó là lý do phải dùng nó thay vì rm.

Lời giải — ba bước:

# Bước 1 — bỏ migration của mình
git merge --abort
dotnet ef migrations remove
Build started...
Build succeeded.
Removing migration '20260921101233_ThemBangGhiChu'.
Reverting the model snapshot.
Done.
Dòng "Reverting the model snapshot" là dòng quan trọng nhất.
Nó là việc bạn KHÔNG làm đúng được bằng tay.
# Bước 2 — lấy bản mới nhất
git checkout main && git pull
git checkout feature/bang-ghi-chu
git rebase main
Successfully rebased and updated refs/heads/feature/bang-ghi-chu.
Không còn conflict — vì migration của bạn đã được gỡ khỏi snapshot,
nên nhánh của bạn không còn sửa file đó nữa.
# Bước 3 — sinh lại
dotnet ef migrations add ThemBangGhiChu
cat src/Crm.Infrastructure/Migrations/*_ThemBangGhiChu.cs
public partial class ThemBangGhiChu : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "GhiChu",
columns: table => new
{
Id = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
LeadId = table.Column<int>(nullable: false),
NoiDung = table.Column<string>(nullable: false),
NgayTao = table.Column<DateTime>(nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_GhiChu", x => x.Id);
table.ForeignKey("FK_GhiChu_Leads_LeadId", x => x.LeadId,
"Leads", "Id", onDelete: ReferentialAction.Cascade);
});

migrationBuilder.CreateIndex("IX_GhiChu_LeadId", "GhiChu", "LeadId");
}

protected override void Down(MigrationBuilder migrationBuilder)
=> migrationBuilder.DropTable("GhiChu");
}

Kiểm chứng bằng mắt: KHÔNG có dòng nào nhắc tới SoDienThoai.

Nếu thấy AddColumn("SoDienThoai", ...) trong migration của bạn
-> snapshot đã bị ghép tay ở đâu đó
-> migration này sẽ cố thêm một cột ĐÃ TỒN TẠI -> lỗi khi chạy

Và kiểm chứng bằng lệnh, chắc chắn hơn mắt:

dotnet ef migrations script 20260921094512_ThemSoDienThoaiChoLead \
20260921103045_ThemBangGhiChu
CREATE TABLE [GhiChu] (
[Id] int NOT NULL IDENTITY,
[LeadId] int NOT NULL,
[NoiDung] nvarchar(max) NOT NULL,
[NgayTao] datetime2 NOT NULL,
CONSTRAINT [PK_GhiChu] PRIMARY KEY ([Id]),
CONSTRAINT [FK_GhiChu_Leads_LeadId] FOREIGN KEY ([LeadId])
REFERENCES [Leads] ([Id]) ON DELETE CASCADE
);
GO

CREATE INDEX [IX_GhiChu_LeadId] ON [GhiChu] ([LeadId]);
GO
Đây là SQL SẼ CHẠY trên database. Đọc nó là cách kiểm chứng cuối cùng —
nó không phụ thuộc vào việc bạn đọc đúng C# hay không.

Và một bước kiểm chứng nữa, trả lời câu hỏi "snapshot có khớp model không":

dotnet ef migrations add KiemTra --dry-run
Nếu snapshot đúng, migration này sẽ RỖNG (không có thay đổi nào)
Nếu nó có nội dung -> snapshot đang LỆCH với model -> có gì đó sai

Đây là phép thử đáng chạy trước mỗi lần commit migration: nó mất vài giây và phát hiện ngay snapshot bị ghép tay.

Đưa phép thử đó vào CI:

- name: Kiểm tra snapshot khớp model
run: |
dotnet ef migrations add __KiemTra --project src/Crm.Infrastructure
if grep -q "migrationBuilder\." src/Crm.Infrastructure/Migrations/*__KiemTra.cs; then
echo "::error::Snapshot lệch với model — có migration chưa được tạo,
hoặc snapshot đã bị sửa tay"
exit 1
fi
dotnet ef migrations remove --project src/Crm.Infrastructure
Bước này bắt được cả hai lỗi:
- ai đó đổi model mà quên tạo migration
- ai đó ghép tay snapshot khi giải quyết conflict

Bài 3 — Thử ghép tay​

Ghép tay snapshot rồi tạo một migration mới. Đọc lệnh SQL sinh ra và tìm chỗ sai.

Tiêu chí hoàn thành: bạn tìm được lệnh SQL sai cụ thể, và hiểu vì sao lỗi này không lộ ra cho tới khi migration chạy trên một database thật.

Gợi ý và lời giải — Bài 3

Gợi ý. Ghép tay thường cho ra một snapshot trông đúng. Vấn đề chỉ lộ ra ở migration tiếp theo.

Lời giải — ghép tay "hợp lý":

// Giải quyết conflict bằng cách giữ CẢ HAI bên — nghe rất hợp lý
modelBuilder.Entity("Crm.Domain.Lead", b =>
{
b.Property<int>("Id").ValueGeneratedOnAdd();
b.Property<string>("HoTen").IsRequired().HasMaxLength(200);
b.Property<string>("SoDienThoai").HasMaxLength(20); // của An
b.ToTable("Leads");
});

modelBuilder.Entity("Crm.Domain.GhiChu", b => // của Bình
{
b.Property<int>("Id").ValueGeneratedOnAdd();
b.Property<string>("NoiDung").IsRequired();
b.ToTable("GhiChu");
});
git add -A && git commit -m "merge: giải quyết conflict snapshot"

Dự án build được, test chạy được, pull request được duyệt. Vấn đề xuất hiện ở migration kế tiếp:

dotnet ef migrations add ThemTrangThaiChoGhiChu
cat Migrations/*_ThemTrangThaiChoGhiChu.cs
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "TrangThai", table: "GhiChu", nullable: false, defaultValue: 0);
}

Trông hoàn toàn đúng. Và nó vẫn hỏng.

dotnet ef database update
Applying migration '20260921101233_ThemBangGhiChu'.
Applying migration '20260921113355_ThemTrangThaiChoGhiChu'.
Failed executing DbCommand:
ALTER TABLE [GhiChu] ADD [TrangThai] int NOT NULL DEFAULT 0;

Microsoft.Data.SqlClient.SqlException:
Invalid object name 'GhiChu'.

Vì sao: bảng GhiChu chưa bao giờ được tạo.

Ghép tay sửa SNAPSHOT — mô tả trạng thái model.
Nhưng migration ThemBangGhiChu đã bị bỏ trong lúc merge,
hoặc nó tạo bảng với cấu trúc KHÁC với snapshot đã ghép.

-> snapshot nói "bảng GhiChu tồn tại và có 3 cột"
-> nhưng KHÔNG có migration nào thực sự tạo ra nó
-> EF Core tin snapshot, và sinh migration tiếp theo dựa trên niềm tin đó

Hai nguồn sự thật lệch nhau:

Mô tả cái gìAi cập nhật
Các file migrationNhững việc PHẢI LÀM với databasemigrations add
SnapshotTrạng thái model ĐƯỢC CHO LÀ đang cómigrations add / remove
Bình thường hai thứ này luôn khớp, vì cùng một lệnh sinh ra cả hai.
Sửa tay một trong hai -> chúng lệch nhau -> EF Core không có cách nào biết.

Vì sao lỗi không lộ ra sớm — bốn tầng đều không bắt được:

1. Biên dịch  : snapshot là C# hợp lệ -> build xanh
2. Test đơn vị: không chạm database -> xanh
3. Code review: đoạn ghép tay trông hợp lý -> được duyệt
4. Máy của bạn: database đã có sẵn bảng từ lần chạy trước -> không lỗi
Nó chỉ lộ ra ở nơi database được dựng TỪ ĐẦU:
- CI với database sạch
- môi trường staging mới
- hoặc production, nếu ba nơi trên đều không dựng lại từ đầu

Đây là thứ tự tệ nhất có thể: lỗi được phát hiện ở nơi xa nhất so với nơi nó được tạo ra.

Cách kiểm chứng chắc chắn nhất — dựng database từ số không:

dotnet ef database drop --force
dotnet ef database update
Chạy MỌI migration theo thứ tự trên một database rỗng.
Nếu chuỗi migration đúng, lệnh này chạy sạch.
Nếu snapshot đã bị ghép tay, nó hỏng ngay ở đây.

Đưa vào CI:

- name: Dựng database từ đầu bằng toàn bộ migration
run: |
dotnet ef database update --connection "$CS_TEST"
Bước này chạy khoảng 10–30 giây và bắt được đúng loại lỗi ở trên,
ở đúng chỗ nên bắt: trong pull request, không phải trên production.

Nếu đã lỡ ghép tay và đã commit — cách sửa:

# Nếu migration chưa chạy ở đâu: xoá và làm lại đúng ba bước
dotnet ef migrations remove # lặp lại cho từng migration bị ảnh hưởng
git checkout main -- src/Crm.Infrastructure/Migrations/CrmDbContextModelSnapshot.cs
dotnet ef migrations add TenMigration
Dòng git checkout là mấu chốt: lấy lại snapshot từ nhánh chính,
tức bản do EF Core sinh ra, chưa bị ai sửa tay.

Nối lại ba bài: bài 1 cho thấy conflict chỉ nằm ở snapshot, bài 2 cho thấy cách xử lý đúng mất chưa tới một phút, bài 3 cho thấy cách sai mất hàng giờ và lộ ra ở chỗ tệ nhất. Chênh lệch giữa hai cách chỉ là biết rằng snapshot là mã sinh ra, không phải mã nguồn.

Tự kiểm tra​

Câu hỏi thường gặp

Vì sao không được ghép tay ModelSnapshot.cs?

Vì nó là ảnh chụp trạng thái mô hình do EF Core sinh ra để so sánh khi tạo migration tiếp theo. Ghép tay sai sẽ khiến migration sau sinh lệnh sai, mà file vẫn biên dịch được nên không có gì cảnh báo.

Hậu quả điển hình của snapshot sai là gì?

Migration tiếp theo sinh lệnh thêm một cột đã tồn tại, gây lỗi khi chạy. Hoặc tệ hơn theo hướng ngược lại, sinh lệnh xoá một cột đang chứa dữ liệu thật vì snapshot thừa cột mà database không có.

Ba bước xử lý đúng là gì?

Huỷ merge và chạy dotnet ef migrations remove để xoá migration của mình, rebase lên main để lấy bản mới nhất, rồi sinh lại migration. EF Core sẽ so sánh model hiện tại với snapshot mới và sinh đúng phần còn thiếu.

Khi nào không được dùng migrations remove?

Khi migration đã được áp dụng ở môi trường nào đó. Kiểm tra bằng dotnet ef migrations list, nếu thấy Applied thì phải thêm migration mới sửa phần khác biệt chứ không xoá cái cũ.

Vì sao mỗi PR chỉ nên có một migration?

Vì lệnh migrations remove chỉ xoá được migration cuối cùng. Nhiều migration trong một PR khiến việc gỡ rối khi có conflict khó hơn nhiều lần.

Nguyên tắc chung với file sinh tự động là gì?

Để công cụ sinh lại thay vì ghép tay. Ghép tay tạo ra trạng thái mà công cụ không bao giờ tự sinh ra được, và đó là nơi những lỗi kỳ lạ xuất hiện.

Kết luận​

Ba điều đáng nhớ nhất:

  1. File sinh tự động thì sinh lại, không ghép tay.
  2. dotnet ef migrations remove làm đúng việc mà bạn không làm đúng được bằng tay.
  3. Rebase hàng ngày. Conflict tỷ lệ với thời gian nhánh tồn tại.

Tham khảo​

Điều hướng​