【C#】依存性注入(DI)完全ガイド|ライフタイム・Keyed Services・Decorator・HostedService・落とし穴まで

【C#】依存性注入(DI)の基本と実装例 C#

依存性注入(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)

DI 適用前と適用後の比較
// 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 は「依存は自分で作らず外から受け取る」という設計原則で、コンテナを使わずとも手動で依存を渡すだけでも DI は成立します。MS.DI や Autofac などの「DI コンテナ」は、その依存関係を解決して組み立てる作業を自動化するツールにすぎません。原則(依存性逆転・コンストラクタでの受け取り)を守ることがまず重要で、コンテナはその結果として不可欠になります。

3つの注入パターン — Constructor / Property / Method

パターン 書き方 使い所 注意
コンストラクタ注入 コンストラクタ引数で受ける デフォルト。必須依存の標準 引数が多すぎる=責務過多のサイン
プロパティ注入 set 可能な public プロパティ オプション依存(Logger 等のデフォルトあり) MS.DI は標準でサポートしない(Autofac 等では可能)
メソッド注入 関数の引数で受け取る 1回限りの利用・ASP.NET Core の [FromServices] 呼び出し側が依存を知っている必要がある
3つのパターンの実装例
// ① コンストラクタ注入(推奨)
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
Captured Dependencies の回避パターン
// 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();
ValidateScopes を本番以外で必ず有効化
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);
Keyed Services が来るまでの代替策
.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 パターン — 既存サービスを包む

MS.DI で 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 で Decorator 登録をシンプルに
Scrutor ライブラリを使うと services.Decorate<IUserRepository, CachingUserRepository>() の1行で Decorator 登録ができます。キャッシング・ロギング・リトライ・サーキットブレイカー・トランザクション管理など、クロスカッティングな関心事を元コードに手を入れずに追加できるため、実務での DI 活用の幅が一気に広がります。

IOptions の3つのバリエーション

インターフェース 再読み込み ライフタイム 用途
IOptions<T> されない(起動時1回) Singleton 変わらない設定。最もシンプル
IOptionsSnapshot<T> リクエストごとに再読み込み Scoped HTTP リクエスト単位で最新の設定を得たい
IOptionsMonitor<T> リアルタイムで変更検知 Singleton バックグラウンドサービス・HostedService で設定変更を即座に反映
3つの IOptions の実例
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

定期実行タスクを DI コンテナから取得
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

Typed HttpClient で外部 API を DI 注入
// 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);
}

よくある落とし穴

落とし穴① — Service Locator アンチパターン
// 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();
}
落とし穴③ — GetService vs GetRequiredService
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
落とし穴④ — IDisposable とライフタイム
// 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 される

よくある質問

Qコンストラクタ引数が5個を超えたらどうすればよいですか?
A責務が多すぎるサインです。機械的にプロパティ注入やサービスロケーターで回避するのではなく、「このクラスは何をするのか」を見直して責務を分割してください。たとえば「注文処理」「通知」「監査ログ」を1つのクラスでやっているなら3つに分け、各々が必要な依存だけを受け取るようにします。どうしても集約クラスが必要な場面では、関連する依存をParameter ObjectOrderServiceDependencies のような record)にまとめる手もあります。
QAddTransient と AddScoped はどう使い分けますか?
A状態を持たない軽量な依存なら AddTransient、「HTTP リクエスト内で同じインスタンスを共有したい」なら AddScoped です。DbContext は典型的な Scoped で、1リクエスト内の複数のリポジトリで同じ EF Change Tracker を共有する必要があります。逆にバリデーター・メッセージビルダーのようにインスタンスを使い回す必要がなく、毎回作っても低コストなら Transient が適切です。
QMS.DI とサードパーティ DI コンテナ(Autofac 等)の使い分けは?
A.NET 8 で Keyed Services が入り、ほとんどのユースケースで MS.DI だけで足りるようになりました。プロパティ注入モジュール単位の登録インターセプター(AOP)が必須なプロジェクトでは AutofacDryIoc の採用を検討してください。ただしこれらは MS.DI の上に乗せる形で導入できるため、基本は MS.DI で進め、必要に応じて追加するのが安全です。
QIOptions と appsettings.json の連携で設定変更を即反映したい場合は?
AIOptionsMonitor<T> を使えば appsettings.json を編集して保存するとファイルウォッチャーが検知し、次回の CurrentValue 取得から新しい値が反映されます。バックグラウンドサービスで動的な設定変更を扱うなら monitor.OnChange(...) で変更通知を購読するパターンが定番です。Web API のリクエスト単位でよければ IOptionsSnapshot<T> がシンプルで推奨です。
QPrimary Constructor(C# 12+)と DI は相性が良いですか?
A極めて相性が良いです。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 = trueValidateScopes = 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 を解説しています。