โหมดมืด
บทที่ 7 — Dependency Injection ลึก (lifetime, options, validation, scope)
🟡 หมายเหตุระดับ: ชื่อบทบอก "ลึก" — เนื้อหาครอบ 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>(); // สร้างใหม่ทุกครั้งที่ injectLifetime + 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 ใช้เวลา) — คู่กับมันคือ keywordawait 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 ปิด — มักเป็นตัวนี้
| Interface | Lifetime | ใช้กับ |
|---|---|---|
IOptions<T> | Singleton | config ที่ไม่เปลี่ยน |
IOptionsSnapshot<T> | Scoped | config ที่ reload ต่อ request |
IOptionsMonitor<T> | Singleton + notify | listen 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 Scrutorcsharp
services.AddScoped<IUserRepo, UserRepo>();
services.Decorate<IUserRepo, CachingUserRepo>(); // wrap UserRepo
services.Decorate<IUserRepo, LoggingUserRepo>(); // wrap CachingUserRepo (เป็นชั้นนอกสุด)💡 ลำดับการ wrap — ตัวที่เรียก
Decorateทีหลังจะอยู่ชั้นนอกสุด ดังนั้นที่ runtime จะเป็นLoggingUserRepo→CachingUserRepo→UserRepoถ้าอยากให้ 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 Scrutorcsharp
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.Resiliencecsharp
services.AddHttpClient<GitHubClient>().AddStandardResilienceHandler();
// → ได้ retry + circuit breaker + timeout + rate limit มาให้ครบโดยอัตโนมัติ (Polly = library จัดการ resilience ยอดนิยม)💡
AddStandardResilienceHandler()คือทางหลักของ .NET 8+ (packageMicrosoft.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 แบบ:
- ลืม register service — class ที่ inject เข้ามาไม่อยู่ใน container
- 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 ที่ injectIServiceProviderแล้วGetRequiredServiceตรง ๆ) → ค้างใน root scope ตลอดอายุแอป → memory leak (หน่วยความจำรั่ว — จองแล้วไม่ถูกคืน สะสมจนเต็ม)
ทางแก้: ใช้ Scoped, สร้าง scope สั้น ๆ ด้วย IServiceScopeFactory, หรือจัดการเองด้วย using
13. Test ใน DI
ข้อดีใหญ่ของ constructor injection (การ inject ผ่าน constructor) คือ test ง่าย
ขั้นตอนใน test:
- สร้าง
ServiceCollectionของตัวเอง - register fake (ของปลอม) หรือ in-memory implementation (ตัวเก็บข้อมูลใน RAM) แทนของจริง
- resolve (ขอ instance จาก container) service ที่จะทดสอบ
- แยกทดสอบ 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.DependencyInjectioncsharp
builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());
builder.Host.ConfigureContainer<ContainerBuilder>(b =>
{
b.RegisterType<UserRepo>().As<IUserRepo>();
});💡 default container ครอบคลุม 95% ของ use case — เปลี่ยน container เฉพาะเมื่อจำเป็น
15. Common Pitfalls
- Captive dependency — Singleton inject Scoped → bug ใหญ่ → inject
IServiceScopeFactoryแล้วCreateAsyncScope()แทน - Transient + IDisposable resolve จาก root — leak ถ้าไม่สร้าง scope
- Use IServiceProvider locator ทุกที่ → coupling
- Multiple SaveChanges กับ EF singleton — ห้าม singleton DbContext
- Forget
ValidateOnStart— config invalid พบตอน first request - Async ใน constructor — ห้าม! ใช้
IHostedServiceหรือ async factory
🛠️ Checkpoint 7 — ลงมือทำ
- สร้าง
JwtOptions+ Bind + Validate + ValidateOnStart - inject
IOptionsSnapshot<T>ใน scoped service — reload config โดยไม่ restart - ทำ decorator:
LoggingRepo→CachingRepo→EfRepo - assembly scan: register ทุก
*Serviceเป็น scoped อัตโนมัติ - HttpClient typed + Polly retry + circuit breaker
- ลอง 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)
🔤 Glossary · 📋 Style guide · 📅 last_verified: 2026-06-12