Skip to content

บทที่ 7 — Dependency Injection ลึก (lifetime, options, validation, scope)

← บทที่ 6 | สารบัญ | บทที่ 8: Background Service →

🟡 หมายเหตุระดับ: ชื่อบทบอก "ลึก" — เนื้อหาครอบ lifetime, options, decorator, captive dependency, scope — เหมาะระดับกลางขึ้นไป สำหรับ deep dive ระดับ production จริง (AsyncLocal pitfall, root provider lifecycle, container ของ 3rd-party) ยังต้องอ่านเอกสาร Microsoft + Andrew Lock blog เสริม

ASP.NET Core มี DI container ในตัว (Microsoft.Extensions.DependencyInjection) — เบา + เร็ว + พอกับเคสปกติส่วนใหญ่ของ web app

ส่วน 3rd-party container (Autofac, Lamar) จะใช้เมื่อต้องการ feature ขั้นสูงเหล่านี้:

📝 ศัพท์ขั้นสูงด้านล่าง — AOP, interception, Dynamic proxy เป็นแนวคิดที่จะเข้าใจเองเมื่อสร้างระบบใหญ่ขึ้น มือใหม่ข้ามรายการนี้ได้ กลับมาอ่านตอนที่ DI container มาตรฐานไม่พอ

  • AOP/interception — เพิ่มพฤติกรรมรอบ ๆ method (เช่น log, transaction) โดยไม่แก้โค้ดเดิม
  • Conditional registration ซับซ้อน — เลือก implementation ตามเงื่อนไข runtime
  • Multi-tenant container per request — มี container แยกต่อ tenant
  • Dynamic proxy ระดับ enterprise — สร้าง class ห่อ runtime อัตโนมัติ

🚀 โซนขั้นสูง — มือใหม่ข้ามได้ บทนี้เจาะลึก DI (lifetime, options pattern, decorator, captive dependency) ถ้าเข้าใจ DI พื้นฐานจากบทที่ 0 และใช้ AddScoped/AddSingleton เป็นแล้ว ส่วนพื้นฐาน (หัวข้อ 1-4) อ่านได้เลย ส่วนที่เหลือ (decorator, assembly scan, AsyncLocal) ค่อยกลับมาตอนเจอปัญหาจริง


1. Lifetime ทั้ง 3 (ทบทวน + ลึก)

💡 stateless = service ที่ไม่เก็บสถานะส่วนตัวระหว่างการเรียก (ไม่มี field ที่เปลี่ยนไปตามแต่ละ request) จึงแชร์เป็น Singleton ได้ปลอดภัย — ตรงข้ามคือ stateful ที่เก็บสถานะ ต้องระวังเรื่องการแชร์

csharp
builder.Services.AddSingleton<IClock, SystemClock>();      // 1 instance ทั้ง app
builder.Services.AddScoped<IUserRepo, UserRepo>();         // 1 ต่อ HTTP request
builder.Services.AddTransient<IEmailer, SmtpEmailer>();    // สร้างใหม่ทุกครั้งที่ inject

Lifetime + Scope tree

Captive Dependency Bug

captive dependency = บั๊กที่ service อายุยาว (Singleton) ดึง service อายุสั้น (Scoped) มาเก็บไว้

💡 resolve = ขั้นตอนที่ container สร้าง/คืน instance ของ service ให้ตอนมีคนขอ (เช่นตอน inject เข้า constructor) — คำนี้จะเจอซ้ำบ่อยตลอดบทนี้

ผลลัพธ์จริงที่เกิดขึ้นมี 4 ขั้น:

  • Singleton จะใช้ DbContext (หรือ Scoped service อื่น) ตัวเดิมของ request แรกตลอดไป
  • ไม่มีการสร้าง instance ใหม่ให้แต่ละ request ตามที่ Scoped ควรทำ
  • ทุก request จึงใช้ connection/state ตัวเดิมร่วมกัน ทำให้ข้อมูลปะปนข้ามกัน
  • ตัวอายุสั้น (Scoped) ถูก "กักขัง" (capture) ให้มีชีวิตยาวเท่า Singleton ไปด้วย
csharp
// ❌ Singleton ดึง Scoped มาเก็บ — "กักขัง (capture)" Scoped ไว้ → Scoped ไม่ถูกปล่อย (release)
public class CacheService(IUserRepo repo) { }    // ตัวนี้จะถูก register เป็น Singleton

// registration ที่ทำให้ bug นี้ reproduce ได้ครบ:
builder.Services.AddScoped<IUserRepo, UserRepo>();        // อายุสั้น (1 ต่อ request)
builder.Services.AddSingleton<CacheService>();            // อายุยาว → จะ "กักขัง" IUserRepo ไว้
// ผล: _repo ใน CacheService จะเป็น instance ของ request แรกตลอดไป
// — query ทุก request ใช้ DbContext เดิม ข้อมูลปะปน/สถานะผิดพลาด

ใน Development (เมื่อ ServiceProviderOptions.ValidateScopes = true ซึ่ง ASP.NET Core เปิดให้อัตโนมัติใน Dev environment) container จะ throw ตอน resolve:

💡 ValidateScopes vs ValidateOnBuild

  • ValidateScopes = ตรวจตอน resolve ครั้งแรก (runtime check) เห็นเฉพาะ service ที่ถูก resolve จริง
  • ValidateOnBuild = ตรวจตอน build provider (startup eager check) ไล่ทุก registration ทันที — fail fast ก่อน ใช้ทั้ง 2 ตัวคู่กันจะปลอดภัยที่สุด
text
System.InvalidOperationException: Cannot consume scoped service
'IUserRepo' from singleton 'CacheService'.

วิธี repro บนเครื่อง: สร้าง minimal API project → ใส่ 2 register ด้านบน → dotnet run ใน Development → ดู exception ตอน app เริ่ม (ถ้าเรียกใน endpoint ก็จะเจอตอน resolve)

แก้: Singleton ที่ต้องใช้ Scoped service → inject IServiceScopeFactory (โรงงานสร้าง scope — ตัวที่แนะนำให้ใช้ใน Singleton/HostedService) แล้วสร้าง scope สั้น ๆ ตอนจะใช้ scoped service:

💡 IAsyncDisposable = interface สำหรับ object ที่ต้อง cleanup งานแบบ async (เช่น DbContext ที่ปิด connection ผ่าน network ใช้เวลา) — คู่กับมันคือ keyword await using (แทน using ธรรมดา) ที่จะ await การ cleanup ให้เสร็จก่อนไปต่อ

csharp
public class JobRunner(IServiceScopeFactory scopeFactory)
{
    public async Task Run(CancellationToken ct)
    {
        await using var scope = scopeFactory.CreateAsyncScope();   // async scope ปลอดภัยกับ IAsyncDisposable
        var repo = scope.ServiceProvider.GetRequiredService<IUserRepo>();
        // ...ใช้ repo ภายใน scope นี้เท่านั้น...
    }
}

💡 ทำไม IServiceScopeFactory แทน IServiceProvider?

  • IServiceScopeFactory สื่อเจตนาชัด — บอกว่า "ฉันจะสร้าง scope"
  • CreateAsyncScope() รองรับ IAsyncDisposable (เช่น DbContext) ได้ถูกต้อง
  • IServiceProvider ที่ inject เข้า Singleton คือ root provider — ถ้าเผลอ GetRequiredService<Scoped>() ตรง ๆ จะ resolve จาก root → captive bug กลับมาทันที

Pattern นี้ใช้ใน HostedService/Background Service บ่อย — ดูบทที่ 8


2. Registration Methods

csharp
// instance
services.AddSingleton<IClock>(new SystemClock());

// factory
services.AddSingleton<IClock>(sp => new SystemClock());

// keyed services (ต้องใช้ .NET 8 ขึ้นไป — ตรวจ <TargetFramework> ใน .csproj ว่าเป็น net8.0 หรือสูงกว่า
//                  project ที่ target net6.0/net7.0 compile ไม่ผ่าน แม้รันบน .NET 8 runtime)
services.AddKeyedSingleton<INotifier, EmailNotifier>("email");
services.AddKeyedSingleton<INotifier, SmsNotifier>("sms");

// inject
public class Notification([FromKeyedServices("email")] INotifier notifier) { }

Try (ไม่ override)

csharp
services.TryAddSingleton<IClock, SystemClock>();   // เพิ่มถ้ายังไม่มี
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHandler, FooHandler>());

ใช้สำหรับ library — extension method ที่ตัว AddXxx() กำหนด default ไม่ทับ user


3. Multiple implementations

บางครั้งมีหลาย implementation (ตัวที่ทำงานจริงของ interface) ของ interface เดียวกันที่อยากใช้ทั้งหมด เช่น validator (ตัวตรวจสอบความถูกต้องของข้อมูล) หลายตัวที่ตรวจคนละด้าน

วิธีทำคือ register หลายตัวแล้ว inject เป็น IEnumerable<T>DI จะส่งมาทุกตัว

รูปแบบที่เหมาะ:

  • pipeline = ส่งข้อมูลผ่านขั้นตอนต่อเนื่อง แต่ละขั้นแปลง/ตรวจสอบ
  • chain of responsibility = ลูกโซ่ความรับผิดชอบ — ส่งงานต่อกันเป็นทอด ๆ ใครรับผิดชอบได้ก็จัดการ
csharp
services.AddSingleton<IValidator, NameValidator>();
services.AddSingleton<IValidator, EmailValidator>();
services.AddSingleton<IValidator, AgeValidator>();

// inject ทั้งหมด
public class UserService(IEnumerable<IValidator> validators)
{
    public void Check(User u)
    {
        foreach (var v in validators) v.Validate(u);
    }
}

ดีสำหรับ pipeline / chain of responsibility


4. Options Pattern

Basic

csharp
public class JwtOptions
{
    public string Secret { get; init; } = "";
    public string Issuer { get; init; } = "";
    public int AccessMinutes { get; init; } = 15;
}

services.Configure<JwtOptions>(builder.Configuration.GetSection("Jwt"));

// inject
public class TokenService(IOptions<JwtOptions> opts)
{
    private readonly JwtOptions _o = opts.Value;
}

Validation

csharp
services.AddOptions<JwtOptions>()
    .Bind(builder.Configuration.GetSection("Jwt"))
    .Validate(o => o.Secret.Length >= 32, "Secret too short")
    .Validate(o => o.AccessMinutes > 0, "AccessMinutes must be positive")
    .ValidateOnStart();   // เรียก validation ตอน startup ทันที (fail fast)

Data Annotations

csharp
public class JwtOptions
{
    [Required, MinLength(32)] public string Secret { get; init; } = "";
    [Required] public string Issuer { get; init; } = "";
}

services.AddOptions<JwtOptions>()
    .Bind(...)
    .ValidateDataAnnotations()
    .ValidateOnStart();

IOptionsSnapshot — reload config (per request)

csharp
public class TokenService(IOptionsSnapshot<JwtOptions> opts)
{
    // opts.Value ใหม่ทุก request ถ้า config เปลี่ยน
}

IOptionsMonitor — change notification

ทุกครั้งที่ subscribe (สมัครฟัง) การเปลี่ยนแปลง config ผ่าน OnChange container จะเก็บ listener (ตัวฟังเหตุการณ์) นั้นไว้ในหน่วยความจำจนกว่าจะถูก dispose — ถ้า subscribe ซ้ำ ๆ โดยไม่ dispose ของเก่า listener จะสะสมพอกพูนขึ้นเรื่อย ๆ ("listener บวม") จนกิน memory มาก

✅ ปลอดภัย — consumer เป็น Singleton: สร้างครั้งเดียวตลอดอายุ app, dispose ตอน app ปิด → listener ไม่บวม

csharp
public class CacheService : IDisposable
{
    private readonly IDisposable? _sub;   // ⭐ subscription handle ต้องเก็บไว้ dispose
                                          // IDisposable? (nullable) เพราะ OnChange คืน null ได้ตาม interface contract
                                          // ดังนั้น nullable annotation นี้ถูกต้อง — ใช้ null-conditional dispose: _sub?.Dispose()

    public CacheService(IOptionsMonitor<CacheOptions> monitor)
    {
        _sub = monitor.OnChange(opts => /* react */);
    }

    public void Dispose() => _sub?.Dispose();
}

services.AddSingleton<CacheService>();   // ✅ ตัว subscribe ต้องเป็น Singleton

❌ อันตราย — consumer เป็น Scoped/Transient: ทุก request จะสร้าง instance ใหม่ ซึ่งจะ subscribe listener ใหม่เข้า monitor ทุกครั้ง ถ้าลืม dispose listener เหล่านี้จะสะสมไม่มีที่สิ้นสุด

csharp
services.AddScoped<RequestCache>();   // ❌ subscribe ใน scoped → leak ทุก request

⚠️ OnChange คืน IDisposable — สรุปสั้น ๆ ก่อน: ถ้าไม่ dispose สิ่งที่ subscribe ไว้ มันจะรั่วไหลของหน่วยความจำ (memory leak) แตกประเด็นเป็นข้อ ๆ:

  • ทุก instance ของ subscriber ที่ถูกสร้างใหม่ จะ register listener (ตัวฟังเหตุการณ์) เข้า monitor ตัวเดียวกัน
  • subscription handle (ตัวจับยึดการสมัครฟัง) คือ IDisposable ที่ OnChange คืนกลับมา
  • ถ้าไม่ dispose handle ภายใน scope/instance นั้น → listener จะค้างใน monitor ตลอดอายุ process
  • ผลคือ memory leak (หน่วยความจำรั่ว) ที่หาเจอยาก

สังเกตอาการ: รัน load test แล้ว memory ค่อย ๆ ขึ้นเรื่อย ๆ โดยไม่ยอมลดลงตอน scope ปิด — มักเป็นตัวนี้

InterfaceLifetimeใช้กับ
IOptions<T>Singletonconfig ที่ไม่เปลี่ยน
IOptionsSnapshot<T>Scopedconfig ที่ reload ต่อ request
IOptionsMonitor<T>Singleton + notifylisten change

5. Service Locator (anti-pattern)

service locator คือการดึง dependency (ตัวที่ class ต้องใช้) จาก container เองในโค้ด เช่นเรียก sp.GetRequiredService<> ตรง ๆ แทนที่จะรับผ่าน constructor

ทำไมถือว่าเป็น anti-pattern (รูปแบบที่ควรเลี่ยง):

  • ซ่อน dependency ที่แท้จริง — อ่าน constructor แล้วไม่รู้ว่า class ใช้อะไรบ้าง
  • test ยาก — ต้อง mock ทั้ง container แทนที่จะส่ง fake เข้าตรง ๆ
  • ผูก class กับ container — สลับ DI framework ทีหลังลำบาก

หลักคือ inject ผ่าน constructor เสมอ (ยกเว้นเคสเฉพาะ เช่น HostedService ที่ต้องสร้าง scope เอง):

csharp
// ❌ avoid — coupling กับ container, test ยาก
public class OrderService
{
    public void PlaceOrder(IServiceProvider sp, int userId)
    {
        var repo = sp.GetRequiredService<IUserRepo>();
        var user = repo.Get(userId);   // userId = 42 เช่น
    }
}

// ✅ inject ตรง ๆ
public class OrderService(IUserRepo repo)
{
    public void PlaceOrder(int userId) => repo.Get(userId);
}

✅ ยอมรับได้ใน specific case: HostedService, EF DbContext factory, framework integration


6. Generic Service

แทนที่จะ register IRepository<User>, IRepository<Post> ทีละตัว DI รองรับ open generic (generic ที่ยังไม่ระบุ type — เขียนเป็น IRepository<>)

ข้อดี:

  • register IRepository<> ครั้งเดียวใช้ได้กับทุก type
  • ลด boilerplate (โค้ดซ้ำซากที่ต้องเขียนเหมือนเดิมทุกที)
  • เหมาะมากกับงานแบบ repository/validator ที่หน้าตาคล้ายกันทุก entity
csharp
public interface IRepository<T> { Task<T?> Get(Guid id); }
public class EfRepository<T> : IRepository<T> { /*...*/ }

services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));

// inject
public class UserService(IRepository<User> repo) { }
public class PostService(IRepository<Post> repo) { }

ใช้กับ repository pattern, validator, etc.


7. Decorator Pattern

อยากเพิ่ม caching/logging ให้ service โดยไม่แก้โค้ดเดิม? ใช้ decorator pattern ได้

แตกประเด็นเป็นข้อ ๆ:

  • decorator pattern = รูปแบบที่ห่อ object เดิมด้วยตัวที่เพิ่มความสามารถใหม่ โดยไม่แก้ของเดิม
  • wrap (ห่อ) implementation เดิม — ตัวห่อทำงานก่อน/หลัง แล้วเรียกของเดิมข้างใน
  • Scrutor library มี Decorate() ที่ทำให้ wrap ซ้อนกันได้หลายชั้น (เช่น cache → log → ตัวจริง)
  • เป็น composition (การประกอบของหลายส่วนเข้าด้วยกัน) แทน inheritance (การสืบทอดจาก class แม่)
  • ตรงกับหลัก SOLID (หลักการออกแบบ OOP 5 ข้อ โดย Robert C. Martin ที่ช่วยให้โค้ดขยายต่อได้ง่าย) — โดยเฉพาะ Open/Closed Principle ที่บอกว่า "ขยายได้ แต่อย่าแก้ของเดิม"
bash
dotnet add package Scrutor
csharp
services.AddScoped<IUserRepo, UserRepo>();
services.Decorate<IUserRepo, CachingUserRepo>();   // wrap UserRepo
services.Decorate<IUserRepo, LoggingUserRepo>();   // wrap CachingUserRepo (เป็นชั้นนอกสุด)

💡 ลำดับการ wrap — ตัวที่เรียก Decorate ทีหลังจะอยู่ชั้นนอกสุด ดังนั้นที่ runtime จะเป็น LoggingUserRepoCachingUserRepoUserRepo ถ้าอยากให้ log ห่อนอกสุด ต้อง Decorate<LoggingUserRepo> เป็นคำสั่งสุดท้าย

csharp
public class CachingUserRepo(IUserRepo inner, IMemoryCache cache) : IUserRepo
{
    public Task<User?> Get(Guid id) =>
        cache.GetOrCreateAsync(id, entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
            return inner.Get(id);
        });
    // 💡 ต้องตั้ง expiration ใน factory delegate เอง (เช่น AbsoluteExpirationRelativeToNow ด้านบน)
    // ไม่งั้น entry จะไม่มีวันหมดอายุ — รวมถึงกรณี cache ค่า null (negative caching)
    // เพื่อกัน DB hammering จาก id ที่ไม่มีจริง ก็ตั้ง expiration แบบเดียวกันได้ปกติ
}

แต่ละ decorator wrap ตัวก่อนหน้า — composition แทน inheritance


8. Assembly Scan

โปรเจกต์ใหญ่มี service เยอะจน register ทีละตัวน่าเบื่อและลืมง่าย วิธีแก้คือ assembly scan

แตกประเด็น:

  • assembly = ไฟล์ .dll ที่รวมโค้ดที่ compile แล้ว
  • assembly scan = การไล่ดู class ทั้งหมดใน assembly แล้วจัดการอัตโนมัติ (Scrutor library ทำให้)
  • ใช้ convention (ข้อตกลงการตั้งชื่อ) เป็นเงื่อนไข เช่น "ทุก class ที่ลงท้ายด้วย Service"
  • จากนั้น register ทุกตัวที่ตรงเงื่อนไขให้อัตโนมัติ → ลด boilerplate และไม่ลืม register ตัวใหม่
bash
dotnet add package Scrutor
csharp
services.Scan(s => s
    .FromAssemblyOf<MyMarker>()
    .AddClasses(c => c.Where(t => t.Name.EndsWith("Service")))
        .AsImplementedInterfaces()
        .WithScopedLifetime());

ลด boilerplate "register ทุก service ที่ปลาย Service"


9. AsyncLocal — Ambient Context

scoped service ใช้ได้ดีใน request แต่ใน background task (ที่ไม่มี request scope) จะเข้าถึงข้อมูลแบบ "ปัจจุบัน" ยาก — ใช้ AsyncLocal ช่วยได้

แตกประเด็น:

  • AsyncLocal<T> เก็บค่าที่ไหลตาม async call chain (สายการเรียก async ที่ต่อเนื่องกัน เช่น method A await B, B await C — ข้อมูลจะ "ติด" ไปตลอดสายนี้)
  • ใช้เป็น ambient context ได้ — ข้อมูลแวดล้อมที่เข้าถึงได้โดยไม่ต้องส่งเป็น parameter (เช่น current user, tenant)
  • ค่าจะตามไปกับ task ที่ spawn จากที่นั้นด้วย
csharp
public class CurrentUser
{
    // ใช้ instance field (ไม่ใช่ static) — เพราะตัว CurrentUser ถูก register เป็น Singleton อยู่แล้ว
    // จึงมี instance เดียวทั้งแอป ส่วน AsyncLocal เก็บค่าแยกตาม async chain ให้เอง
    private readonly AsyncLocal<UserContext?> _store = new();
    public UserContext? Value
    {
        get => _store.Value;
        set => _store.Value = value;
    }
}

services.AddSingleton<CurrentUser>();

// middleware
app.Use(async (ctx, next) =>
{
    var user = ctx.RequestServices.GetRequiredService<CurrentUser>();
    user.Value = new(/* from claims */);
    await next();
    user.Value = null;
});

🚀 โซนขั้นสูงมาก — มือใหม่ข้ามส่วน pitfall ด้านล่างได้เลย กลับมาอ่านตอนเจอบั๊กเรื่อง AsyncLocal จริง ๆ

ไม่ใช่ replacement ตรง ๆ ของ Scoped DI — เข้าใจให้ถูก:

  • AsyncLocal flow ผ่าน ExecutionContext (ข้อมูลบริบทที่ runtime พกไปด้วยกันกับ async call แต่ละสาย รวมค่าของ AsyncLocal ทั้งหมด — เหมือน "กระเป๋าสัมภาระ" ที่ติดไปทุก await) ที่ capture ตอน await/Task.Run/timer schedule → ทำงานได้ดีในเส้น async chain เดียวกัน
  • Pitfall 1: ถ้ามี ExecutionContext.SuppressFlow() (บาง low-level API หรือ third-party scheduler) → ค่าหาย
  • Pitfall 2: ใน Task.Run(...) ที่ติด ConfigureAwait(false) (บอก runtime ว่าไม่ต้องกลับมา thread เดิมหลัง await) value ยัง flow ได้ตามปกติ เพราะ ExecutionContext เป็นคนละกลไกกับ SynchronizationContext (ตัวกำหนดว่าโค้ดหลัง await จะกลับมารันบน thread ไหน) — แต่ถ้าเป็นงานแบบ fire-and-forget (ยิงแล้วไม่รอผล ไม่ await) บน thread ที่ไม่ใช่ thread-pool worker (เช่น native thread ที่สร้างเอง) ค่าอาจ stale (ค้างเป็นค่าเก่าไม่อัปเดต)
  • Pitfall 3: mutate object reference ใน AsyncLocal store มองเห็นข้าม request ทันที ถ้าไม่ใส่ใน new object → ใช้ pattern "wrap ใน mutable holder" หรือ replace ทั้ง object เสมอ
csharp
// ✅ ปลอดภัย — replace object
user.Value = new UserContext(newName);

// ❌ อันตราย — มี request อื่นแชร์ reference ก็เห็น
user.Value.Name = newName;

ใช้แทน scoped service ใน background task (เพราะ async-aware) — แต่ถ้าทำได้ใช้ IServiceScopeFactory.CreateScope() + scoped service ของ DI แทน เพราะ DI cleanup ดีกว่า มี boundary ชัดกว่า


10. HttpClientFactory

การ new HttpClient() เองมีปัญหาหลายข้อ — แตกประเด็น:

  • socket exhaustion = socket (ช่องทางเชื่อมต่อเครือข่ายระดับ OS เปิดได้จำกัดต่อ process) ถูกใช้จนหมด
    • สาเหตุ: สร้าง HttpClient ใหม่บ่อย ๆ ทำให้แต่ละตัวจอง socket ไว้ค้าง ไม่คืนทันที
    • ผลลัพธ์: socket ที่จองไว้สะสมจนครบโควตา แอปต่อเน็ตไม่ได้อีกต่อไป
  • HttpClient ตัวเก่ายัง hold connection ไว้แม้ class ถูก GC แล้ว → leak
  • DNS ของ host ที่เก็บค้าง — ถ้า IP เปลี่ยน ตัวเก่าก็ยิงไป IP เก่าตลอด

HttpClientFactory แก้ปัญหาเหล่านี้ให้:

  • จัดการ lifetime/connection pool — re-use socket ปลอดภัย
  • ผูก resilience policy (นโยบายทนความล้มเหลว) ได้ง่าย เช่น retry (ลองใหม่), circuit breaker (ตัวตัดวงจรเมื่อปลายทางล่ม หยุดยิงชั่วคราว)
  • รองรับทั้งแบบ named (อ้างด้วยชื่อ string) และ typed client (ผูกกับ class เฉพาะ)
csharp
// named
services.AddHttpClient("github", c =>
{
    c.BaseAddress = new Uri("https://api.github.com");
    c.DefaultRequestHeaders.Add("User-Agent", "MyApp");
});

// typed
services.AddHttpClient<GitHubClient>(c =>
{
    c.BaseAddress = new Uri("https://api.github.com");
});

// ใช้
using System.Net.Http.Json;   // จำเป็นสำหรับ GetFromJsonAsync

public class GitHubClient(HttpClient http)
{
    // GetFromJsonAsync = เรียก HTTP GET แล้วแปลง JSON response เป็น object ให้อัตโนมัติ
    public async Task<User> GetUser(string name) =>
        await http.GetFromJsonAsync<User>($"/users/{name}")
            ?? throw new InvalidOperationException($"GitHub user '{name}' not found or returned null response");
}

ข้อดี:

  • pool connection (ไม่ leak socket)
  • centralized config
  • integrate กับ Polly (retry, circuit breaker)

Polly

bash
dotnet add package Microsoft.Extensions.Http.Resilience
csharp
services.AddHttpClient<GitHubClient>().AddStandardResilienceHandler();
// → ได้ retry + circuit breaker + timeout + rate limit มาให้ครบโดยอัตโนมัติ (Polly = library จัดการ resilience ยอดนิยม)

💡 AddStandardResilienceHandler() คือทางหลักของ .NET 8+ (package Microsoft.Extensions.Http.Resilience) มาแทนการต่อ Polly directly แบบยุคก่อน — ค่า default ผ่านการปรับมาให้ใช้กับ HTTP ทั่วไปได้เลย ไม่ต้อง tune เอง

หรือ custom:

csharp
services.AddHttpClient<GitHubClient>()
    .AddResilienceHandler("default", pipeline =>
    {
        pipeline.AddRetry(new()
        {
            ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
                .Handle<HttpRequestException>()
                .HandleResult(r => r.StatusCode == HttpStatusCode.TooManyRequests),
            MaxRetryAttempts = 3,
            BackoffType = DelayBackoffType.Exponential,
        });
        pipeline.AddCircuitBreaker(/*...*/);
        pipeline.AddTimeout(TimeSpan.FromSeconds(10));
    });

11. Validate Service Provider

ปัญหา DI ที่เจอบ่อยมี 2 แบบ:

  1. ลืม register service — class ที่ inject เข้ามาไม่อยู่ใน container
  2. inject Scoped เข้า Singleton — captive dependency bug

ถ้าไม่ตรวจตอน startup ปัญหาจะไปโผล่ตอน runtime (ตอนแอปกำลังรับ request) กลาง production

วิธีแก้: เปิด ValidateOnBuild/ValidateScopes ให้ container ตรวจตอน startup แล้ว fail fast (ระเบิดทันทีตอนเปิดแอป แทนที่จะรอเจอตอน user เรียก):

csharp
builder.Host.UseDefaultServiceProvider(o =>
{
    o.ValidateScopes = true;
    o.ValidateOnBuild = true;
});

ValidateOnBuild = true ที่ startup — Container ตรวจว่า resolve ได้ทุก service → fail fast


12. Disposable Service

csharp
public class FileService : IDisposable
{
    public void Dispose() { /*...*/ }
}

services.AddScoped<FileService>();
// scope ปิด → container เรียก Dispose ให้

Container track instance ที่ IDisposable แล้ว dispose ตอน scope end

ระวัง: Transient + IDisposable — leak เฉพาะตอน resolve จาก root provider

  • Transient IDisposable ที่ resolve จาก request scope → ถูก dispose ตอน scope ปิด (ปลอดภัย)
  • Transient IDisposable ที่ resolve จาก root provider (เช่นใน Singleton ที่ inject IServiceProvider แล้ว GetRequiredService ตรง ๆ) → ค้างใน root scope ตลอดอายุแอป → memory leak (หน่วยความจำรั่ว — จองแล้วไม่ถูกคืน สะสมจนเต็ม)

ทางแก้: ใช้ Scoped, สร้าง scope สั้น ๆ ด้วย IServiceScopeFactory, หรือจัดการเองด้วย using


13. Test ใน DI

ข้อดีใหญ่ของ constructor injection (การ inject ผ่าน constructor) คือ test ง่าย

ขั้นตอนใน test:

  1. สร้าง ServiceCollection ของตัวเอง
  2. register fake (ของปลอม) หรือ in-memory implementation (ตัวเก็บข้อมูลใน RAM) แทนของจริง
  3. resolve (ขอ instance จาก container) service ที่จะทดสอบ
  4. แยกทดสอบ logic โดยไม่ต้องพึ่ง DB/external service จริง
csharp
public class UsersService(IPasswordHasher<User> hasher, IUserRepo repo) { /*...*/ }

// test
var services = new ServiceCollection();
services.AddSingleton<IPasswordHasher<User>, FakeHasher>();
services.AddSingleton<IUserRepo, InMemoryRepo>();
services.AddTransient<UsersService>();

using var sp = services.BuildServiceProvider();
var svc = sp.GetRequiredService<UsersService>();

14. Third-party Container (Autofac, Lamar)

ถ้าต้องการ feature ที่ default ไม่มี (named, AOP interception):

bash
dotnet add package Autofac.Extensions.DependencyInjection
csharp
builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());
builder.Host.ConfigureContainer<ContainerBuilder>(b =>
{
    b.RegisterType<UserRepo>().As<IUserRepo>();
});

💡 default container ครอบคลุม 95% ของ use case — เปลี่ยน container เฉพาะเมื่อจำเป็น


15. Common Pitfalls

  1. Captive dependency — Singleton inject Scoped → bug ใหญ่ → inject IServiceScopeFactory แล้ว CreateAsyncScope() แทน
  2. Transient + IDisposable resolve จาก root — leak ถ้าไม่สร้าง scope
  3. Use IServiceProvider locator ทุกที่ → coupling
  4. Multiple SaveChanges กับ EF singleton — ห้าม singleton DbContext
  5. Forget ValidateOnStart — config invalid พบตอน first request
  6. Async ใน constructor — ห้าม! ใช้ IHostedService หรือ async factory

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

  1. สร้าง JwtOptions + Bind + Validate + ValidateOnStart
  2. inject IOptionsSnapshot<T> ใน scoped service — reload config โดยไม่ restart
  3. ทำ decorator: LoggingRepoCachingRepoEfRepo
  4. assembly scan: register ทุก *Service เป็น scoped อัตโนมัติ
  5. HttpClient typed + Polly retry + circuit breaker
  6. ลอง captive dependency bug — singleton inject scoped → ดู error ที่ startup (ValidateScopes)

สรุปบทที่ 7

  • 3 lifetime: Singleton / Scoped / Transient
  • Captive dependency = singleton จับ scoped ไว้ — bug ที่ container catch ให้
  • Options pattern + ValidateOnStart = fail fast
  • IOptions / IOptionsSnapshot / IOptionsMonitor ต่างกันตาม lifetime + reload
  • Multiple impl + Decorator pattern + Assembly scan = composition ที่ flexible
  • HttpClientFactory + Polly = HTTP client ที่ production-ready
  • default container ครอบคลุมเคสปกติของ web app ส่วนใหญ่ — เปลี่ยน Autofac เมื่อจำเป็น (AOP, conditional registration, multi-tenant per request)

→ บทที่ 8: Background Service


🔤 Glossary · 📋 Style guide · 📅 last_verified: 2026-06-12