依存性注入(Dependency Injection, DI)は、クラスが使う依存オブジェクトを自分で生成せずに外から受け取る設計手法です。.NET Core 以降の Microsoft.Extensions.DependencyInjection(MS.DI)が標準の DI コンテナとして組み込まれており、ASP.NET Core・Worker Service・Console アプリすべてで同じ API が使えます。
本記事では3つの注入パターン・3つのライフタイムと相互作用・Keyed Services(.NET 8+)・IEnumerable<T> による複数実装・Decorator パターン・ファクトリー登録・IOptions/IOptionsSnapshot/IOptionsMonitor の違い・IHostedService・Primary Constructor(C# 12+)連携・ValidateOnBuild による起動時検証・よくある落とし穴まで体系的に解説します。
- なぜ DI を使うのか — 依存性逆転の原則(DIP)
- 3つの注入パターン — Constructor / Property / Method
- 3つのライフタイム — Singleton / Scoped / Transient
- Captured Dependencies 問題 — ライフタイムの不整合
- Keyed Services — 同じ型で複数実装(.NET 8+)
- IEnumerable<T> — 同じ型を全部取得
- ファクトリー登録 — 動的にインスタンスを構築
- Decorator パターン — 既存サービスを包む
- IOptions の3つのバリエーション
- バックグラウンド実行 — IHostedService / BackgroundService
- HttpClient の統合 — IHttpClientFactory
- よくある落とし穴
- よくある質問
- まとめ
なぜ DI を使うのか — 依存性逆転の原則(DIP)
// Before: NotificationService が SmtpMessageSender を直接 new している
public class NotificationService_Bad
{
private readonly SmtpMessageSender _sender = new(); // 具象依存
public Task NotifyAsync(string userId, string message)
=> _sender.SendAsync($"{userId}@example.com", message);
}
// 問題:
// 1. SmtpMessageSender を差し替えられない(SMS や別プロバイダに対応できない)
// 2. テストで本物のSMTPが呼ばれてしまう
// 3. SmtpMessageSender の設定・ライフタイムをこの型が知っている必要がある
// After: インターフェースを受け取る(依存性逆転)
public interface IMessageSender
{
Task SendAsync(string to, string body);
}
public sealed class NotificationService
{
private readonly IMessageSender _sender;
public NotificationService(IMessageSender sender) => _sender = sender;
public Task NotifyAsync(string userId, string message)
=> _sender.SendAsync($"{userId}@example.com", message);
}
// 効果:
// 1. 実装を差し替え可能(SMTP/SendGrid/ダミー)
// 2. テストでフェイク実装を注入できる
// 3. NotificationService は「誰を使うか」を知らなくてよい
DI は「依存は自分で作らず外から受け取る」という設計原則で、コンテナを使わずとも手動で依存を渡すだけでも DI は成立します。MS.DI や Autofac などの「DI コンテナ」は、その依存関係を解決して組み立てる作業を自動化するツールにすぎません。原則(依存性逆転・コンストラクタでの受け取り)を守ることがまず重要で、コンテナはその結果として不可欠になります。
3つの注入パターン — Constructor / Property / Method
| パターン | 書き方 | 使い所 | 注意 |
|---|---|---|---|
| コンストラクタ注入 | コンストラクタ引数で受ける | デフォルト。必須依存の標準 | 引数が多すぎる=責務過多のサイン |
| プロパティ注入 | set 可能な public プロパティ | オプション依存(Logger 等のデフォルトあり) | MS.DI は標準でサポートしない(Autofac 等では可能) |
| メソッド注入 | 関数の引数で受け取る | 1回限りの利用・ASP.NET Core の [FromServices] |
呼び出し側が依存を知っている必要がある |
// ① コンストラクタ注入(推奨)
public sealed class OrderService
{
private readonly IOrderRepository _repo;
private readonly IPaymentGateway _payment;
public OrderService(IOrderRepository repo, IPaymentGateway payment)
=> (_repo, _payment) = (repo, payment);
}
// C# 12+ の Primary Constructor ならフィールド宣言を省ける
public sealed class OrderService2(IOrderRepository repo, IPaymentGateway payment)
{
public Task ProcessAsync() => repo.SaveAsync();
}
// ② プロパティ注入(MS.DI 標準外 / オプション依存)
public sealed class AuditableService
{
public ILogger Logger { get; set; } = NullLogger.Instance; // デフォルト値
}
// ③ メソッド注入(ASP.NET Core の [FromServices])
app.MapGet("/users/{id}", (int id, [FromServices] IUserService svc) =>
svc.GetAsync(id));
特別な理由がない限り常にコンストラクタ注入を選んでください。
readonly フィールドに格納でき、依存忘れをコンパイル時に検出でき、必要な依存がシグネチャに明示されるためです。プロパティ注入は循環依存の回避やサードパーティライブラリの統合など特殊用途に限り、メソッド注入は ASP.NET Core のエンドポイント個別注入など「そのメソッドでしか使わない」時に使います。3つのライフタイム — Singleton / Scoped / Transient
| ライフタイム | 登録 API | インスタンス数 | 典型用途 |
|---|---|---|---|
| Singleton | AddSingleton |
アプリ全体で1個 | 設定・キャッシュ・HttpClient ファクトリ・ステートレスな重量オブジェクト |
| Scoped | AddScoped |
スコープ(HTTP リクエスト)ごとに1個 | DbContext・ユーザー状態・リクエスト単位の共有 |
| Transient | AddTransient |
解決するたびに新規 | 軽量ステートレス・短時間利用 |
using Microsoft.Extensions.DependencyInjection; var services = new ServiceCollection(); // Singleton: 1 インスタンスで複数スレッドから共有される // → 必ずスレッドセーフに実装する services.AddSingleton<IConfiguration, FrozenConfiguration>(); services.AddSingleton<IHttpClientFactory, DefaultHttpClientFactory>(); services.AddSingleton<IMemoryCache, MemoryCache>(); // Scoped: HTTP リクエスト(または using var scope = ...Scope())の中で同じインスタンス // → DbContext は典型的な Scoped(リクエスト内でユニットオブワーク) services.AddScoped<ApplicationDbContext>(); services.AddScoped<IUserService, UserService>(); // Transient: 解決のたびに新しいインスタンス // → 軽量で状態を持たないバリデーターやメッセージビルダーに services.AddTransient<IMessageFormatter, JsonMessageFormatter>(); services.AddTransient<IOrderValidator, OrderValidator>(); var provider = services.BuildServiceProvider();
Captured Dependencies 問題 — ライフタイムの不整合
「長いライフタイムが短いライフタイムを参照する」と、短い方のインスタンスが意図せず長生きして破損するCaptured Dependencies問題が発生します。
| 親ライフタイム | 子ライフタイム | 結果 |
|---|---|---|
| Singleton | Singleton | OK |
| Singleton | Scoped | NG — 危険:Scoped が Singleton と同じ寿命を持ってしまう |
| Singleton | Transient | 要注意:Transient が Singleton と同じ寿命になる |
| Scoped | Scoped / Transient / Singleton | OK |
| Transient | すべて | OK |
// NG: Singleton が Scoped な DbContext をコンストラクタで受ける
public class SingletonCache
{
private readonly AppDbContext _db; // Scoped!
public SingletonCache(AppDbContext db) => _db = db; // ⚠ 危険
}
// → アプリ起動時に1回だけ解決される DbContext を永遠に使い続ける
// 接続プールが枯渇したり、リクエスト間で状態が混ざる
// OK: Singleton から Scoped が必要なら IServiceScopeFactory でスコープを作る
public class SingletonJob
{
private readonly IServiceScopeFactory _scopeFactory;
public SingletonJob(IServiceScopeFactory scopeFactory)
=> _scopeFactory = scopeFactory;
public async Task RunAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.SaveChangesAsync();
} // ← using の終わりで Scoped が解放される
}
// 起動時検証で Captured Dependencies を自動検出
var host = Host.CreateDefaultBuilder()
.UseDefaultServiceProvider(opt =>
{
opt.ValidateOnBuild = true; // 起動時に全サービスの解決を検証
opt.ValidateScopes = true; // Scoped をルートから解決すると例外
})
.ConfigureServices(s => { /* ... */ })
.Build();
ASP.NET Core は開発環境のみ
ValidateScopes = trueがデフォルトで、本番では無効になっています。開発・CI でのみ Captured Dependencies の検出が効く状態です。本番で同じ検証を動かす必要はありませんが、ValidateOnBuild = true は本番でも有効にするのが推奨です。アプリ起動時にすべての登録サービスが解決可能か検証するため、「動かしてみたら DI 解決エラー」を本番運用前に検出できます。Keyed Services — 同じ型で複数実装(.NET 8+)
// .NET 8 以降: 同じインターフェースを「キー」で区別して複数登録できる
var services = new ServiceCollection();
services.AddKeyedSingleton<IMessageSender, SmtpSender>("smtp");
services.AddKeyedSingleton<IMessageSender, SlackSender>("slack");
services.AddKeyedSingleton<IMessageSender, TwilioSender>("sms");
// コンストラクタで [FromKeyedServices] で取得
public sealed class OrderNotifier(
[FromKeyedServices("smtp")] IMessageSender email,
[FromKeyedServices("slack")] IMessageSender slack)
{
public async Task NotifyAsync(Order o)
{
await email.SendAsync(o.CustomerEmail, "ご注文ありがとうございます");
await slack.SendAsync("#sales", $"新規注文: {o.Id}");
}
}
// 解決側: GetRequiredKeyedService<T>
var provider = services.BuildServiceProvider();
var sms = provider.GetRequiredKeyedService<IMessageSender>("sms");
// キーに enum や任意のオブジェクトも使える
public enum Channel { Email, Slack, Sms }
services.AddKeyedSingleton<IMessageSender, SmtpSender>(Channel.Email);
.NET 7 以前でも同様のことは 名前付きファクトリ(自前で辞書を持つ)や Autofac の名前付き登録で実現できましたが、.NET 8 で標準 API が入ったことで、ストラテジー/プラグインパターンの実装が大幅にシンプルになりました。ビジネスルールの分岐で「A プロバイダの支払い」「B プロバイダの支払い」を切り替えるような場面ではKeyed Services が第一選択です。
IEnumerable<T> — 同じ型を全部取得
// 同じ型に複数登録すると、IEnumerable<T> で全部取得できる
services.AddSingleton<IValidator, RequiredValidator>();
services.AddSingleton<IValidator, RangeValidator>();
services.AddSingleton<IValidator, EmailValidator>();
public sealed class CompositeValidator(IEnumerable<IValidator> validators)
{
public ValidationResult Validate(object o) =>
validators.Select(v => v.Validate(o)).Aggregate(ValidationResult.Merge);
}
// TryAdd* 系: 既に登録済みなら追加しない(冪等登録)
services.TryAddSingleton<ILogger, ConsoleLogger>(); // 1回目: 追加される
services.TryAddSingleton<ILogger, FileLogger>(); // 2回目: 無視される
// → ライブラリで「デフォルト実装を用意するが、ユーザーが上書き済みなら尊重」に便利
// TryAddEnumerable: 同じ型でも実装クラスが重複する登録のみ避ける
services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidator, EmailValidator>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidator, EmailValidator>());
// → 2回目は「同じ実装クラス」なのでスキップ
ファクトリー登録 — 動的にインスタンスを構築
// ① 既存インスタンスをそのまま登録
var cfg = new FrozenConfig("production");
services.AddSingleton<IConfiguration>(cfg); // インスタンス登録はライフタイムが無意味化する
// ② ファクトリーデリゲート(解決時の sp で他サービスを引き出せる)
services.AddScoped<IDatabaseClient>(sp =>
{
var opt = sp.GetRequiredService<IOptions<DbOptions>>().Value;
var log = sp.GetRequiredService<ILogger<DatabaseClient>>();
return new DatabaseClient(opt.ConnectionString, log);
});
// ③ Open Generics(ジェネリック型の型引数ごとに登録される)
services.AddSingleton(typeof(IRepository<>), typeof(EfRepository<>));
// → IRepository<User>, IRepository<Order> などで EfRepository<User>/EfRepository<Order> が解決される
// ④ 条件付きファクトリー(環境によって実装を切り替え)
services.AddSingleton<IMessageSender>(sp =>
{
var env = sp.GetRequiredService<IHostEnvironment>();
return env.IsDevelopment()
? new ConsoleSender() // 開発ではコンソール出力
: new SmtpSender(sp.GetRequiredService<IOptions<SmtpOptions>>());
});
Decorator パターン — 既存サービスを包む
// 同じインターフェースを「元の実装」と「装飾した実装」で2段登録する
// Scrutor ライブラリを使うと AddScoped<T, TDecorator>().Decorate<T>() が使えるが
// MS.DI 標準でも手動で実装できる
public interface IUserRepository { Task<User?> GetAsync(int id); }
public sealed class DbUserRepository : IUserRepository { /* 省略 */ }
// キャッシング Decorator
public sealed class CachingUserRepository : IUserRepository
{
private readonly IUserRepository _inner;
private readonly IMemoryCache _cache;
public CachingUserRepository(IUserRepository inner, IMemoryCache cache)
=> (_inner, _cache) = (inner, cache);
public async Task<User?> GetAsync(int id)
{
if (_cache.TryGetValue(id, out User? u)) return u;
u = await _inner.GetAsync(id);
if (u != null) _cache.Set(id, u, TimeSpan.FromMinutes(5));
return u;
}
}
// 登録: Decorator が外側、内部実装を DI で解決してもらう
services.AddSingleton<DbUserRepository>();
services.AddSingleton<IUserRepository>(sp =>
new CachingUserRepository(
sp.GetRequiredService<DbUserRepository>(),
sp.GetRequiredService<IMemoryCache>()));
// 使用側から見ると IUserRepository だが、実体は CachingUserRepository → DbUserRepository
Scrutor ライブラリを使うと services.Decorate<IUserRepository, CachingUserRepository>() の1行で Decorator 登録ができます。キャッシング・ロギング・リトライ・サーキットブレイカー・トランザクション管理など、クロスカッティングな関心事を元コードに手を入れずに追加できるため、実務での DI 活用の幅が一気に広がります。IOptions の3つのバリエーション
| インターフェース | 再読み込み | ライフタイム | 用途 |
|---|---|---|---|
IOptions<T> |
されない(起動時1回) | Singleton | 変わらない設定。最もシンプル |
IOptionsSnapshot<T> |
リクエストごとに再読み込み | Scoped | HTTP リクエスト単位で最新の設定を得たい |
IOptionsMonitor<T> |
リアルタイムで変更検知 | Singleton | バックグラウンドサービス・HostedService で設定変更を即座に反映 |
public sealed class SmtpOptions
{
public required string Host { get; init; }
public int Port { get; init; } = 587;
}
// 登録
builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp"));
// ① IOptions<T>: 起動時の設定値(変わらない)
public sealed class MailService(IOptions<SmtpOptions> opt)
{
private readonly SmtpOptions _o = opt.Value;
}
// ② IOptionsSnapshot<T>: リクエストごとに最新を取得(Scoped)
public sealed class ApiController(IOptionsSnapshot<SmtpOptions> opt) : ControllerBase
{
public IActionResult Get() => Ok(opt.Value.Host);
// → appsettings.json を編集して保存すると次リクエストから反映される
}
// ③ IOptionsMonitor<T>: Singleton でも変更を購読できる
public sealed class ConnectionPool(IOptionsMonitor<SmtpOptions> monitor)
{
public ConnectionPool(IOptionsMonitor<SmtpOptions> monitor)
{
// 現在値
var current = monitor.CurrentValue;
// 変更通知を購読(HostedService に向いている)
monitor.OnChange(newOpts =>
{
Console.WriteLine($"設定が変更されました: {newOpts.Host}");
});
}
}
バックグラウンド実行 — IHostedService / BackgroundService
using Microsoft.Extensions.Hosting;
public sealed class CleanupWorker(
IServiceScopeFactory scopeFactory,
ILogger<CleanupWorker> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Scoped 依存は必ず自前でスコープを作って取得する
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Sessions
.Where(s => s.ExpiresAt < DateTime.UtcNow)
.ExecuteDeleteAsync(stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Cleanup failed");
}
await Task.Delay(TimeSpan.FromMinutes(10), stoppingToken);
}
}
}
// 登録: AddHostedService で Singleton として登録される
builder.Services.AddHostedService<CleanupWorker>();
HttpClient の統合 — IHttpClientFactory
// NG: HttpClient を普通に new するとソケット枯渇・DNS 変更の反映遅れ
public sealed class BadApiClient
{
public async Task GetAsync() =>
await new HttpClient().GetAsync("https://api.example.com"); // アンチパターン
}
// OK: Typed HttpClient で登録すると IHttpClientFactory が裏で管理してくれる
public sealed class PaymentApiClient
{
private readonly HttpClient _http;
public PaymentApiClient(HttpClient http)
{
_http = http;
_http.BaseAddress = new Uri("https://api.payment.example.com");
}
public Task<HttpResponseMessage> ChargeAsync(decimal amount) =>
_http.PostAsJsonAsync("/charge", new { Amount = amount });
}
// 登録
builder.Services.AddHttpClient<PaymentApiClient>();
// Polly を組み合わせてリトライ・サーキットブレイカーも追加可能
builder.Services.AddHttpClient<PaymentApiClient>()
.AddStandardResilienceHandler(); // .NET 8+ の標準レジリエンス
// 使用側
public class OrderService(PaymentApiClient payment)
{
public Task ChargeAsync(Order o) => payment.ChargeAsync(o.Total);
}
よくある落とし穴
// NG: IServiceProvider を直接注入して Get で解決する
public class OrderService
{
private readonly IServiceProvider _sp;
public OrderService(IServiceProvider sp) => _sp = sp;
public Task ProcessAsync()
{
var repo = _sp.GetRequiredService<IOrderRepository>(); // 必要になった時に解決
return repo.SaveAsync();
}
}
// 問題:
// - 依存関係がシグネチャに現れない(何を使うか外から分からない)
// - テストで何をモックすべきか不明
// - ValidateOnBuild で検出できない
// OK: 必要な依存はコンストラクタで明示する
public class OrderServiceGood(IOrderRepository repo)
{
public Task ProcessAsync() => repo.SaveAsync();
}
// 循環依存: A が B を、B が A を要求する
public class A { public A(B b) { } }
public class B { public B(A a) { } }
services.AddSingleton<A>();
services.AddSingleton<B>();
provider.GetRequiredService<A>(); // InvalidOperationException: A circular dependency was detected
// 対処① — 責務を分割して共通部分を切り出す
// A, B が共通の処理 X に依存するなら、X を別クラスにする
// 対処② — イベントで結合を緩める
// A が B を直接参照するのではなく、「何かが起きた」イベントを発行し、
// B はそれを購読する(Mediator / MediatR パターン)
// 対処③ — どうしても必要なら Lazy<T> や遅延注入
public class A(Lazy<B> b) // B は最初のアクセス時に初めて解決される
{
public void DoSomething() => b.Value.Do();
}
using var scope = provider.CreateScope(); var sp = scope.ServiceProvider; // GetService: 未登録なら null を返す(C# 8 nullable 警告で検出できる) IOrderRepository? maybeRepo = sp.GetService<IOrderRepository>(); if (maybeRepo is null) // ← これを忘れると NullReferenceException // GetRequiredService: 未登録なら InvalidOperationException を投げる(推奨) IOrderRepository repo = sp.GetRequiredService<IOrderRepository>(); // 登録されている前提のコードでは GetRequiredService を使うほうが安全 // 登録されているか分からない状況(プラグインシステム等)では GetService
// MS.DI は「コンテナが生成したインスタンス」だけを自動 Dispose する
public class DbConn : IDisposable { /* ... */ }
// ① 登録: コンテナ管理 → スコープ終了時に自動 Dispose
services.AddScoped<DbConn>();
// ② インスタンス登録: コンテナは Dispose を呼ばない(呼び出し側の責任)
var conn = new DbConn();
services.AddSingleton(conn);
// → provider.Dispose() しても conn.Dispose() は呼ばれない
// → ownsInstance パラメータ指定なしの AddSingleton(instance) は要注意
// ③ ファクトリー登録: 戻り値はコンテナ管理対象として Dispose される
services.AddScoped<DbConn>(sp => new DbConn("conn-string")); // これは自動 Dispose される
よくある質問
OrderServiceDependencies のような record)にまとめる手もあります。AddTransient、「HTTP リクエスト内で同じインスタンスを共有したい」なら AddScoped です。DbContext は典型的な Scoped で、1リクエスト内の複数のリポジトリで同じ EF Change Tracker を共有する必要があります。逆にバリデーター・メッセージビルダーのようにインスタンスを使い回す必要がなく、毎回作っても低コストなら Transient が適切です。Autofac や DryIoc の採用を検討してください。ただしこれらは MS.DI の上に乗せる形で導入できるため、基本は MS.DI で進め、必要に応じて追加するのが安全です。IOptionsMonitor<T> を使えば appsettings.json を編集して保存するとファイルウォッチャーが検知し、次回の CurrentValue 取得から新しい値が反映されます。バックグラウンドサービスで動的な設定変更を扱うなら monitor.OnChange(...) で変更通知を購読するパターンが定番です。Web API のリクエスト単位でよければ IOptionsSnapshot<T> がシンプルで推奨です。public class OrderService(IRepository repo, IPaymentGateway pay) のように書くだけで、DI 経由で注入した依存をそのままクラス本体から参照できます。フィールド宣言・代入の定型コードが消え、Primary Constructor の引数がそのまま readonly 相当の private フィールドとして振る舞います。構造の大きなサービスクラスでは記述量が半分以下になるため、新規コードでは積極的に採用してください。まとめ
| ポイント | 推奨 |
|---|---|
| 注入パターン | 常にコンストラクタ注入(Primary Constructor C# 12+) |
| Singleton | スレッドセーフに実装。設定・キャッシュ・HttpClient ファクトリに |
| Scoped | DbContext・リクエスト状態。Singleton から参照するときは IServiceScopeFactory |
| Transient | 軽量ステートレス。作成コストが低いもののみ |
| Captured Dependencies | ValidateOnBuild = true と ValidateScopes = true で検出 |
| Keyed Services | .NET 8+ で複数実装の使い分けが標準化。[FromKeyedServices("key")] |
| IEnumerable<T> | 同じインターフェースを複数登録 → コンポジット・バリデーター |
| Decorator | キャッシング・ロギング・リトライを後付け。Scrutor で簡潔化 |
| IOptions | 静的設定は IOptions、リクエスト単位は IOptionsSnapshot、動的反映は IOptionsMonitor |
| HostedService | BackgroundService + IServiceScopeFactory が基本パターン |
| HttpClient | AddHttpClient<T>() の Typed HttpClient + 標準レジリエンス |
| 落とし穴 | Service Locator・循環依存・IDisposable 管理の3点に注意 |
関連する設計機能は以下を参照してください。appsettings.json と IOptions パターンで設定注入の詳細、インターフェースと抽象クラス完全ガイドで依存性逆転の前提、IDisposable・using完全ガイドでライフタイム管理、コンストラクタ完全ガイドで Primary Constructor を解説しています。

