Skip to content

Module System

ASP.NET Core gives you dependency injection but no concept of a module. When a framework grows to 135 packages, you hit real problems fast:

  • Ordering — packages must register services in the right sequence (a caching module before a module that depends on it).
  • Lifecycle — some setup happens at DI registration time, other setup happens after builder.Build() (middleware, endpoints).
  • Conditional loading — Vault is useless in local development, Redis is optional if no connection string is configured.
  • Discovery — you need a single entry point that pulls in the entire dependency tree without listing every package by hand.

Granit solves this with a lightweight module system inspired by ABP Framework, built on four primitives: GranitModule, [DependsOn], topological sort, and a two-phase lifecycle.

Each Granit NuGet package exposes exactly one class that inherits from GranitModule. This class is the package’s entry point — it declares dependencies and implements lifecycle hooks.

[DependsOn(typeof(GranitPersistenceModule))]
[DependsOn(typeof(GranitCachingModule))]
public sealed class GranitNotificationsModule : GranitModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddGranitNotifications();
}
public override void OnApplicationInitialization(ApplicationInitializationContext context)
{
// Map endpoints, register middleware, etc.
}
}

The [DependsOn] attribute accepts one or more module types. Multiple attributes can be stacked. The attribute is inherited, so a module automatically includes its base class dependencies.

ModuleLoader walks the [DependsOn] graph starting from your root module, discovers all reachable modules, deduplicates them, and sorts them using Kahn’s algorithm. Dependencies are loaded first; the root module is loaded last. Circular dependencies throw an InvalidOperationException at startup with the names of the involved modules.

sequenceDiagram
    participant Host as Program.cs
    participant Loader as ModuleLoader
    participant ModA as Module A (leaf)
    participant ModB as Module B (root)

    Host->>Loader: builder.AddGranit(ModB)
    Loader->>Loader: Discover + topological sort

    Note over Loader: Phase 1 — ConfigureServices
    Loader->>ModA: IsEnabled(context)?
    ModA-->>Loader: true
    Loader->>ModA: ConfigureServices(context)
    Loader->>ModB: IsEnabled(context)?
    ModB-->>Loader: true
    Loader->>ModB: ConfigureServices(context)

    Host->>Host: app = builder.Build()

    Note over Host: Phase 2 — OnApplicationInitialization
    Host->>ModA: OnApplicationInitialization(context)
    Host->>ModB: OnApplicationInitialization(context)

Phase 1 — ConfigureServices runs before builder.Build(). The ServiceConfigurationContext gives you access to:

PropertyTypePurpose
ServicesIServiceCollectionDI registration
ConfigurationIConfigurationappsettings, env vars
BuilderIHostApplicationBuilderFull builder (needed by Observability, etc.)
ModuleAssembliesIReadOnlyList<Assembly>All loaded module assemblies for convention scanning
ItemsIDictionary<string, object?>Shared state for inter-module communication

Phase 2 — OnApplicationInitialization runs after Build(), before app.Run(). The ApplicationInitializationContext provides the resolved IServiceProvider.

Both phases have async variants (ConfigureServicesAsync, OnApplicationInitializationAsync) for modules that need to read remote config or verify connectivity at startup.

Override IsEnabled to skip a module’s lifecycle methods based on configuration or environment. The module stays in the dependency graph (its dependents still load), but its ConfigureServices and OnApplicationInitialization are not called.

// Vault is disabled in Development — no Vault server needed locally
[DependsOn(typeof(GranitEncryptionModule))]
public sealed class GranitVaultModule : GranitModule
{
public override bool IsEnabled(ServiceConfigurationContext context) =>
!context.Builder.Environment.IsDevelopment();
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddGranitVault();
}
}

Your Program.cs needs exactly one call:

var builder = WebApplication.CreateBuilder(args);
builder.AddGranit<AppHostModule>();
var app = builder.Build();
await app.UseGranit();
await app.RunAsync();

AddGranit<T>() discovers every module reachable from AppHostModule, sorts them, and runs Phase 1. UseGranit() runs Phase 2. That is the entire bootstrap.

A fluent builder API is also available when you need to compose modules from multiple independent roots:

builder.AddGranit(granit => granit
.AddModule<GranitPersistenceModule>()
.AddModule<GranitObservabilityModule>()
.AddModule<AppHostModule>()
);

Not every cross-cutting concern requires a hard [DependsOn]. The ICurrentTenant interface lives in Granit.Core.MultiTenancy and is available to every module without referencing Granit.MultiTenancy. A NullTenantContext (where IsAvailable returns false) is registered by default during AddGranit.

This means a module like Granit.BlobStorage.EntityFrameworkCore can inject ICurrentTenant? and apply tenant filters without declaring [DependsOn(typeof(GranitMultiTenancyModule))]. The hard dependency is reserved for modules that must enforce tenant isolation (e.g., BlobStorage throws if no tenant context is available, for GDPR reasons).

The module graph is your architecture diagram. Every [DependsOn] is a compile-time-visible edge. The topological sort guarantees that if module B depends on module A, A’s services are registered first. No runtime surprises, no ordering bugs.

  • Core reference — full API surface of Granit.Core
  • Bundles — pre-composed module groups for common scenarios