All articles

Read appsettings.json in a .NET Class Library with Dependency Injection

The options pattern done right: the host owns configuration sources, the library declares typed settings, and DI carries one to the other — with validation at startup.

0 · log in to like, save & follow Share on LinkedIn Share on X

The question "how do I read appsettings.json from my class library?" has a surprising correct answer: you don't. A library that builds its own ConfigurationBuilder and reads files ties itself to file paths, breaks in tests, and fights the host application over configuration sources. The .NET options pattern inverts it: the host owns configuration; the library declares a typed settings class and receives it through dependency injection. This post builds the full pattern on .NET 10, verified end to end.

Read appsettings.json in a .NET Class Library with Dependency Injection

The wrong way, and why it hurts

// inside the class library — don't do this
var config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build();
var sender = config["Email:SenderAddress"];

Three failures hiding in two lines: the file path resolves against whatever the current directory happens to be (services, tests, and tools all differ); the library now ignores every other source the host configured (environment variables, user secrets, Key Vault); and nothing is typed — a typo in "Email:SenderAdress" is a silent null.

The library side: a settings class and a consumer

The library ships two things and references only Microsoft.Extensions.Options:

public class EmailSettings
{
    public required string SenderName { get; set; }
    public required string SenderAddress { get; set; }
    public int RetryCount { get; set; } = 3;
}

public class NewsletterService(IOptions<EmailSettings> options)
{
    public void Send(string subject)
    {
        var s = options.Value;
        Console.WriteLine($"Sending '{subject}' as {s.SenderName} <{s.SenderAddress}> (retries: {s.RetryCount})");
    }
}

No files, no builders, no paths. The library defines what it needs (an EmailSettings), defaults what it can (RetryCount = 3), and requires what it must (required members fail fast when unbound).

The host side: bind and register

The host — an ASP.NET Core app, a worker service, or the console app in the demo — owns the sources and binds the section:

var configuration = new ConfigurationBuilder()
    .SetBasePath(AppContext.BaseDirectory)
    .AddJsonFile("appsettings.json", optional: false)
    .AddEnvironmentVariables()               // env vars override the file
    .Build();

var services = new ServiceCollection();
services.Configure<EmailSettings>(configuration.GetSection("Email"));
services.AddSingleton<NewsletterService>();

with the host's appsettings.json:

{
  "Email": {
    "SenderName": "GeeksArray",
    "SenderAddress": "no-reply@geeksarray.com",
    "RetryCount": 5
  }
}

Verified run:

Sending 'Welcome to GeeksArray!' as GeeksArray <no-reply@geeksarray.com> (retries: 5)

The host's 5 overrode the library's default 3; had the host omitted RetryCount, the default would stand. In ASP.NET Core the ceremony is even shorter — builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("Email")) — because the WebApplication builder already assembled the configuration (json + environment + user secrets + command line) in the standard override order. Email__RetryCount=7 as an environment variable beats the file with zero code changes — which is exactly how containerized deployments inject settings.

IOptions, IOptionsSnapshot, IOptionsMonitor

Three interfaces, one decision — how fresh must the values be?

Interface Lifetime Values change at runtime?
IOptions<T> singleton never — read once at first use
IOptionsSnapshot<T> scoped per request/scope (reloadOnChange file edits apply)
IOptionsMonitor<T> singleton live, with OnChange callbacks

IOptions<T> is right for 90% of libraries. Reach for IOptionsMonitor in long-lived singletons that must react to config edits without restart (log levels are the classic case).

Validation: fail at startup, not at 2 a.m.

Unbound or invalid settings should stop the app before traffic arrives:

services.AddOptions<EmailSettings>()
    .Bind(configuration.GetSection("Email"))
    .ValidateDataAnnotations()
    .Validate(s => s.RetryCount is >= 0 and <= 10, "RetryCount must be 0-10")
    .ValidateOnStart();

ValidateOnStart moves the failure from "first email fails mysteriously" to "service refuses to boot with a clear message" — the difference between a deploy rollback and an incident.

Testing becomes trivial

Because the library never touches files, tests construct options directly:

var service = new NewsletterService(Options.Create(new EmailSettings
{ SenderName = "Test", SenderAddress = "t@example.com" }));

No test appsettings.json, no directory juggling, no configuration builder in test code. This is the quiet proof the design is right — the library's requirements are visible in its constructor, satisfiable from anywhere.

The rule to remember: configuration sources are the host's business; typed settings are the library's contract; dependency injection carries one to the other. The complete runnable demo is in the companion repository.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.