Skip to content

บทที่ 2 — Database + EF Core (code-first, migration, transaction)

← บทที่ 1 | สารบัญ | บทที่ 3: Security + JWT

EF Core = ORM อย่างเป็นทางการของ .NET ที่เป็น code-first และ query ด้วย LINQ

รองรับฐานข้อมูลผ่าน provider หลายตัว: PostgreSQL, SQL Server, SQLite, MySQL, Oracle, Cosmos DB

📦 ศัพท์ฐานของบทนี้ (ดูครั้งเดียวพอ)

  • LINQ = ไวยากรณ์ query ข้อมูลในภาษา C# (เช่น .Where(...)) — EF แปลงเป็น SQL ให้ (เรียนเต็มใน csharp/09)
  • ORM (Object-Relational Mapping) = ตัวแปลงระหว่าง object ในโค้ด (เช่น class User) กับตาราง/แถวในฐานข้อมูล ทำให้เราเขียนโค้ด C# แทนการเขียน SQL ดิบ
  • code-first = นิยาม schema (โครงสร้างตาราง) จาก class ในโค้ดก่อน แล้วให้ EF สร้างตาราง DB ตาม (ตรงข้ามกับ database-first ที่มี DB ก่อน)
  • schema = โครงสร้างของฐานข้อมูล (มีตารางอะไร คอลัมน์อะไร ชนิดอะไร)
  • change tracking / tracking = EF จดจำว่า entity ที่โหลดมาถูกแก้ตรงไหนบ้าง พอสั่ง SaveChanges ก็ gen SQL UPDATE เฉพาะส่วนที่เปลี่ยนให้เอง
  • navigation property = property บน entity ที่ชี้ไป entity ที่เกี่ยวข้อง (เช่น Post.Author ชี้ไป User เจ้าของ) ใช้เดินตามความสัมพันธ์ระหว่างตาราง

1. ทำไม EF Core

  • LINQ → SQL ที่ type-safe (ผูกกับ type ชัด เขียนผิดรู้ตั้งแต่ compile)
  • Migration (การปรับโครงสร้าง DB เป็นขั้น ๆ) ที่ track schema change (ติดตามการเปลี่ยนแปลง schema)
  • Change tracking (สั่ง SaveChanges ครั้งเดียว apply ทุก change)
  • ทำงานข้าม DB ด้วย provider (ตัวเชื่อมกับ DB แต่ละชนิด) ที่เปลี่ยนได้
  • มี cache + transaction + concurrency control (การควบคุมการแก้ข้อมูลพร้อมกัน)

ทางเลือก: Dapper (micro-ORM ขนาดเล็กที่เน้นความเร็ว), LinqToDB, RepoDB — เลือกเมื่อ EF Core หนักเกินไปสำหรับเคสที่ต้องการ control SQL เอง


2. Setup

เริ่มใช้ EF Core (ORM ของ .NET) ด้วยการติดตั้ง 3 package: core, design (สำหรับ migration) และ provider ตาม DB ที่ใช้ (Npgsql/SqlServer/SQLite) พร้อมติดตั้ง dotnet-ef tool สำหรับสั่ง migration จาก CLI:

bash
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL    # หรือ SQL Server, SQLite

# dotnet tool = เครื่องมือ CLI ที่ติดตั้งแบบ global (ใช้ได้ทุก project)
# ต่างจาก dotnet add package ที่เพิ่ม library เฉพาะ project นี้เท่านั้น
dotnet tool install --global dotnet-ef

3. DbContext

DbContext คือหัวใจของ EF Core — เป็นตัวแทนของ database session (ช่วงการเชื่อมต่อกับ DB หนึ่งครั้ง)

ภายใน DbContext มี:

  • DbSet<T> ต่อ 1 ตาราง — มองเป็น collection ของแถวในตารางนั้น
  • OnModelCreating สำหรับกำหนด schema และความสัมพันธ์
    • key = กุญแจหลัก (primary key)
    • index = ดัชนีช่วยให้ค้นเร็ว
    • foreign key = กุญแจที่ชี้ไปอ้างอิงตารางอื่น

จากนั้น register เข้า DI พร้อม connection string (สตริงบอกที่อยู่ + รหัสผ่านของ DB)

ส่วนนี้แสดงทั้งการนิยามและ register:

csharp
// Data/AppDbContext.cs
public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    // DbSet<User> Users = ตัวแทนตาราง users ใน DB — ใช้ query / add / remove ผ่านตัวนี้ได้
    // Set<User>() เป็นวิธีที่ปลอดภัยกว่า public auto-property { get; set; } ธรรมดา
    public DbSet<User> Users => Set<User>();
    public DbSet<Post> Posts => Set<Post>();

    protected override void OnModelCreating(ModelBuilder mb)
    {
        mb.Entity<User>(b =>
        {
            b.HasKey(u => u.Id);
            b.Property(u => u.Email).IsRequired().HasMaxLength(254);
            b.HasIndex(u => u.Email).IsUnique();
            b.Property(u => u.Name).IsRequired().HasMaxLength(100);
        });

        mb.Entity<Post>(b =>
        {
            b.HasOne(p => p.Author)
             .WithMany(u => u.Posts)
             .HasForeignKey(p => p.AuthorId)
             .OnDelete(DeleteBehavior.Cascade);
        });
    }
}

Register

csharp
// ปกติ — Scoped (1 instance ต่อ request)
// GetConnectionString("Default") อ่านค่าจาก appsettings.json ที่ key ConnectionStrings.Default
// เช่น "ConnectionStrings": { "Default": "Host=localhost;Database=mydb;Username=..." }
builder.Services.AddDbContext<AppDbContext>(opt =>
    opt.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

// ถ้าต้องการ throughput สูง — ใช้ DbContext pool (reuse instance ลด allocation)
// builder.Services.AddDbContextPool<AppDbContext>(opt =>
//     opt.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

💡 AddDbContextPool<> เหมาะกับเคส high-throughput — EF จะ pool instance ของ DbContext แล้ว reset state ก่อน reuse · ข้อจำกัด: ห้าม inject scoped service (เช่น ITenantContext) ตรง ๆ เข้า constructor เพราะ instance reuse ข้าม request


4. Entity Class

entity คือ class ธรรมดาที่ map ไปเป็นตารางใน DB — EF Core อ่าน property มาสร้างคอลัมน์ และ navigation property (เช่น Author, Posts — property ที่ชี้ไป entity ที่เกี่ยวข้อง) มาสร้างความสัมพันธ์ ส่วนนี้แสดง entity User/Post พร้อมเทคนิค = null! ที่บอก compiler ว่า EF จะ populate (เติมค่า) ให้ตอน load:

csharp
public class User
{
    // Guid.NewGuid() ใน property initializer: ง่ายและใช้งานได้ แต่มีข้อแลกเปลี่ยน:
    // - DB ไม่ได้ gen Guid ให้ → ไม่ใช่ sequential → อาจกระทบ B-tree index performance บน table ขนาดใหญ่
    // - ทางเลือก: ใช้ HasDefaultValueSql("gen_random_uuid()") ใน OnModelCreating ให้ PostgreSQL gen ให้
    //   หรือ .NET 9+ ใช้ Guid.CreateVersion7() ที่เรียงตามเวลา (time-ordered, ดีกว่าสำหรับ index)
    public Guid Id { get; set; } = Guid.NewGuid();
    public required string Name { get; set; }
    public required string Email { get; set; }
    public string? PasswordHash { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    // navigation: ใช้ ICollection ตามขนบ EF (มี Add/Remove พอ — ไม่ต้อง expose List API เต็ม)
    // [] = collection expression (C# 12) เทียบเท่า new List<Post>()
    public ICollection<Post> Posts { get; init; } = [];

    // optional: สำหรับ §8 (ExecuteUpdate)
    public DateTime? LastLogin { get; set; }
    public bool Active { get; set; } = true;
}

public class Post
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public Guid AuthorId { get; set; }
    public User Author { get; set; } = null!;
    public required string Title { get; set; }
    public string? Body { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

💡 = null!; คืออะไร? เป็นการบอก compiler ว่า "ตอนนี้เป็น null ก็จริง แต่ EF จะเติมค่าให้ตอน load — อย่าเตือนเลย"

ที่ต้องเขียนแบบนี้เพราะ NRT (Nullable Reference Types) เป็น feature ที่ทำให้ compiler เตือนเมื่อเราอาจเผลอใช้ค่า null — ถ้าไม่ใส่ = null! compiler จะเตือนว่า navigation property ที่ไม่ใช่ nullable อาจเป็น null ตอน initialize

💡 ! ต่อท้ายค่า (null-forgiving operator) — เป็น C# 8+ feature ที่บอก compiler ว่า "ฉันรู้ว่าค่านี้ไม่ใช่ null แน่นอน อย่าเตือน" ใช้ได้ทุกที่ ไม่ใช่เฉพาะใน property initializer

💡 required กับ EF Core: EF Core 7+ รองรับ required member แล้ว (instantiate ผ่าน reflection ได้โดยไม่เรียก constructor parameter) · ถ้าใช้ EF Core 6 หรือเก่ากว่า ให้เปลี่ยนเป็น = default! แทน

💡 navigation ใช้ ICollection<T> แทน List<T> ตามแนวทางที่ EF แนะนำ — ICollection พอสำหรับ EF (มี Add/Remove) โดยไม่ผูกกับ concrete type · init; ช่วยกันไม่ให้ใครแอบเปลี่ยน reference ของ collection หลังสร้าง


5. Migration

migration = กลไกแปลงการเปลี่ยน entity ให้กลายเป็น SQL ที่ปรับ schema ของ DB ให้ตรงกัน เช่น เพิ่ม property PhoneNumber ใน class User → migration จะ gen คำสั่ง ALTER TABLE users ADD COLUMN phone_number TEXT; ให้

คำสั่งหลัก:

  • migrations add <ชื่อ> — สร้างไฟล์ migration ที่ describe การเปลี่ยนแปลง
  • database update — apply migration จริงกับ DB
  • migrations script — gen SQL ไฟล์เดียวสำหรับ production (ให้ DBA รัน)

EF Core track ประวัติ schema ไว้ในตาราง __EFMigrationsHistory ทำให้ deploy ฐานข้อมูลได้แบบ version control (มีประวัติย้อนได้เหมือน git)

⚠️ ระวัง rename column — EF ไม่รู้ว่าเปลี่ยนชื่อ จะ gen เป็น drop คอลัมน์เก่า + add คอลัมน์ใหม่ (= ข้อมูลในคอลัมน์เดิมหายหมด) · ต้องแก้ migration ด้วยมือให้เรียก RenameColumn แทน

bash
dotnet ef migrations add InitialCreate
# → Migrations/<timestamp>_InitialCreate.cs

dotnet ef database update
# → apply schema

# revert — ต้องทำตามลำดับนี้เสมอ:
# 1. ย้อน schema ใน DB ก่อน (ถ้า migration นั้น apply ไปแล้ว)
dotnet ef database update <previous-migration-name>
# ⚠️ database update <previous> = ย้อนกลับ schema แต่ไม่คืน data ที่อยู่ในคอลัมน์ที่ถูกลบ
#    อย่ารันบน DB production โดยไม่ backup ก่อน
# 2. ลบไฟล์ migration ออก (ทำหลัง database update เท่านั้น)
dotnet ef migrations remove

# generate SQL (production)
dotnet ef migrations script
dotnet ef migrations script <from> <to>

⚠️ Pitfall: rename column = drop + add column (data หาย) — ต้อง override ใน migration ใช้ RenameColumn


6. CRUD ด้วย EF Core

ทีนี้มาดู CRUD จริงผ่าน EF Core — inject DbContext เข้า service แล้วใช้ Add/Remove/Find ตามด้วย SaveChangesAsync() ที่ commit (ยืนยันบันทึก) การเปลี่ยนแปลงทั้งหมด สังเกตว่า EF ทำ change tracking (จดจำการแก้ entity) ให้ จึงแค่แก้ property แล้ว save ก็ได้ UPDATE เอง (DbContext เป็น Scoped — 1 instance ต่อ request):

csharp
public class UsersService
{
    private readonly AppDbContext _db;
    public UsersService(AppDbContext db) => _db = db;

    // ⚠️ ในโค้ดตัวอย่างนี้ return User entity ตรง ๆ เพื่อให้เข้าใจง่าย
    // แต่ใน production ควร project เป็น DTO ก่อน (เช่น .Select(u => new UserResponse(...)))
    // เพื่อกัน PasswordHash และ field ที่ไม่ควรเปิดเผยรั่วออกไปเมื่อ serialize ส่ง HTTP response
    public Task<List<User>> List() => _db.Users.AsNoTracking().ToListAsync();

    // FindAsync คืน ValueTask<User?> — เปิดเป็น async/await ให้ตรงไปตรงมา
    public async Task<User?> Get(Guid id) => await _db.Users.FindAsync(id);

    public async Task<User> Create(CreateUserDto dto)
    {
        var u = new User { Name = dto.Name, Email = dto.Email };
        _db.Users.Add(u);
        await _db.SaveChangesAsync();
        return u;
    }

    // หมายเหตุ: pattern นี้ silent overwrite เมื่อสองคนแก้แถวเดียวกันพร้อมกัน
    // ดู §13 เรื่อง optimistic concurrency (xmin/RowVersion) ถ้าต้องการ 409 Conflict แทน
    public async Task<User?> Update(Guid id, UpdateUserDto dto)
    {
        var u = await _db.Users.FindAsync(id);
        if (u is null) return null;
        if (dto.Name is not null) u.Name = dto.Name;
        if (dto.Email is not null) u.Email = dto.Email;
        await _db.SaveChangesAsync();
        return u;
    }

    public async Task<bool> Delete(Guid id)
    {
        var u = await _db.Users.FindAsync(id);
        if (u is null) return false;
        _db.Users.Remove(u);
        await _db.SaveChangesAsync();
        return true;
    }
}

AppDbContext มี lifetime = Scoped (1 instance per request) — ไม่ต้องสร้างเอง


7. Query — LINQ to SQL

จุดเด่นของ EF Core คือเขียน query เป็น LINQ (C#) แล้วมันแปลเป็น SQL ให้ — Where/Select/Include/Skip/Take map ไปเป็น WHERE/JOIN/pagination (การแบ่งหน้า) เคล็ดลับ performance สำคัญคือ AsNoTracking() สำหรับ query อ่านอย่างเดียว (ปิด change tracking → เร็วกว่า + ประหยัด memory เพราะไม่ต้องจดจำว่าจะแก้อะไร) ส่วนนี้รวม query pattern ที่ใช้บ่อย:

csharp
// กรอง (where) แล้วเลือกเฉพาะ field ที่ต้องการ (select)
var emails = await _db.Users
    .Where(u => u.CreatedAt > DateTime.UtcNow.AddDays(-7))
    .Select(u => u.Email)
    .ToListAsync();

// join ตารางที่สัมพันธ์กัน ด้วย Include
var users = await _db.Users
    .Include(u => u.Posts)
    .ToListAsync();

// include ซ้อนหลายชั้น (โหลด Users แล้วโหลด Posts และ Author ของแต่ละ Post)
// หมายเหตุ: ถ้ามี navigation property อื่น เช่น Post.Comments
// ก็ ThenInclude ต่อได้ในลักษณะเดียวกัน
await _db.Users
    .Include(u => u.Posts)
        .ThenInclude(p => p.Author)    // โหลด Author ของแต่ละ Post (เป็น User)
    .ToListAsync();

// projection — แปลงผลลัพธ์เป็น DTO ตรง ๆ (ดึงเฉพาะ field ที่ใช้ เร็วกว่า)
var summaries = await _db.Users
    .Select(u => new UserSummary(u.Id, u.Name, u.Posts.Count))
    .ToListAsync();

// แบ่งหน้า (pagination) — ข้าม 20 แถวแรก เอา 10 แถวถัดไป
var page = await _db.Users
    .OrderBy(u => u.CreatedAt)
    .Skip(20).Take(10)
    .ToListAsync();

// aggregate — สรุปผลรวม เช่น นับจำนวน หาค่าเฉลี่ย
var someDate = DateTime.UtcNow.AddDays(-30);
int count = await _db.Users.CountAsync(u => u.CreatedAt > someDate);
double avgPosts = await _db.Users.AverageAsync(u => u.Posts.Count);

Track vs No-Track

csharp
// ค่าตั้งต้น = track (EF จดจำการเปลี่ยนแปลง)
var u = await _db.Users.FindAsync(id);
u.Name = "Bob";
await _db.SaveChangesAsync();   // EF gen คำสั่ง UPDATE ให้เอง

// no-track — สำหรับอ่านอย่างเดียว (เร็วกว่า เพราะไม่ต้องจดจำ)
var u2 = await _db.Users.AsNoTracking().FirstAsync(u => u.Id == id);

💡 ทุก query ที่อ่านอย่างเดียวใช้ AsNoTracking() — ลด memory + เร็ว เพราะ EF ไม่ต้องจดจำ entity ใน change tracker

💡 ถ้า query join entity เดิมหลายรอบ (เช่น Post → Author + Comment → Author) AsNoTrackingWithIdentityResolution() จะ dedupe instance ของ Author คนเดียวกันให้ ทำให้ใช้ memory น้อยกว่า AsNoTracking() ธรรมดาในเคสนี้


8. ExecuteUpdate / ExecuteDelete (EF Core 7+)

การ update/delete หลายแถวด้วย pattern ปกติ (load entity → แก้ → save) ช้าเพราะต้องดึงทุก row (แถว) เข้า memory ก่อน — EF Core 7+ มี ExecuteUpdate/ExecuteDelete ที่ gen SQL UPDATE/DELETE ... WHERE ตรง ๆ ไม่ผ่าน change tracker เร็วกว่ามากสำหรับ bulk operation (การจัดการทีละหลายแถว):

csharp
// bulk update — แก้หลายแถวรวดเดียว โดยไม่ load entity เข้ามาก่อน
await _db.Users
    .Where(u => u.LastLogin < DateTime.UtcNow.AddYears(-1))
    .ExecuteUpdateAsync(s => s.SetProperty(u => u.Active, false));

// bulk delete — ลบหลายแถวรวดเดียว
await _db.Posts
    .Where(p => p.CreatedAt < DateTime.UtcNow.AddYears(-5))
    .ExecuteDeleteAsync();

เร็วกว่ามาก — generate SQL ตรง ๆ ไม่ผ่าน change tracker

⚠️ ข้อควรระวังของ ExecuteUpdate/ExecuteDelete:

  • bypass change trackerSaveChanges events และ interceptors ไม่ทำงาน
  • bypass query filters — รวมถึง global query filter ที่ใช้ทำ soft delete (§14) และ multi-tenancy (§15) → อาจลบ/แก้ข้อมูลข้ามขอบเขต ถ้าเผลอ
  • bypass concurrency token — ไม่ตรวจ xmin/RowVersion ให้ (§13)

ใช้สำหรับงาน bulk ที่รู้ขอบเขตชัดเจน · งานปกติยังควรใช้ load → modify → save


9. Raw SQL

บางครั้ง LINQ ทำไม่ได้หรือ performance ไม่พอ — EF Core ให้เขียน raw SQL (SQL ดิบที่เขียนเอง) ผ่าน 3 ช่องทาง:

  • FromSql(...) — รัน SELECT แล้ว map กลับเป็น entity
  • ExecuteSql(...) — รันคำสั่งที่ไม่ต้อง map ผล (UPDATE/DELETE/etc.)
  • เข้าถึง DbConnection ตรง ๆ — เมื่อต้องการ control เต็ม

🚨 กฎความปลอดภัย: ห้าม string concat ค่าจาก user ลง SQL!

  • SQL injection = การที่ผู้ไม่หวังดีแอบใส่ SQL ผ่าน input (เช่น input ชื่อว่า ' OR '1'='1) → ถ้าเรา concat เข้า SQL ตรง ๆ จะกลายเป็น query ที่อ่านข้อมูลเกินที่ตั้งใจ
  • ใน EF Core ใช้ string interpolation ($"...{x}") ผ่าน FromSql/ExecuteSql — EF จะแปลง {x} เป็น parameter ให้อัตโนมัติ (ปลอดภัย)
  • ห้ามใช้ + หรือ string.Format ต่อ SQL — จะกลายเป็น string ธรรมดาที่ไม่ parameterize
csharp
// แบบผูก type ชัดเจน (map ผลกลับเป็น entity User)
var users = await _db.Users
    .FromSql($"SELECT * FROM users WHERE email = {email}")   // {email} กลายเป็น parameter ปลอดภัย
    .ToListAsync();

// คำสั่งที่คืนค่าเดียว (scalar)
var n = await _db.Database.ExecuteSqlAsync($"UPDATE users SET active=true WHERE id={id}");

// query อิสระ — เข้าถึง DbConnection ตรง ๆ
await using var conn = _db.Database.GetDbConnection();
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM users";
var count = (long)(await cmd.ExecuteScalarAsync())!;

FormattableString interpolation safe — EF แปลง {email} เป็น parameter ไม่ใช่ string concat

⚠️ FromSql ต้อง return คอลัมน์ครบและตรงกับ mapping ของ entity — ถ้า SQL ที่เขียน return column ไม่ครบ/ผิด type จะ throw ตอน runtime (เห็น error เฉพาะตอนยิงจริง ไม่ใช่ตอน compile)


10. Transaction

transaction = กลุ่มคำสั่งที่ต้องสำเร็จทั้งหมดหรือยกเลิกทั้งหมด

SaveChanges แต่ละครั้ง EF Core ครอบ transaction ในตัวให้อยู่แล้ว — เพียงพอสำหรับงานปกติ

แต่ถ้าต้องการให้ หลาย SaveChanges สำเร็จ/ล้มเหลวพร้อมกัน ต้องใช้ explicit transaction ที่ประกาศเองด้วย BeginTransaction + Commit/Rollback

และเมื่อใช้กับ cloud DB ควรครอบด้วย execution strategy เพื่อ retry อัตโนมัติเมื่อ connection สะดุด:

csharp
// implicit — SaveChanges มี transaction ในตัวอยู่แล้ว
await _db.SaveChangesAsync();

// explicit — ประกาศเอง เมื่อต้องการครอบหลาย SaveChanges
using var tx = await _db.Database.BeginTransactionAsync();
try
{
    await _db.Users.AddAsync(user);
    await _db.SaveChangesAsync();
    await _db.AuditLogs.AddAsync(log);
    await _db.SaveChangesAsync();
    await tx.CommitAsync();
}
catch (Exception ex)
{
    // RollbackAsync อาจ throw เองได้ถ้า connection ขาด — ครอบไว้เพื่อไม่ให้ทับ exception เดิม
    try { await tx.RollbackAsync(); }
    catch (Exception rbEx) { /* log rbEx ถ้ามี logger: logger.LogError(rbEx, "rollback failed") */ }
    throw; // rethrow exception เดิม
}

// EF Core 6+ — execution strategy + retry
var strategy = _db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
    using var tx = await _db.Database.BeginTransactionAsync();
    // ...
    await tx.CommitAsync();
});

11. N+1 Trap

N+1 (เอ็น-บวก-หนึ่ง) เป็นปัญหา performance ที่เจอบ่อยสุดกับ ORM — query หลัก 1 ครั้งดึง N รายการ แล้ววนเข้าถึง relation (ข้อมูลที่สัมพันธ์กัน) ทำให้ยิง query เพิ่มอีก N ครั้ง (รวมเป็น N+1 ครั้ง) ข่าวดีคือ EF Core ปิด lazy loading (การโหลดข้อมูลที่สัมพันธ์แบบทันทีที่เข้าถึง) โดย default จึงบังคับให้คิดเรื่องนี้ แก้ด้วย Include หรือ projection ให้โหลดมาในครั้งเดียว:

csharp
// ❌ เกิด N+1
var users = await _db.Users.ToListAsync();
foreach (var u in users)
    // ถ้าใช้ EF Core ค่าตั้งต้น (ปิด lazy load): Posts.Count = 0 — ไม่โหลดมา ไม่ใช่ N+1
    // N+1 เกิดจริงเมื่อเปิด lazy loading (ไม่แนะนำ) — EF จะยิง query แยกต่อ user
    Console.WriteLine(u.Posts.Count);

// ✅ ใช้ Include โหลดมาทีเดียว
var users = await _db.Users.Include(u => u.Posts).ToListAsync();

// ✅ ใช้ projection ดึงเฉพาะที่ต้องการ
var data = await _db.Users
    .Select(u => new { u.Name, PostCount = u.Posts.Count })
    .ToListAsync();

⚠️ EF Core ค่าตั้งต้น = ปิด eager loading และไม่มี lazy loading (กัน N+1) — ต้องสั่ง Include เองชัด ๆ (eager loading = โหลดข้อมูลสัมพันธ์มาพร้อมกันทันที)


12. Performance Tips

รวมเทคนิครีด performance จาก EF Core — AsNoTracking สำหรับ read, compiled query (query ที่แปลไว้ล่วงหน้า), index ที่เหมาะ และ ToQueryString() ดู SQL จริงที่ gen ออกมา เทคนิคพวกนี้สร้างความต่างมากใน production

cartesian explosion คืออะไร — เมื่อ Include หลายความสัมพันธ์ในครั้งเดียว (เช่น user ที่มีทั้ง Posts และ Comments) SQL จะ JOIN ทุกตารางเข้าด้วยกัน ทำให้แถวผลลัพธ์ทวีคูณกันแบบบานปลาย (เช่น 1 user มี 10 posts × 10 comments = 100 แถว ที่ข้อมูล user ซ้ำกัน 100 รอบ) ใช้ AsSplitQuery แก้โดยแยกยิงเป็นหลาย query แทนที่จะ JOIN ก้อนเดียว

csharp
// 1. AsNoTracking สำหรับ query ที่อ่านอย่างเดียว
_db.Users.AsNoTracking()

// 2. AsSplitQuery เมื่อ Include หลายระดับ (กัน cartesian explosion ที่อธิบายข้างบน)
_db.Users.Include(u => u.Posts).Include(u => u.Comments).AsSplitQuery()

// 3. compiled query — แปล query ไว้ล่วงหน้า ใช้ซ้ำได้เร็วขึ้น (.NET 7+)
// EF.CompileAsyncQuery + FirstOrDefault คืน IAsyncEnumerable (ไม่ใช่ Task<User?> โดยตรง)
// ใช้งานด้วย: await foreach หรือ await query(db, id).SingleOrDefaultAsync()
// ทางเลือกที่ง่ายกว่าสำหรับ single entity: ใช้ EF.CompileQuery (synchronous) แล้วห่อ Task
private static readonly Func<AppDbContext, Guid, IAsyncEnumerable<User>> GetById =
    EF.CompileAsyncQuery((AppDbContext db, Guid id) =>
        db.Users.Where(u => u.Id == id));

// 4. ใช้ index ช่วยค้นเร็ว — เพิ่มใน OnModelCreating
b.HasIndex(u => new { u.Status, u.CreatedAt });

// 5. ดู SQL จริงที่ EF จะ gen ออกมา (ช่วย debug performance)
_db.Users.Where(u => u.Email == "x").ToQueryString();   // → ตัวอย่าง SQL

13. Concurrency Control

เมื่อสองคนแก้ข้อมูลแถวเดียวกันพร้อมกัน คนที่ save ทีหลังอาจทับงานคนแรกโดยไม่รู้ตัว

optimistic concurrency = การคุมการแก้พร้อมกันแบบ "ไม่ล็อกแถวก่อน แต่ตรวจตอน save"

วิธีคือเก็บ "version" ของแถวไว้ — ถ้า version เปลี่ยนไประหว่างที่อ่านมาแก้ EF จะ throw DbUpdateConcurrencyException ให้จัดการ (reload = โหลดใหม่ หรือ merge = รวมการแก้) แทนที่จะทับเงียบ ๆ:

csharp
// optimistic concurrency ผ่านคอลัมน์ xmin ของ PostgreSQL (ใช้เป็นเลข version ของแถว)
b.Property<uint>("xmin").IsRowVersion();

// ในแอป
try
{
    var u = await _db.Users.FindAsync(id);
    u.Name = "Bob";
    await _db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
    // มีคนแก้ตัดหน้าไปก่อน → โหลดใหม่ (reload) หรือรวมการแก้ (merge)
}

14. Soft Delete

หลายระบบไม่อยากลบข้อมูลจริง — เก็บไว้สำหรับ audit (ตรวจสอบย้อนหลัง) หรือกู้คืน แต่ทำให้เหมือนถูกลบไปแล้ว

soft delete = การลบแบบ "ทำเครื่องหมายว่าลบ" ไม่ลบจริง

วิธีทำ: ใช้คอลัมน์ DeletedAt แล้วตั้ง global query filter (ตัวกรองที่ใช้กับทุก query อัตโนมัติ) ให้ทุก query ซ่อนแถวที่ถูกลบ · เรียก IgnoreQueryFilters() เมื่อต้องการเห็นรวมแถวที่ลบไป:

csharp
public class User { public DateTime? DeletedAt { get; set; } /*...*/ }

// ตั้ง global filter — ทุก query ซ่อนแถวที่ DeletedAt ไม่ใช่ null
mb.Entity<User>().HasQueryFilter(u => u.DeletedAt == null);

// อยากเห็นรวมที่ถูกลบด้วย ก็สั่งข้าม filter
await _db.Users.IgnoreQueryFilters().ToListAsync();

15. Multi-tenancy ผ่าน global filter

คำศัพท์:

  • SaaS (Software as a Service) = ซอฟต์แวร์ที่ให้บริการผ่านเว็บ
  • multi-tenant = หลายลูกค้า/องค์กรใช้ระบบเดียวกัน แต่ละรายเรียกว่า tenant

ในแอป SaaS แบบ multi-tenant ที่หลายลูกค้าแชร์ DB เดียวกัน global query filter ช่วยกัน "ข้อมูลรั่วข้าม tenant" ได้อัตโนมัติ

ตั้ง filter ตาม TenantId ของ request ปัจจุบัน → ทุก query จะถูกกรองให้เห็นเฉพาะข้อมูลของ tenant นั้น โดยไม่ต้องเติม .Where(t => t.TenantId == ...) เองทุกที่ (ลดความเสี่ยงลืม):

csharp
// AppDbContext.cs — _tenant เป็น field ที่ inject เข้ามาทาง constructor
// ITenantContext เป็น interface ที่เราเขียนเองและ register เข้า DI
// (อ่านค่า tenant จาก JWT claim หรือ HTTP header ใน middleware)
public class AppDbContext : DbContext
{
    private readonly ITenantContext _tenant;

    public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenant)
        : base(options) => _tenant = tenant;

    protected override void OnModelCreating(ModelBuilder mb)
    {
        mb.Entity<User>().HasQueryFilter(u => u.TenantId == _tenant.Id);
    }
}

ทุก query auto filter ตาม tenant ปัจจุบัน

HasQueryFilter lambda ถูกประเมินทุก query ไม่ใช่แค่ตอน build model — EF Core เก็บ expression tree ไว้ แล้วประเมินค่า _tenant.Id ทุกครั้งที่ยิง query จริง ดังนั้น filter จะได้ค่า tenant ที่ถูกต้องต่อ request

⚠️ ข้อสำคัญ: DbContext ต้องเป็น Scoped (ค่าตั้งต้น) ไม่ใช่ Singleton — เพราะแต่ละ request ต้องได้ instance ใหม่ที่ inject ITenantContext ของตัวเอง · ถ้าใช้ DbContextPool ต้องระวังเป็นพิเศษเพราะ instance อาจถูก reuse ข้าม request


16. Connection Resilience (cloud DB)

cloud DB มี connection สะดุดเป็นครั้งคราว สาเหตุที่พบบ่อย:

  • failover = สลับไปใช้เครื่องสำรองตอนเครื่องหลักล่ม
  • network blip = เน็ตกระตุกชั่วครู่
  • throttling = service จำกัด rate ชั่วคราว

ถ้าไม่จัดการ query จะ fail ทันทีเมื่อเจอเหตุพวกนี้

EF Core มี retry on failure ที่ลองใหม่อัตโนมัติเมื่อเจอ transient error (error ชั่วคราวที่หายเองได้) → แอปทนต่อความสะดุดเล็ก ๆ โดยไม่ต้องเขียน retry เอง:

csharp
builder.Services.AddDbContext<AppDbContext>(opt =>
    opt.UseNpgsql(connString, npg =>
    {
        npg.EnableRetryOnFailure(
            maxRetryCount: 5,
            maxRetryDelay: TimeSpan.FromSeconds(30),
            errorCodesToAdd: null);
    }));

จำเป็นกับ Azure SQL/Cosmos ที่ throttle ได้


17. Migration ใน Production

bash
# ใน CI: gen SQL แบบ idempotent (รันซ้ำกี่ครั้งผลเหมือนเดิม ไม่พัง)
dotnet ef migrations script --idempotent --output migration.sql

# ให้ DBA (ผู้ดูแลฐานข้อมูล) หรือเครื่องมือ migration นำไป apply
psql -f migration.sql

หรือ run จาก app ตอน startup (ไม่แนะนำสำหรับ multi-replica — กรณีรันแอปหลายตัวพร้อมกัน):

csharp
await using (var scope = app.Services.CreateAsyncScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await db.Database.MigrateAsync();
}

⚠️ อย่ารัน migration ใน startup hook เมื่อใช้ rolling deploy — อธิบายเป็นข้อ:

  • rolling deploy = วิธี deploy แบบทยอยเปลี่ยน instance ทีละตัว (เพื่อไม่ให้บริการขาดช่วง)
  • replica = สำเนาของแอปที่รันพร้อมกันหลาย instance
  • ระหว่าง rolling: replica ใหม่ migrate schema แล้ว แต่ replica เก่ายังรันด้วย schema เก่าอยู่ → schema ไม่ตรงกับ runtime ระหว่างช่วง deploy
  • วิธีที่ดีกว่า: แยก migration เป็น job ต่างหาก (เช่น Kubernetes Job = งานที่รันครั้งเดียวจบบน Kubernetes) ที่เสร็จก่อนค่อย deploy แอป

18. Common Pitfalls

  1. Tracked DbContext + อายุยาว → memory โต — scoped ดีอยู่แล้ว, อย่าตั้งเป็น singleton
  2. Include ใหญ่ ๆ = cartesian explosion → ใช้ AsSplitQuery
  3. SaveChanges ใน loop = เกิด N transactions → รวมเป็น batch (ทำทีเดียวหลายอัน)
  4. เปิด lazy loading = N+1 ทุกที่ — ปิดไว้
  5. .ToList() แล้วค่อย filter → โหลดทั้งหมดเข้า memory แล้วกรองในแอป → ควรกรองใน DB ก่อน
  6. Decimal precision (จำนวนหลักรวม, จำนวนหลักทศนิยม) = ค่าตั้งต้น (18, 2) ซึ่ง OK สำหรับเงินทั่วไป — สำหรับเงินที่ต้องการความละเอียดสูง (เช่น forex, crypto) แนะนำ (19, 4): b.Property(x => x.Price).HasPrecision(19, 4)
  7. Migration ที่ rename → EF ไม่รู้ว่าเป็นการเปลี่ยนชื่อ → ใส่ manual override (สั่ง RenameColumn เอง)

🛠️ Checkpoint 2 — ลงมือทำ

  1. setup PG ด้วย Docker (ต้องติดตั้ง Docker Desktop ก่อน — ช่วยรัน PostgreSQL ในเครื่องโดยไม่ต้องติดตั้ง DB จริง):
    bash
    docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=pg postgres:16.4-alpine

    ⚠️ password pg ใช้ได้เฉพาะ local dev — ห้ามใช้ใน environment อื่น

  2. สร้าง AppDbContext + entity User/Post + migration
  3. CRUD endpoint จากบทที่ 1 → switch จาก in-memory เป็น EF
  4. ทำ pagination + sort + filter ที่ endpoint GET /users?page=1&size=20&sort=createdAt
  5. ทำ bulk update ด้วย ExecuteUpdate
  6. ลอง AsNoTracking + benchmark กับเดิม
  7. enable Resilience strategy + transaction รวม insert user + audit log

สรุปบทที่ 2

  • EF Core = code-first ORM ของ .NET — LINQ → SQL + migration
  • DbContext = scoped (1 per request)
  • Include ทุก relation ที่ใช้ — กัน N+1
  • AsNoTracking ทุก read query
  • ExecuteUpdate/ExecuteDelete สำหรับ bulk
  • transaction ที่ explicit + retry strategy สำหรับ cloud DB
  • migration ใน CI/CD เป็น job แยก ไม่ใช่ startup hook

→ บทที่ 3: Security + JWT