| name | simplemodule |
| description | Comprehensive guide for working with the SimpleModule modular monolith framework. Use when creating modules, adding features, writing endpoints, configuring services, working with the event bus, permissions, menus, settings, database contexts, or understanding the project architecture. Triggers on: "add module", "new module", "add feature", "create endpoint", "module architecture", "how does SimpleModule work", "event bus", "permissions", "menu", "settings", "database context", "contracts", "IModule", "IEndpoint", "IViewEndpoint", "Inertia", "CrudEndpoints", "debug module", "review module", "module status", "SM00", "source generator diagnostic", "module not found", "page 404", "event bus", "settings", "IMessageBus", "Wolverine", "SettingDefinition", "policy", "IPolicy", "IAuthorizer", "entity-level authorization", "FormRequest", "Route const".
|
SimpleModule Framework Guide
Architecture Overview
SimpleModule is a modular monolith for .NET 10 with compile-time module discovery via Roslyn source generators. Frontend uses React 19 + Inertia.js served via a static HTML shell.
Key principle: Single deployment, shared database, compile-time safety over runtime discipline, convention over configuration.
Project Layout
modules/{Name}/
src/SimpleModule.{Name}.Contracts/ # Public API (interfaces, DTOs, events, constants)
src/SimpleModule.{Name}/ # Implementation (module class, services, endpoints, pages)
tests/SimpleModule.{Name}.Tests/ # Tests (unit + integration)
framework/ # Core, Database, Generator, Hosting, Storage(.Local/.Azure/.S3), Testing
template/SimpleModule.Host/ # Host app calling generated extension methods
Module Anatomy
Every module has two assemblies plus optional tests:
1. Contracts Assembly (SimpleModule.{Name}.Contracts)
Purpose: Public API that other modules can depend on. Never expose internals.
Contains:
{Name}Constants.cs — module name and route prefix
I{Name}Contracts.cs — service interface for cross-module use
- DTOs marked with
[Dto] — auto-generates TypeScript interfaces
- Value objects using Vogen (
[ValueObject<int>]) for type-safe IDs
- Domain events inheriting
DomainEvent (marker interface IEvent, both in SimpleModule.Core.Events)
- Permission constants implementing
IModulePermissions
Project file pattern:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\framework\SimpleModule.Core\SimpleModule.Core.csproj" />
</ItemGroup>
</Project>
2. Implementation Assembly (SimpleModule.{Name})
Contains:
{Name}Module.cs — implements IModule, decorated with [Module]
{Name}ContractsService.cs — implements I{Name}Contracts
{Name}DbContext.cs — EF Core context (if module has data)
Endpoints/{Feature}/ — API endpoint classes implementing IEndpoint
Pages/ — view endpoint classes implementing IViewEndpoint, co-located with their React .tsx components (optionally grouped in feature subfolders)
Pages/index.ts — page registry mapping route names to React components
EntityConfigurations/ — EF Core entity configurations
vite.config.ts and package.json — frontend build config
Project file pattern:
<Project Sdk="Microsoft.NET.Sdk.StaticWebAssets">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\..\..\..\framework\SimpleModule.Core\SimpleModule.Core.csproj" />
<ProjectReference Include="..\SimpleModule.{Name}.Contracts\SimpleModule.{Name}.Contracts.csproj" />
</ItemGroup>
</Project>
Module Class Pattern
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SimpleModule.Core;
using SimpleModule.Core.Menu;
using SimpleModule.{Name}.Contracts;
namespace SimpleModule.{Name};
[Module({Name}Constants.ModuleName, RoutePrefix = {Name}Constants.RoutePrefix, ViewPrefix = "/{name}")]
public class {Name}Module : IModule
{
public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
services.AddScoped<I{Name}Contracts, {Name}ContractsService>();
services.AddModuleDbContext<{Name}DbContext>(configuration, {Name}Constants.ModuleName);
}
public void ConfigureMenu(IMenuBuilder menus)
{
menus.Add(new MenuItem
{
Label = "{Name}",
Url = "/{name}",
Icon = """<svg class="w-4 h-4" ...>...</svg>""",
Order = 50,
Section = MenuSection.AppSidebar,
});
}
public void ConfigurePermissions(PermissionRegistryBuilder builder)
{
builder.AddPermissions<{Name}Permissions>();
}
}
IModule Lifecycle Hooks (all optional)
| Method | Purpose |
|---|
ConfigureServices(IServiceCollection, IConfiguration) | DI registration |
ConfigureEndpoints(IEndpointRouteBuilder) | Escape hatch for non-standard routes |
ConfigureMiddleware(IApplicationBuilder) | Module-specific middleware |
ConfigureMenu(IMenuBuilder) | Navigation items |
ConfigurePermissions(PermissionRegistryBuilder) | Permission definitions |
ConfigureSettings(ISettingsBuilder) | Application settings |
ConfigureFeatureFlags(IFeatureFlagBuilder) | Feature flag definitions |
ConfigureAgents(IAgentBuilder) | AI agent registrations |
ConfigureRateLimits(IRateLimitBuilder) | Rate-limit policies |
ConfigureHost(IHost) | Host-level integration (e.g. DB init) after services are built |
OnStartAsync(CancellationToken) | Startup initialization |
OnStopAsync(CancellationToken) | Graceful shutdown cleanup |
CheckHealthAsync(CancellationToken) | Per-module health status (Task<ModuleHealthStatus>) |
Endpoint Patterns
Every endpoint (IEndpoint / IViewEndpoint) declares a public const string Route and passes it to its MapXxx call (enforced by SM0054); one endpoint class per file (SM0049). Form posts bind a [FormRequest] class — sealed partial, extending FormRequest<TSelf> (SM0056 / SM0057) — directly as a handler parameter, with validation auto-run before the handler.
See references/endpoints.md for complete endpoint patterns.
Database & Data Access
See references/database.md for database patterns.
Frontend Integration
See references/frontend.md for React/Inertia patterns.
Testing
See references/testing.md for test patterns.
Cross-Module Communication
See references/cross-module.md for events, contracts, and permissions.
Integration Checklist
When adding a new module, ensure:
- Contracts project created with constants, interface, and DTOs
- Implementation project uses
Microsoft.NET.Sdk.StaticWebAssets with <FrameworkReference Include="Microsoft.AspNetCore.App" />
- Module class decorated with
[Module] and implements IModule
ProjectReference added to template/SimpleModule.Host/SimpleModule.Host.csproj
- Both projects added to
SimpleModule.slnx under /modules/{Name}/ folder
- Every
IViewEndpoint with Inertia.Render() has a matching entry in Pages/index.ts
- Run
dotnet build to verify source generator picks up the module
- Run
npm run validate-pages to verify page registry matches endpoints
Common Pitfalls
| Symptom | Cause | Fix |
|---|
| Module not discovered at build | Missing [Module] attribute or wrong project not referenced in Host | Add [Module] to module class; add <ProjectReference> to Host .csproj |
| Page navigates but shows blank | Missing Pages/index.ts entry | Add 'Module/Page': () => import('./Page') to Pages/index.ts |
| SM0011 diagnostic | Direct impl→impl project reference | Reference only the .Contracts project; inject I{Name}Contracts interface |
| SM0014 diagnostic | No public interfaces in Contracts assembly | Add I{Name}Contracts to the Contracts project |
| SM0025 diagnostic | No implementation for contract interface | Create {Name}ContractsService implementing I{Name}Contracts and register in ConfigureServices |
| SM0041/SM0042 diagnostic | View endpoint misconfigured | Ensure Inertia.Render name is prefixed with module name; add ViewPrefix to [Module] attribute |
| SM0054 diagnostic | Endpoint has no Route const | Add public const string Route = {Name}Constants.Routes.X; and pass Route to the MapXxx call |
TreatWarningsAsErrors build failure | Nullable, unused variable, or analyzer warning | Fix the warning; suppress in .editorconfig only if genuinely intentional |
| Event handler never called | Class/method doesn't match Wolverine's convention | Name the class *Handler/*Consumer with a Handle/Consume method (first param = the event); Wolverine auto-discovers it — no DI registration |
| Cross-module data wrong | Injecting impl class directly | Always inject I{Name}Contracts interface, never the concrete service class |
Events Pattern
Cross-module events use Wolverine for in-process messaging. Define events in the Contracts assembly as record types inheriting DomainEvent (which supplies EventId and OccurredAt):
using SimpleModule.Core.Events;
public sealed record OrderPlaced(OrderId OrderId, decimal Total) : DomainEvent;
Publish by injecting Wolverine's IMessageBus:
using Wolverine;
public class OrderService(OrdersDbContext db, IMessageBus bus)
{
public async Task PlaceAsync(OrderId id, decimal total)
{
await bus.PublishAsync(new OrderPlaced(id, total));
}
}
Handle in any module — Wolverine auto-discovers handlers by convention (class name ending in Handler/Consumer, method named Handle/HandleAsync/Consume/ConsumeAsync, first parameter is the event, remaining parameters resolved from DI):
public class OrderPlacedHandler
{
public Task Handle(OrderPlaced evt, IEmailContracts email, CancellationToken ct)
{
return Task.CompletedTask;
}
}
No DI registration needed — Wolverine scans loaded assemblies at startup. Non-conventional classes can opt in with [WolverineHandler].
Semantics: Wolverine routes by the message's runtime type; each handler chain runs in its own scope with independent exception isolation. Make handlers idempotent — messages may be re-run. See references/cross-module.md for retry/error-queue policies.
Settings Pattern
Register setting definitions in ConfigureSettings on the module class:
public void ConfigureSettings(ISettingsBuilder settings)
{
settings.Add(new SettingDefinition
{
Key = "Orders.MaxItemsPerOrder",
DisplayName = "Max Items Per Order",
Group = "Orders",
Scope = SettingScope.Application,
Type = SettingType.Number,
DefaultValue = "50",
});
}
Read a setting in a service:
var max = await _settings.GetSettingAsync<int>("Orders.MaxItemsPerOrder", SettingScope.Application);
Scopes: System = application-wide (e.g., feature flags), Application = per tenant/deployment, User = per authenticated user.
Policies (instance-level authorization)
String permissions (.RequirePermission(...)) are the coarse capability gate. Policies add per-resource rules — ownership, tenancy, state-machine checks. Declare a public sealed class {Name}Policy : IPolicy<{Resource}Dto> in the module that owns the resource; the source generator auto-discovers and registers it as a scoped service (no manual registration).
using SimpleModule.Core.Authorization.Policies;
public sealed class ProductPolicy : IPolicy<ProductDto>
{
public Task<AuthorizationResult> AuthorizeAsync(
ClaimsPrincipal user, string action, ProductDto resource, CancellationToken ct = default)
{
var result = action switch
{
PolicyActions.Update or PolicyActions.Delete =>
resource.OwnerId == user.GetUserId()
? AuthorizationResult.Allow()
: AuthorizationResult.Deny("You can only modify your own products."),
PolicyActions.View => AuthorizationResult.Allow(),
_ => AuthorizationResult.Deny($"Unknown action '{action}'."),
};
return Task.FromResult(result);
}
}
Check it from an endpoint or service by injecting IAuthorizer and calling AuthorizeAsync(user, PolicyActions.Update, resource) (throws ForbiddenException, or NotFoundException for anti-enumeration via AuthorizationResult.DenyAsNotFound(...)), or CheckAsync(...) for a non-throwing AuthorizationResult. Semantics are deny-wins: every registered policy must allow; the first deny short-circuits.
Diagnostics: resource type must be a contracts [Dto] (SM0058), policy class must be public (SM0059) and owned by the resource's module (SM0060), and must not be generic (SM0061).
Constitution Diagnostics (SM00xx)
The source generator enforces these rules at build time. Run /debug-module {Name} to check all at once. Codes currently span SM0001–SM0061 (non-contiguous); the most commonly hit are:
| Code | Rule | Common cause |
|---|
| SM0001 | No duplicate DbSet property names across modules | Two modules declare a DbSet with the same property name |
| SM0007 | No duplicate entity configurations | Registered the same IEntityTypeConfiguration<T> twice |
| SM0010 | No circular module dependencies | Contract projects reference each other in a cycle |
| SM0011 | No direct module-to-module implementation references | Took a shortcut referencing another module's impl project |
| SM0014 | Contracts assembly has no public interfaces | Forgot to add I{Name}Contracts to the Contracts project |
| SM0015 | No duplicate view page names | Two IViewEndpoints return the same Inertia component name |
| SM0025 | No implementation found for contract interface | Forgot to create the {Name}ContractsService class |
| SM0032 | Permission class must be sealed | Permissions class is not sealed |
| SM0040 | No duplicate module names | Two modules share the same [Module] name string |
| SM0041 | View page name must be prefixed with module name | Inertia component name doesn't start with the module name |
| SM0042 | ViewPrefix required when module has view endpoints | Module has IViewEndpoints but no ViewPrefix on [Module] |
| SM0043 | Module must override at least one IModule method | Empty or placeholder module class |
| SM0049 | One endpoint class per file | Two endpoint classes declared in the same .cs file |
| SM0054 | Endpoint missing Route const | Endpoint has no public const string Route field (Info) |
| SM0056 | FormRequest class must be sealed | [FormRequest] class is not sealed |
| SM0057 | FormRequest must extend FormRequest<TSelf> | [FormRequest] class has the wrong base type |
| SM0058–SM0061 | Policy class rules | Resource not a [Dto], policy not public, policy in foreign module, or generic policy |
For the full list of diagnostics, see docs/CONSTITUTION.md.
Key Constraints
- Source generator targets netstandard2.0 with
IIncrementalGenerator
- Modules need
<FrameworkReference Include="Microsoft.AspNetCore.App" />
- Module Vite builds use library mode — externalize React, React-DOM, @inertiajs/react
- TreatWarningsAsErrors is enabled globally
- File-scoped namespaces (error), usings outside namespace (error), prefer
var
- Naming: interfaces
IFoo, public PascalCase, private fields _camelCase