Skip to content

Modules

Structure modular monoliths as independently configurable feature modules within one host.

Overview

The Modules feature in bITdevKit supports modular monoliths: applications organized into feature modules within one host and repository. A module owns service registration, can contribute middleware and, for web modules, can map endpoints. Teams can use those lifecycle boundaries to keep business logic, data access and presentation code grouped by domain.

The feature also provides configuration binding, module enablement and request-context resolution. Modules can use other bITdevKit components, including Requester, Notifier and repositories, through normal dependency injection.

Background

A modular monolith combines a single deployment, shared runtime and unified codebase with explicit internal boundaries. Dividing an application into modules aligned with business domains can reduce accidental coupling and make ownership clearer. These boundaries still depend on the application design: the Modules feature supplies lifecycle and context mechanisms, but it does not enforce data or source-code isolation.

Challenges

In a growing monolith, service registration, middleware, endpoints and configuration can become difficult to associate with the feature that owns them. Disabling a feature or identifying the active feature for an HTTP request or an in-process request also requires consistent conventions.

Solution

Define each feature as an IModule or IWebModule. Select known modules explicitly with WithModule<TModule>(), or discover modules from named assemblies. The host registers selected modules, runs their application-pipeline hooks and maps web-module routes. Configuration controls whether a module is enabled, while context accessors resolve a module from an HTTP request or .NET type. Disabled modules can then be rejected by HTTP middleware or a Requester/Notifier pipeline behavior.

Key Features

  • Module lifecycle: Use Register, Use and, for web modules, Map to group startup responsibilities.
  • Explicit registration: Use WithModule<TModule>(), WithModule(Type) or WithModule(IModule) without relying on assembly discovery or load order.
  • Host isolation: Resolve IModuleRegistry to inspect the selected modules for one host.
  • Assembly discovery: Use DiscoverModulesFrom<TMarker>() or the assembly-scanning AddModules overload when a host must find every constructible module in an assembly.
  • Configuration binding: Bind and validate settings from the Modules:{module-name} configuration section.
  • Module enablement: Set Modules:{module-name}:Enabled to false to mark a module as disabled.
  • Request context: Resolve a module from the ModuleName header or query parameter, a module segment in the path or a configured API path selector.
  • Type context: Resolve a module from a request type's assembly metadata or namespace.
  • Diagnostics: Add the module name to logging scopes, tracing baggage, metrics tags and HTTP response headers.
  • Pipeline integration: Reject Requester and Notifier operations associated with disabled modules by using ModuleScopeBehavior<,>.

Architecture

The Modules feature centers on the IModule and IWebModule interfaces. IModule.Register adds services, IModule.Use contributes to the application pipeline and IWebModule.Map contributes routes.

Each service collection owns one IModuleRegistry. Every WithModule overload selects a module through that registry and then uses one registration operation. The operation applies enablement, registers the module and its ActivitySource, and calls Register once for that host. UseModules and MapModules resolve the registry from the built application. Both methods use the same instances that ran Register.

The registry orders modules by ascending Priority, then by Name with ordinal-ignore-case comparison. Registering the same instance or selecting an already selected type is idempotent. Registering a second instance of the same type fails because WithModule is not a replacement API. Different module types cannot use the same name, including names that differ only by case.

Explicit registration does not scan assemblies. If the registry does not contain the exact type, WithModule<TModule>() and WithModule(Type) create it through Factory.Create. The type must be a concrete, closed IModule with a public parameterless constructor. Activation failures throw an InvalidOperationException that identifies the module type.

Reflection discovery is separate. Use DiscoverModulesFrom<TMarker>() inside the registration callback to scan the marker's assembly. The assembly-scanning AddModules overload remains available. If that overload receives no assemblies, it scans the assemblies loaded at the time of the call. Every discovered instance enters the same registration operation as an explicitly selected module.

ModuleBase derives the default module name by removing Module from the class name and converting the result to lowercase. For example, CustomerModule is named customer, so its configuration section is Modules:customer. The registry applies Modules:{module-name}:Enabled before Register. The same value is therefore visible in Register, Use and Map. Registration and lifecycle callbacks still run for disabled modules, while context-aware middleware and behaviors reject work associated with a disabled module.

IModule.IsRegistered remains available as compatibility metadata, but the registry does not use it to decide whether to call Register. Reusing one module instance in two hosts calls Register once in each host. Module activity listeners follow host lifetime and share one process-wide callback while one or more module hosts are running.

RequestModuleMiddleware, installed by UseRequestModuleContext, resolves an HTTP request's module. For a resolved module, it adds the module name to the log scope, activity baggage, response headers and request items. ModuleScopeBehavior<,> performs the corresponding enablement check for Requester and Notifier operations whose request type can be associated with a module.

sequenceDiagram
    participant Client
    participant Middleware as RequestModuleMiddleware
    participant Endpoint as ASP.NET Core endpoint
    participant Services as Module services

    Client->>Middleware: HTTP Request (e.g., /api/customers)
    Middleware->>Middleware: Resolve module context
    alt Module Enabled
        Middleware->>Endpoint: Continue request pipeline
        Endpoint->>Services: Execute operation
        Services-->>Endpoint: Result
        Endpoint-->>Middleware: HTTP response
        Middleware-->>Client: HTTP Response
    else Module Disabled
        Middleware-->>Client: ModuleNotEnabledException
    end

Use Cases

Use modules to group domain-aligned features such as customer and order management, to keep feature-specific startup code together or to make a feature unavailable through configuration. Module context is also useful for adding a feature identifier to logs and traces. A modular structure can make later extraction into a separate service easier, but extraction and independent deployment are not provided by this feature.

Basic Usage

Start by defining a module class and configuring the application host. The following example shows a CustomerModule for managing customer data.

Module definition and setup

public class CustomerModule : WebModuleBase
{
    public override IServiceCollection Register(
        IServiceCollection services,
        IConfiguration configuration = null,
        IWebHostEnvironment environment = null)
    {
        var moduleConfiguration = this.Configure<CustomerModuleConfiguration, CustomerModuleConfiguration.Validator>(services, configuration);

        services.AddSqlServerDbContext<CustomerDbContext>(o => o
            .UseConnectionString(moduleConfiguration.ConnectionStrings["Default"])
            .UseLogger())
            .WithDatabaseMigratorService(o => o
                .Enabled(environment.IsLocalDevelopment())
                .DeleteOnStartup(environment.IsLocalDevelopment()));

        services.AddEntityFrameworkRepository<Customer, CustomerDbContext>()
            .WithBehavior<RepositoryLoggingBehavior<Customer>>()
            .WithBehavior<RepositoryAuditStateBehavior<Customer>>();

        services.AddEndpoints<CustomerEndpoints>();

        return services;
    }

    public override IApplicationBuilder Use(
        IApplicationBuilder app,
        IConfiguration configuration = null,
        IWebHostEnvironment environment = null)
    {
        return app;
    }

    public override IEndpointRouteBuilder Map(
        IEndpointRouteBuilder app,
        IConfiguration configuration = null,
        IWebHostEnvironment environment = null)
    {
        return app;
    }
}

Configure the module in appsettings.json:

{
  "Modules": {
    "customer": {
      "Enabled": true,
      "ConnectionStrings": {
        "Default": "Server=(localdb)\\MSSQLLocalDB;Database=customers;Trusted_Connection=True"
      }
    }
  }
}

Define a configuration class and validator:

public class CustomerModuleConfiguration
{
    public IReadOnlyDictionary<string, string> ConnectionStrings { get; set; }

    public class Validator : AbstractValidator<CustomerModuleConfiguration>
    {
        public Validator()
        {
            RuleFor(c => c.ConnectionStrings)
                .NotNull().NotEmpty()
                .Must(c => c.ContainsKey("Default"))
                .WithMessage("Connection string 'Default' is required");
        }
    }
}

Register the module in the host:

var builder = DevKitWebApplication.CreateBuilder(args)
    .AddConfiguration()
    .AddLogging()
    .AddModules(modules => modules
        .WithModule<CustomerModule>());

var app = builder.Build();
app.UseRequestModuleContext();
app.UseModules();
app.MapModules();
app.MapEndpoints();
app.Run();

With the module enabled, GET /api/customers/{id} reaches CustomerEndpoints. MapHttpOk converts a successful result to HTTP 200 and maps a failed result to the configured HTTP error response instead of reading a missing value.

To inspect the modules selected for this host, resolve its read-only registry:

var registry = app.Services.GetRequiredService<IModuleRegistry>();

foreach (var module in registry.Modules)
{
    Console.WriteLine($"{module.Priority}: {module.Name}");
}

Discovering modules

Use explicit registration when the host knows the module type. It is deterministic and does not depend on assembly load order.

Use reflection discovery when the host must register every module in an assembly:

var builder = DevKitWebApplication.CreateBuilder(args)
    .AddConfiguration()
    .AddLogging()
    .AddModules(modules => modules
        .DiscoverModulesFrom<CustomerModule>());

Discovery has the same construction requirements and duplicate checks as explicit registration. It does not create a second set of web modules for Map.

Defining endpoints

Define module-specific endpoints using minimal APIs:

public class CustomerEndpoints : EndpointsBase
{
    public override void Map(IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("api/customers").WithTags("Customers");

        group.MapGet("/{id:guid}", async (IRequester requester, Guid id) =>
        {
            var result = await requester.SendAsync(new CustomerFindOneQuery(id.ToString()));
            return result.MapHttpOk();
        }).WithName("Customers.GetById");

        group.MapPost("", async (IRequester requester, CustomerModel model) =>
        {
            var result = await requester.SendAsync(new CustomerCreateCommand(model));
            return result.MapHttpCreated(value => $"/api/customers/{value.Id}");
        }).WithName("Customers.Create");
    }
}

Register endpoints in the module:

services.AddEndpoints<CustomerEndpoints>();

Scoping requests

Install RequestModuleMiddleware to resolve module context for HTTP requests:

app.UseRequestModuleContext();

For commands and queries, apply the ModuleScopeBehavior:

services.AddRequester()
    .AddHandlers()
    .WithBehavior(typeof(ModuleScopeBehavior<,>));

The behavior rejects a command, query or notification when its request type resolves to a disabled module. If no accessor resolves the type, the operation continues with the module name UnknownModule.

Feature toggling

Toggle modules by setting the Enabled property in appsettings.json:

{
  "Modules": {
    "customer": {
      "Enabled": false
    }
  }
}

When middleware or ModuleScopeBehavior<,> resolves a disabled module, it throws ModuleNotEnabledException. Disabling a module does not skip its Register, Use or Map callback.

Best practices

  • Align modules with business domains (e.g., customers vs. orders) for clear boundaries.
  • Use separate database contexts or schemas to isolate module data.
  • Keep shared utilities outside domain modules when they do not have a clear domain owner.
  • Use environment-specific configuration when module availability differs by environment.
  • Test module registration, middleware and endpoint mapping independently where practical.
  • Keep cross-module dependencies explicit if later extraction is a requirement.
  • Use strongly-typed configurations with validation to prevent runtime errors.

Appendix A: Comparison with microservices

Summary

Modules in a monolith contrast with microservices, which are independently deployable services. Both aim to separate concerns, but they differ in deployment and complexity.

Characteristics

Modular monolith

  • Approach: Single deployment with logically separated modules, sharing a runtime and repository.
  • Strengths: Simplifies deployment, reduces distributed system complexity, supports parallel development.
  • Considerations: Shared resources may cause contention, requires careful boundary design.

Microservices

  • Approach: Independent services with separate deployments and databases.
  • Strengths: Scales independently, isolates failures, allows polyglot persistence.
  • Considerations: Increases operational complexity due to a network involved, requires distributed system expertise.

Tradeoffs

  • Deployment: Modular monoliths deploy as a single unit, simplifying operations but limiting independent scaling. Microservices deploy separately, enabling fine-grained scaling but requiring orchestration.
  • Complexity: Modules reduce distributed system challenges, while microservices introduce network latency and consistency issues.
  • Development: Modules support parallel work within one repo, while microservices require cross-team coordination.
  • Migration: Modules can be extracted to microservices, providing a transition path.

Practical considerations

Choose modules when one deployment and runtime fit the operational requirements but explicit feature boundaries are still useful. Choose separate services when independent deployment, scaling or failure isolation justify the additional network and operational concerns. The Modules feature does not automate a later migration; it provides lifecycle boundaries that can support one.