Backend guides
The settings pattern
Add strongly-typed, validated configuration with the settings pattern and bind it from appsettings and environment variables.
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:
| Setting | Env var |
|---|---|
Database:ConnectionString | Database__ConnectionString |
Auth:Jwt:SigningKey | Auth__Jwt__SigningKey |
Widget:Endpoint | Widget__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 singleAddSettings<T>line wires binding plus validation. - Inject
IOptions<T>, notIConfiguration. 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.exampleso deploy operators know what to set.
New here? Start with Getting started to run the API locally before changing its configuration.