Skip to content

Dependency Injection

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:

  1. Who registers what? Each module should own its own services.
  2. 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.

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.

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();
}
LifetimeWhen to useExample
SingletonStateless registries, metrics, configurationConcurrencyLimiterRegistry, SettingDefinitionManager
ScopedAnything that depends on the current request (user, tenant)ISettingProvider, ICurrentUserService
TransientRarely — only for lightweight, stateless, disposable typesIClaimsTransformation

Use TryAdd* to allow downstream modules to replace default implementations:

// Default in-memory store — replaced by EF Core store when that module is installed
services.TryAddSingleton<InMemorySettingStore>();
services.TryAddSingleton<ISettingStoreReader>(
sp => sp.GetRequiredService<InMemorySettingStore>());
services.TryAddSingleton<ISettingStoreWriter>(
sp => sp.GetRequiredService<InMemorySettingStore>());

Every configurable Granit module follows the same recipe. No exceptions.

  1. Declare a sealed class with a const string SectionName.
  2. Bind to the configuration section with BindConfiguration.
  3. Add ValidateDataAnnotations() for constraint validation.
  4. 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"
}
}

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>();

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.

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.
ConventionRule
Service registrationConfigureServices in the module class, delegates to AddGranit*()
Optionssealed class + SectionName + BindConfiguration + ValidateDataAnnotations + ValidateOnStart
Cross-module overridesPostConfigure — never mutate another module’s options in Configure
AbstractionsSame package as implementation — no *.Abstractions split
Extension method parametersNone (configuration comes from appsettings.json)