Skip to content
Slicekit

Backend guides

The settings pattern

Add strongly-typed, validated configuration with the settings pattern and bind it from appsettings and environment variables.

View .md
On this page

Why typed settings

The API never reads IConfiguration directly. Configuration binds from appsettings.json (plus environment-specific layers and environment variables) into typed records under api/src/Slicekit.Core/Settings/, and consumers inject IOptions<T> (or IOptionsSnapshot<T> / IOptionsMonitor<T> when they need reload semantics).

Every section is validated at boot. A missing connection string or a malformed URL fails the host before it accepts traffic, not on the first request that touches the setting.

Scope: API only. The frontend reads VITE_* env vars at build time. See Frontend overview.

Layout

One settings class per logical group, all in api/src/Slicekit.Core/Settings/:

api/src/Slicekit.Core/Settings/
├── AppSettings.cs        Root composition: every section as a property.
├── DatabaseSettings.cs
├── AuthSettings.cs
├── OAuthSettings.cs
├── AdminSettings.cs
├── MessagingSettings.cs
├── CachingSettings.cs
├── EmailSettings.cs
├── SmtpSettings.cs
├── FrontendSettings.cs
├── ApiKeySettings.cs
├── AuditingSettings.cs
└── StorageSettings.cs

A settings class is a plain class with init-only or settable properties and data-annotation attributes for validation. Here is the whole of FrontendSettings.cs:

using System.ComponentModel.DataAnnotations;

namespace Slicekit.Core.Settings;

public sealed class FrontendSettings
{
    [Required] public string BaseUrl { get; set; } = string.Empty;
}

1. Define the record

Create api/src/Slicekit.Core/Settings/<Name>Settings.cs. Use [Required], [Range], [Url], and the rest of System.ComponentModel.DataAnnotations for single-field rules:

using System.ComponentModel.DataAnnotations;

namespace Slicekit.Core.Settings;

public sealed class WidgetSettings
{
    [Required, Url] public string Endpoint { get; init; } = string.Empty;

    [Range(1, 100)] public int MaxBatchSize { get; init; } = 10;
}

A handy convention used by several classes is a static SectionName property, so the section name lives next to the type that owns it:

public static string SectionName => "Widget";

2. Nest it under the root

Add a property to AppSettings.cs so the section nests under the composed root:

public sealed class AppSettings
{
    public DatabaseSettings Database { get; set; } = new();
    // ...
    public WidgetSettings Widget { get; set; } = new();
}

3. Add placeholder values to appsettings.json

appsettings.json is committed and holds placeholder values only, never real secrets. Add your section with values safe for local dev:

{
  "Widget": {
    "Endpoint": "http://localhost:9999",
    "MaxBatchSize": 10
  }
}

4. Register it

Add one line to api/src/Slicekit.Core/Configuration/Settings.cs, inside ConfigureSettings:

builder.AddSettings<WidgetSettings>(WidgetSettings.SectionName);

That private AddSettings<T> helper wires up binding, ValidateDataAnnotations() and ValidateOnStart() in one shot:

private static OptionsBuilder<T> AddSettings<T>(this IHostApplicationBuilder builder, string section)
    where T : class =>
    builder.Services.AddOptions<T>()
        .BindConfiguration(section)
        .ValidateDataAnnotations()
        .ValidateOnStart();

The API project picks up the whole set through a single .ConfigureSettings() call in api/src/Slicekit.Api/Program.cs. No per-section registration in the host.

5. Inject it

Depend on IOptions<WidgetSettings> wherever you need the values. Reach for IOptionsSnapshot<T> when you want per-request reload, or IOptionsMonitor<T> for change notifications:

public sealed class WidgetClient(IOptions<WidgetSettings> options)
{
    private readonly WidgetSettings _settings = options.Value;
    // ...
}

Cross-field rules: implement IValidatableObject

Data annotations cover one field at a time. For invariants that span fields (“B is required when flag A is on”) or to validate nested settings objects (which ValidateDataAnnotations() does not walk by default), implement IValidatableObject on the settings class. The same AddSettings<T> registration picks it up automatically.

EmailSettings is the worked example in the repo. It carries a nested SmtpSettings and only demands a host and a from-address once email is switched on:

public sealed class EmailSettings : IValidatableObject
{
    public static string SectionName => "Email";

    public bool Enabled { get; init; }
    public SmtpSettings Smtp { get; init; } = new();

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (Smtp.Port is <= 0 or > 65535)
            yield return new ValidationResult(
                "Email:Smtp:Port must be between 1 and 65535",
                [$"{nameof(Smtp)}.{nameof(SmtpSettings.Port)}"]);

        if (!Enabled) yield break;

        if (string.IsNullOrWhiteSpace(Smtp.Host))
            yield return new ValidationResult(
                "Email:Smtp:Host is required when Email:Enabled is true",
                [$"{nameof(Smtp)}.{nameof(SmtpSettings.Host)}"]);

        if (string.IsNullOrWhiteSpace(Smtp.FromEmail))
            yield return new ValidationResult(
                "Email:Smtp:FromEmail is required when Email:Enabled is true",
                [$"{nameof(Smtp)}.{nameof(SmtpSettings.FromEmail)}"]);
    }
}

Each yielded ValidationResult becomes a separate startup error, with the member name(s) attributing the violation. Keeping the rules in the settings class means a single file describes both the shape and its invariants.

Secrets and overrides

appsettings.json ships placeholders. Real values arrive from environment variables at runtime, using ASP.NET’s __ separator to walk into nested sections:

SettingEnv var
Database:ConnectionStringDatabase__ConnectionString
Auth:Jwt:SigningKeyAuth__Jwt__SigningKey
Widget:EndpointWidget__Endpoint

The repo ships no Key Vault, AWS Secrets Manager, or similar scaffolding. Host operators wire env vars however their platform prefers (Docker secrets, Kubernetes secrets, GitHub Actions, and so on). .env.prod.example at the repo root lists every variable a production deploy needs and is consumed by docker-compose.prod.yml. When you add a new section, list its env-var name(s) there. See Deployment for how those values flow into the production stack.

Validation at boot

Combined with ValidateOnStart(), both data annotations and IValidatableObject run when the host starts. Malformed config fails the API at boot rather than at first use:

dotnet run --project api/src/Slicekit.Api

If a required setting is missing or invalid, the host throws before it accepts traffic, and the error names the offending section and member.

Conventions to keep

  • One class per section. Group related keys; nest the property on AppSettings.
  • Placeholders, never secrets, in appsettings.json. Real values come from env vars at runtime.
  • Register once in Settings.cs. A single AddSettings<T> line wires binding plus validation.
  • Inject IOptions<T>, not IConfiguration. Consumers never read raw configuration.
  • Cross-field rules live on the settings class via IValidatableObject, beside the shape.
  • Document new env vars in .env.prod.example so deploy operators know what to set.

New here? Start with Getting started to run the API locally before changing its configuration.