Dependency Injection
The problem
Section titled “The problem”Vanilla ASP.NET Core DI works fine for small applications. Once you cross twenty or
thirty services, Program.cs becomes a dumping ground: authentication, caching,
persistence, background jobs, observability — all crammed into a single file with no
clear ownership. Modular frameworks need conventions that answer two questions:
- Who registers what? Each module should own its own services.
- Where does configuration come from? Parameters should not be scattered across method arguments, environment variables, and magic strings.
Granit answers both with a module system and a strict Options pattern.
Module service registration
Section titled “Module service registration”Every Granit package exposes a GranitModule subclass. The module’s
ConfigureServices method is the single entry point for DI registration — no
extension methods in Program.cs, no global Startup class.
public sealed class GranitBulkheadModule : GranitModule{ public override void ConfigureServices(ServiceConfigurationContext context) => context.Services.AddGranitBulkhead();}The AddGranit*() extension method is the internal implementation detail. Application
code never calls it directly — the module system calls ConfigureServices for every
module in the dependency graph, in topological order.
Declaring module dependencies
Section titled “Declaring module dependencies”Modules declare their dependencies with [DependsOn]. The module system resolves
the graph at startup and calls ConfigureServices in the correct order:
[DependsOn(typeof(GranitPersistenceModule))][DependsOn(typeof(GranitCachingModule))]public sealed class GranitSettingsEntityFrameworkCoreModule : GranitModule{ public override void ConfigureServices(ServiceConfigurationContext context) => context.Services.AddGranitSettingsEntityFrameworkCore();}Service lifetime conventions
Section titled “Service lifetime conventions”| Lifetime | When to use | Example |
|---|---|---|
| Singleton | Stateless registries, metrics, configuration | ConcurrencyLimiterRegistry, SettingDefinitionManager |
| Scoped | Anything that depends on the current request (user, tenant) | ISettingProvider, ICurrentUserService |
| Transient | Rarely — only for lightweight, stateless, disposable types | IClaimsTransformation |
Use TryAdd* to allow downstream modules to replace default implementations:
// Default in-memory store — replaced by EF Core store when that module is installedservices.TryAddSingleton<InMemorySettingStore>();services.TryAddSingleton<ISettingStoreReader>( sp => sp.GetRequiredService<InMemorySettingStore>());services.TryAddSingleton<ISettingStoreWriter>( sp => sp.GetRequiredService<InMemorySettingStore>());The Options pattern
Section titled “The Options pattern”Every configurable Granit module follows the same recipe. No exceptions.
The recipe
Section titled “The recipe”- Declare a
sealed classwith aconst string SectionName. - Bind to the configuration section with
BindConfiguration. - Add
ValidateDataAnnotations()for constraint validation. - Add
ValidateOnStart()so the application fails fast on misconfiguration.
public sealed class ObservabilityOptions{ public const string SectionName = "Observability";
[Required] public string ServiceName { get; set; } = "unknown-service";
public string ServiceVersion { get; set; } = "0.0.0";
[Required] public string OtlpEndpoint { get; set; } = "http://localhost:4317";
public bool EnableTracing { get; set; } = true;
public bool EnableMetrics { get; set; } = true;}Registration in the module’s extension method:
builder.Services .AddOptions<ObservabilityOptions>() .BindConfiguration(ObservabilityOptions.SectionName) .ValidateDataAnnotations() .ValidateOnStart();The corresponding appsettings.json section:
{ "Observability": { "ServiceName": "my-backend", "OtlpEndpoint": "http://otel-collector:4317" }}Custom validation
Section titled “Custom validation”When DataAnnotations are not enough, implement IValidateOptions<T>:
internal sealed class BulkheadOptionsValidator : IValidateOptions<GranitBulkheadOptions>{ public ValidateOptionsResult Validate( string? name, GranitBulkheadOptions options) { if (options.MaxConcurrentRequests < 1) return ValidateOptionsResult.Fail( "MaxConcurrentRequests must be at least 1.");
return ValidateOptionsResult.Success; }}Register it as a singleton in the module’s extension method:
services.AddSingleton<IValidateOptions<GranitBulkheadOptions>, BulkheadOptionsValidator>();The PostConfigure pattern
Section titled “The PostConfigure pattern”Some modules need to override options registered by a dependency — without the
consumer knowing about it. Granit uses PostConfigure for this.
The canonical example: Granit.Authentication.Keycloak post-configures the standard
JwtBearerOptions registered by Granit.Authentication.JwtBearer:
services .AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme) .PostConfigure<IOptions<KeycloakOptions>>((jwt, keycloakOpts) => { KeycloakOptions options = keycloakOpts.Value; string audience = options.Audience ?? options.ClientId; jwt.Authority = options.Authority; jwt.Audience = audience; jwt.RequireHttpsMetadata = options.RequireHttpsMetadata; jwt.TokenValidationParameters.NameClaimType = "preferred_username"; });The application only declares [DependsOn(typeof(GranitAuthenticationKeycloakModule))].
The Keycloak module’s PostConfigure runs after the JWT Bearer module’s Configure,
transparently overriding Authority, Audience, and name claim type with Keycloak-specific
values.
No separate Abstractions packages
Section titled “No separate Abstractions packages”Many .NET frameworks split every package into Foo and Foo.Abstractions. The idea
is that consumers depend only on the interfaces, not the implementations.
Granit does not do this. Interfaces and implementations live in the same package. With 135 packages, splitting would mean 186 packages — doubling the dependency graph complexity, NuGet restore time, and cognitive overhead, for minimal benefit.
The trade-off works because:
- Module boundaries are the abstraction. Each module exposes a small public API
surface. Internal types are
internal. TryAdd*handles replacement. If a downstream module provides a different implementation, it wins — no separate abstractions package needed.- Granit is a framework, not a library. Consumers install whole modules, not individual interfaces. The module system manages the dependency graph.
Summary
Section titled “Summary”| Convention | Rule |
|---|---|
| Service registration | ConfigureServices in the module class, delegates to AddGranit*() |
| Options | sealed class + SectionName + BindConfiguration + ValidateDataAnnotations + ValidateOnStart |
| Cross-module overrides | PostConfigure — never mutate another module’s options in Configure |
| Abstractions | Same package as implementation — no *.Abstractions split |
| Extension method parameters | None (configuration comes from appsettings.json) |