A CLI tool and library suite for scaffolding .NET modular monolith solutions — with a built-in CQRS mediator, messaging with transactional outbox, and optional Aspire integration.
Modulus helps you build modular monoliths in .NET. Instead of starting with microservices, you start with a single deployable unit where each feature lives in its own module with clear boundaries. When the time comes, any module can be extracted into a standalone service.
Modulus provides:
- A CLI tool that scaffolds solutions, adds modules, and wires everything together
- A lightweight CQRS mediator with pipeline behaviors and a Result pattern (no MediatR dependency)
- An in-house messaging layer with RabbitMQ, Azure Service Bus, and in-memory transports — no commercial messaging-framework dependencies
- A transactional outbox for reliable cross-module event publishing
- Optional Aspire integration for local development orchestration
dotnet tool install --global ModulusKit.Climodulus init EShop --aspire --transport rabbitmqThis creates a full solution with building blocks (Domain, Application, Infrastructure layers), a WebApi host, test projects, and Aspire orchestration.
cd EShop
modulus add-module Catalog
modulus add-module OrdersEach module gets its own Domain, Application, Infrastructure, Api, and Integration layers plus unit, integration, and architecture test projects.
modulus list-modules# Define an integration event in the publishing module
modulus add-event OrderPlaced --module Orders --properties "OrderId:Guid,Total:decimal"
# Generate a handler in the consuming module (auto-wires the cross-module reference)
modulus add-consumer OrderPlaced --module Catalogadd-event creates the event in the module's Integration project; add-consumer creates an IIntegrationEventHandler<T> in the consuming module's Infrastructure project and adds the MOD001-compliant Integration project reference automatically. Handlers are discovered and registered by the source generator — no manual wiring.
Strongly typed IDs, handler registration, and module discovery all work automatically once the packages are referenced. No additional setup is needed.
graph TB
subgraph Host["EShop.WebApi"]
API[Minimal API Endpoints]
REG[Module Registration]
end
subgraph Modules
subgraph Catalog["Catalog Module"]
CA[Catalog.Api]
CAP[Catalog.Application]
CD[Catalog.Domain]
CI[Catalog.Infrastructure]
CINT[Catalog.Integration]
end
subgraph Orders["Orders Module"]
OA[Orders.Api]
OAP[Orders.Application]
OD[Orders.Domain]
OI[Orders.Infrastructure]
OINT[Orders.Integration]
end
end
subgraph BuildingBlocks["Building Blocks"]
BB_D[Domain<br/>AggregateRoot, Entity, ValueObject]
BB_A[Application<br/>UnitOfWork, Pagination]
BB_I[Infrastructure<br/>BaseDbContext, Repository]
BB_INT[Integration<br/>IntegrationEvent base types]
end
subgraph Libraries["Modulus Libraries"]
MED[Modulus.Mediator<br/>CQRS + Pipeline]
MSG[Modulus.Messaging<br/>Transports + Outbox]
GEN[Modulus.Generators<br/>Source Generators]
ANZ[Modulus.Analyzers<br/>Compile-time Analysis]
end
API --> CA
API --> OA
CA --> CAP --> CD
CA --> CI
OA --> OAP --> OD
OA --> OI
CI --> MED
OI --> MED
CI --> MSG
OI --> MSG
CD --> BB_D
OD --> BB_D
CAP --> BB_A
OAP --> BB_A
CI --> BB_I
OI --> BB_I
CINT --> BB_INT
OINT --> BB_INT
GEN -.-> CAP
GEN -.-> OAP
ANZ -.-> CAP
ANZ -.-> OAP
Modulus includes a custom CQRS mediator — no MediatR dependency required. It provides commands, queries, domain events, and streaming queries with a configurable pipeline.
// Command (no return value)
public record PlaceOrder(string CustomerId, List<OrderItem> Items) : ICommand;
// Command (with return value)
public record CreateProduct(string Name, decimal Price) : ICommand<Guid>;
// Query
public record GetOrderById(Guid Id) : IQuery<OrderDto>;public class PlaceOrderHandler : ICommandHandler<PlaceOrder>
{
public async Task<Result> Handle(PlaceOrder command, CancellationToken ct)
{
var order = Order.Create(command.CustomerId, command.Items);
await _repository.Add(order, ct);
return Result.Success();
}
}
public class GetOrderByIdHandler : IQueryHandler<GetOrderById, OrderDto>
{
public async Task<Result<OrderDto>> Handle(GetOrderById query, CancellationToken ct)
{
var order = await _repository.GetById(query.Id, ct);
if (order is null)
return Error.NotFound("Order.NotFound", "Order was not found");
return Result<OrderDto>.Success(order.ToDto());
}
}var result = await mediator.Send(new PlaceOrder("cust-1", items));
if (result.IsFailure)
return Results.BadRequest(result.Errors);Behaviors wrap every request in a middleware-style pipeline, executing in registration order:
Request → UnhandledExceptionBehavior → LoggingBehavior → ValidationBehavior → Handler → Response
| Behavior | Purpose |
|---|---|
UnhandledExceptionBehavior |
Catches exceptions and returns a generic failure Result (details logged, not exposed to callers) |
LoggingBehavior |
Logs request timing and success/failure |
ValidationBehavior |
Runs FluentValidation validators, short-circuits on errors |
MetricsBehavior |
Records handler duration via System.Diagnostics.Metrics |
Register the pipeline:
services.AddModulusMediator();
services.AddModulusHandlers(); // Auto-generated by source generator
services.AddPipelineBehavior(typeof(UnhandledExceptionBehavior<,>));
services.AddPipelineBehavior(typeof(LoggingBehavior<,>));
services.AddPipelineBehavior(typeof(ValidationBehavior<,>));Every command and query returns a Result or Result<T>, making error handling explicit and composable:
// Error types map to HTTP status codes
Error.Validation(code, description) // → 400 Bad Request
Error.Unauthorized(code, description) // → 401 Unauthorized
Error.Forbidden(code, description) // → 403 Forbidden
Error.NotFound(code, description) // → 404 Not Found
Error.Conflict(code, description) // → 409 Conflict
Error.Failure(code, description) // → 500 Internal Server ErrorA typical request flows through the full pipeline from HTTP request to HTTP response:
HTTP Request
↓
Minimal API Endpoint
↓
mediator.Send(command) / mediator.Query(query)
↓
┌─────────────────────────────────┐
│ UnhandledExceptionBehavior │ ← catches exceptions → Result.Failure
│ ┌───────────────────────────┐ │
│ │ LoggingBehavior │ │ ← logs timing + outcome
│ │ ┌─────────────────────┐ │ │
│ │ │ ValidationBehavior │ │ │ ← FluentValidation → ValidationResult (short-circuits)
│ │ │ ┌───────────────┐ │ │ │
│ │ │ │ Handler │ │ │ │ ← business logic → Result.Success / Result.Failure
│ │ │ └───────────────┘ │ │ │
│ │ └─────────────────────┘ │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
↓
UnitOfWork commit (on success)
↓
Result → HTTP Response
├── IsSuccess → 200 OK / 201 Created
├── Validation error → 400 Bad Request
├── NotFound error → 404 Not Found
└── Failure error → 500 Internal Server Error
Modulus ships its own messaging layer for cross-module communication with pluggable transports: in-memory (built in), RabbitMQ (ModulusKit.Messaging.RabbitMq), and Azure Service Bus (ModulusKit.Messaging.AzureServiceBus). Broker transports are one registration call (AddModulusRabbitMqTransport() / AddModulusAzureServiceBusTransport()) alongside AddModulusMessaging.
public record OrderShipped(Guid OrderId, DateTime ShippedAt)
: IntegrationEvent;
// Store in the outbox for reliable, transactional delivery (recommended)
await outboxStore.Save(new OrderShipped(orderId, DateTime.UtcNow));
// Or publish directly (not transactional — use only when outbox is not needed)
await messageBus.Publish(new OrderShipped(orderId, DateTime.UtcNow));public class OrderShippedHandler : IIntegrationEventHandler<OrderShipped>
{
public async Task Handle(OrderShipped @event, CancellationToken ct)
{
// React to the event in another module
}
}Configure the transport at startup — no handler code changes required:
services.AddModulusMessaging(options =>
{
options.Transport = Transport.RabbitMq; // or InMemory, AzureServiceBus
options.ConnectionString = "amqp://localhost";
options.Assemblies.Add(typeof(Program).Assembly);
options.OutboxBatchSize = 100; // 1–1000, default 100
options.OutboxPollInterval = TimeSpan.FromSeconds(5); // min 1s, default 5s
});The outbox pattern ensures events are published reliably even if the message broker is temporarily unavailable. Events are stored as database rows first, then a background processor publishes them:
- Handler saves business data and calls
outboxStore.Save(...); the default store commits outbox rows through its ownOutboxDbContext OutboxProcessordispatches pending events (immediately via change notification, with polling as the fallback sweep)- Events are published through the configured transport and marked as processed
For strict atomicity between business rows and outbox rows, map OutboxMessage into your application DbContext so both commit in a single SaveChanges — see the outbox documentation for both configurations and their trade-offs.
Pass --aspire when initializing to include .NET Aspire projects:
modulus init EShop --aspireThis adds an AppHost and ServiceDefaults project. The WebApi host automatically registers service defaults and health check endpoints.
Modulus includes Roslyn incremental source generators that eliminate boilerplate and replace runtime reflection with compile-time code generation.
Annotate a readonly partial record struct with [StronglyTypedId] to generate a complete value type with EF Core, JSON, and model binding support:
[StronglyTypedId]
public readonly partial record struct OrderId;
[StronglyTypedId(typeof(int))]
public readonly partial record struct SequenceNumber;The generator produces:
- Value property, constructor,
New()factory (Guid-backed), andEmpty - EF Core
ValueConverterfor database persistence - System.Text.Json
JsonConverterfor API serialization TypeConverterfor minimal API route parameter binding
Supported backing types: Guid (default), int, long.
A source generator discovers all handler and validator types at compile time and produces an AddModulusHandlers() extension method with explicit registrations -- no Scrutor, no reflection:
// In your module's ConfigureServices:
services.AddModulusMediator();
services.AddModulusHandlers(); // Source-generated — registers all handlers and validatorsDiscovered types: ICommandHandler<>, IQueryHandler<>, IStreamQueryHandler<>, IDomainEventHandler<>, IIntegrationEventHandler<>, and AbstractValidator<>.
A source generator scans referenced assemblies for IModuleRegistration implementations and produces AddAllModules() and MapAllModuleEndpoints() methods:
// In Program.cs:
builder.Services.AddAllModules(builder.Configuration);
app.MapAllModuleEndpoints();Control initialization order with the [ModuleOrder] attribute:
[ModuleOrder(1)]
public class CatalogModule : IModuleRegistration { /* ... */ }This eliminates the manual ModuleRegistration.cs file and simplifies the modulus add-module CLI command -- adding a module no longer requires modifying the composition root.
Modulus ships with five Roslyn analyzers that enforce modular architecture conventions directly in your IDE:
| Rule | Severity | Description |
|---|---|---|
| MOD001 | Error | Module boundary violation -- cross-module reference to non-Integration project |
| MOD002 | Warning | Handler not returning Result or Result<T> |
| MOD003 | Warning | Throwing exceptions for expected errors in handlers instead of returning Error |
| MOD004 | Warning | Infrastructure attributes (EF, JSON) in Domain layer |
| MOD005 | Info | Public setter on entity property |
Code fixes are available for MOD003 (converts throw to return Error) and MOD005 (adds private to setter). Suppress rules with #pragma warning disable or .editorconfig.
Analyzers complement the NetArchTest architecture tests -- analyzers give real-time IDE feedback as you type, while architecture tests provide a CI safety net.
Each module follows a clean architecture layout:
src/Modules/Catalog/
├── src/
│ ├── Catalog.Api/ # Minimal API endpoints
│ ├── Catalog.Application/ # Commands, queries, handlers, validators
│ ├── Catalog.Domain/ # Entities, value objects, domain events
│ ├── Catalog.Infrastructure/ # DbContext, repositories, module registration
│ └── Catalog.Integration/ # Integration events shared with other modules
└── tests/
├── Catalog.Tests.Unit/
├── Catalog.Tests.Integration/
└── Catalog.Tests.Architecture/
Modules communicate with each other only through integration events — never by direct project references.
Because modules have clear boundaries, extracting one to a standalone service is straightforward:
- Create a new WebApi host for the module
- Move the module projects (Api, Application, Domain, Infrastructure) to the new solution
- Switch the transport from
InMemorytoRabbitMqorAzureServiceBusin both solutions - Update the outbox to publish events over the real transport
- Remove the module from the monolith solution
No handler or business logic changes are needed — the mediator and messaging abstractions remain the same.
| Package | Description |
|---|---|
ModulusKit.Cli |
CLI tool for scaffolding modular monolith solutions |
ModulusKit.Mediator |
CQRS mediator with pipeline behaviors and Result pattern |
ModulusKit.Mediator.Abstractions |
Mediator interfaces, Result types, and pipeline contracts |
ModulusKit.Messaging |
Messaging core with transactional outbox/inbox and in-memory transport |
ModulusKit.Messaging.Abstractions |
Messaging interfaces and integration event contracts |
ModulusKit.Messaging.RabbitMq |
RabbitMQ transport built on RabbitMQ.Client |
ModulusKit.Messaging.AzureServiceBus |
Azure Service Bus transport built on Azure.Messaging.ServiceBus |
ModulusKit.Generators |
Source generators for strongly typed IDs, handler registration, and module discovery |
ModulusKit.Analyzers |
Roslyn analyzers enforcing modular architecture conventions |
ModulusKit.Testing |
Test harness, in-memory test transport, and outbox/inbox assertion helpers |
ModulusKit.Generators and ModulusKit.Analyzers are development-dependency packages, so they do not flow transitively through other ModulusKit.* packages (or through ProjectReferences). Reference them explicitly in each project that needs them:
<!-- In every project that defines handlers, validators, or [StronglyTypedId] types,
and in the host project (for AddAllModules/MapAllModuleEndpoints): -->
<PackageReference Include="ModulusKit.Generators" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<!-- In projects where the MOD001–MOD005 rules should run: -->
<PackageReference Include="ModulusKit.Analyzers" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />Solutions scaffolded by modulus init / modulus add-module come with this wiring already in place.
Contributions are welcome! Please open an issue or pull request on GitHub.
- Fork the repository
- Create a feature branch (
git checkout -b feature/my-feature) - Make your changes and add tests
- Run the test suite (
dotnet test) - Submit a pull request
This project is licensed under the MIT License.