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

依存性注入(Dependency Injection, DI)は、クラスが必要とするオブジェクトを自分で生成せず外部から受け取る設計手法です。具体的には「new で直接作らない」「どの具象クラスを使うかを利用側が決めない」という二点を徹底することで、テスト容易性と拡張性、再利用性を高められます。ここでは C#/.NET の組み込みコンテナを用いた実装手順を、最小構成から段階的に解説します。

インターフェースで依存を表現する

まずは機能をインターフェースで表し、実装は別クラスに分離します。利用側(クライアント)はインターフェースだけを知り、実装の詳細には依存しません。

public interface IMessageSender
{
    Task SendAsync(string to, string body);
}

public sealed class SmtpMessageSender : IMessageSender
{
    public async Task SendAsync(string to, string body)
    {
        // SMTP 送信の具体処理(ダミー)
        await Task.Delay(10);
        Console.WriteLine($"Send mail to {to}: {body}");
    }
}

コンストラクタインジェクションで受け取る

クライアントは必要な依存をコンストラクタ引数として受け取ります。これにより、テスト時はモック実装を渡すだけで検証でき、実運用時は本番実装を注入できます。

public sealed class NotificationService
{
    private readonly IMessageSender _sender;

    public NotificationService(IMessageSender sender)
    {
        _sender = sender;
    }

    public Task NotifyAsync(string userId, string message)
    {
        // ユーザーの連絡先取得などの処理は省略
        var to = $"{userId}@example.com";
        return _sender.SendAsync(to, message);
    }
}

.NET 組み込み DI コンテナへの登録

.NET では Microsoft.Extensions.DependencyInjection によるサービスコンテナが標準提供されています。IServiceCollection に対してインターフェースと実装のマッピングを登録し、BuildServiceProvider() で解決器を作ります。

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();

// ライフタイムを選んで登録
services.AddSingleton<IMessageSender, SmtpMessageSender>();
services.AddTransient<NotificationService>();

var provider = services.BuildServiceProvider();

// 解決して実行
var notifier = provider.GetRequiredService<NotificationService>();
await notifier.NotifyAsync("taro", "ようこそ!");

ここでは送信者をシングルトン、通知サービスをトランジェントとして登録しています。シングルトンはアプリ全体で一つのインスタンスを共有し、トランジェントは解決のたびに新しいインスタンスを作成します。Web アプリではスコープドという単位もあり、HTTP リクエストごとに一つのインスタンスを維持します。

ASP.NET Core での実運用例

最小 API を例に、DI とコントローラ(ハンドラ)の統合を示します。フレームワークが自動でコンストラクタにサービスを注入します。

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<IMessageSender, SmtpMessageSender>();
builder.Services.AddScoped<NotificationService>();

var app = builder.Build();

app.MapPost("/notify/{user}", async (string user, NotificationService svc) =>
{
    await svc.NotifyAsync(user, "通知メッセージ");
    return Results.Ok();
});

await app.RunAsync();

ルートハンドラの引数に NotificationService を書くだけで、登録済みのスコープドサービスがリクエストごとに注入されます。送信者はシングルトンとして使い回されます。

設定値や外部サービスの注入

実運用では API キーや接続文字列などの設定が必要になります。IOptions<T> を使って設定オブジェクトを型安全に注入できます。

public sealed class MailOptions
{
    public string Host { get; init; } = "";
    public int Port { get; init; } = 25;
}

public sealed class SmtpMessageSender : IMessageSender
{
    private readonly MailOptions _opt;

    public SmtpMessageSender(Microsoft.Extensions.Options.IOptions<MailOptions> options)
    {
        _opt = options.Value;
    }

    public async Task SendAsync(string to, string body)
    {
        await Task.Delay(10);
        Console.WriteLine($"SMTP {_opt.Host}:{_opt.Port} -> {to}: {body}");
    }
}

// 登録例(ASP.NET Core Program.cs)
builder.Services.Configure<MailOptions>(builder.Configuration.GetSection("Mail"));
builder.Services.AddSingleton<IMessageSender, SmtpMessageSender>();

これにより、環境ごとに appsettings だけ差し替えて同一コードを運用できます。接続の張り直しコストが大きい場合はシングルトン、リクエストごとに状態を持つ場合はスコープド、軽量でステートレスならトランジェントというように、オブジェクトの性質に応じてライフタイムを選びます。

テスト容易性とモック注入

DI の利点はテストで最も顕著です。実装をモックに差し替えるだけで副作用を排除できます。

public sealed class FakeMessageSender : IMessageSender
{
    public List<(string to, string body)> Sent { get; } = new();

    public Task SendAsync(string to, string body)
    {
        Sent.Add((to, body));
        return Task.CompletedTask;
    }
}

// 単体テスト例(xUnit)
[Fact]
public async Task Notify_Sends_One_Message()
{
    var fake = new FakeMessageSender();
    var svc = new NotificationService(fake);

    await svc.NotifyAsync("hanako", "hi");

    Assert.Single(fake.Sent);
}

生成や外部接続のロジックをテストから切り離せるため、リグレッションを安全に検出できます。

よくある落とし穴と対処の考え方

シングルトンにスコープド依存を直接持たせるとライフタイムの不一致で例外や予期せぬ動作を招きます。必要なら IServiceProvider からスコープを作って取得するなどの対策をとります。また、コンテナは「サービスの生成と配線」を担うだけであり、ビジネスロジックの条件分岐やファクトリ判断まで埋め込まないようにすると、構成が過剰に複雑化することを避けられます。

まとめ

依存性注入は、インターフェース経由の依存とコンストラクタインジェクションを基本に、組み込みコンテナへ登録してフレームワークに解決させるという流れで実装します。ライフタイムはオブジェクトの性質に合わせて選び、設定や外部接続も IOptions とあわせて注入します。これにより、交換可能でテストしやすく、環境差分にも強いアプリケーション設計が実現できます。