Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions .claude/skills/minimal-api/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ description: >
CrudEndpoints helpers, Inertia.Render, or form handling. Triggers on: "add endpoint",
"create endpoint", "new endpoint", "API endpoint", "view endpoint", "MapGet", "MapPost",
"MapPut", "MapDelete", "Inertia.Render", route handling, parameter binding questions,
or any work touching files in Endpoints/ or Views/ directories.
or any work touching files in Endpoints/ or Pages/ directories.
---

# SimpleModule Minimal API Endpoints
Expand All @@ -25,7 +25,7 @@ Both implement `void Map(IEndpointRouteBuilder app)`. The source generator disco

One endpoint per file. Class name = `{Action}Endpoint`. Place in:
- `Endpoints/{Feature}/` for `IEndpoint`
- `Views/` for `IViewEndpoint`
- `Pages/` for `IViewEndpoint` (co-located with its `.tsx` component; optionally grouped in feature subfolders)

## Parameter Binding Rules

Expand Down Expand Up @@ -94,7 +94,7 @@ app.MapPost("/", async ([FromForm] string name, [FromForm] decimal price, ISvc s

**Critical**: Every `IViewEndpoint` with `Inertia.Render("Module/Page", ...)` MUST have a matching entry in `Pages/index.ts`. Run `npm run validate-pages` to verify.

**Note**: CA1812 ("internal class never instantiated") is suppressed via `.editorconfig` for `Endpoints/` and `Views/` directories. No `[SuppressMessage]` attributes needed on payload classes.
**Note**: CA1812 ("internal class never instantiated") is suppressed via `.editorconfig` for `Endpoints/` and `Pages/` (`*Endpoint.cs`) directories. No `[SuppressMessage]` attributes needed on payload classes.

## Authorization

Expand All @@ -113,19 +113,28 @@ Note: `.RequireAuthorization()` is already applied to the route group by the sou

## Validation

Use validator classes for complex validation, inline checks for simple cases:
Use [FluentValidation](https://docs.fluentvalidation.net/). Define a `sealed` validator extending `AbstractValidator<T>`, register it once per module with `services.AddValidatorsFromAssemblyContaining<ThisModule>()` in `ConfigureServices`, then inject `IValidator<TRequest>` into the endpoint handler:

```csharp
// Validator class pattern
var validation = CreateRequestValidator.Validate(request);
if (!validation.IsValid)
throw new ValidationException(validation.Errors);

// Simple inline
if (string.IsNullOrWhiteSpace(request.Name))
throw new ArgumentException("Name is required.", nameof(request));
// Validator (lives next to the request DTO or in a Validators/ folder)
public sealed class CreateRequestValidator : AbstractValidator<CreateRequest>
{
public CreateRequestValidator() =>
RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required.");
}

// In the endpoint lambda (async — inject IValidator<TRequest>)
async (CreateRequest request, IValidator<CreateRequest> validator, IThingContracts contracts) =>
{
var validation = await validator.ValidateAsync(request);
if (!validation.IsValid)
throw new Core.Exceptions.ValidationException(validation.ToValidationErrors());
// ...
}
```

`ToValidationErrors()` (in `SimpleModule.Core.Validation`) converts FluentValidation's `ValidationResult` into the `Dictionary<string, string[]>` that `ValidationException` and the RFC 7807 response writer expect. For trivial guards, a plain `ArgumentException` is still fine.

## Response Types

Prefer `TypedResults.*` for OpenAPI documentation:
Expand Down
31 changes: 20 additions & 11 deletions .claude/skills/minimal-api/references/patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,17 @@ public class CreateEndpoint : IEndpoint
public void Map(IEndpointRouteBuilder app) =>
app.MapPost(
"/",
(CreateProductRequest request, IProductContracts productContracts) =>
async (
CreateProductRequest request,
IValidator<CreateProductRequest> validator,
IProductContracts productContracts
) =>
{
var validation = CreateRequestValidator.Validate(request);
var validation = await validator.ValidateAsync(request);
if (!validation.IsValid)
throw new ValidationException(validation.Errors);
throw new Core.Exceptions.ValidationException(validation.ToValidationErrors());

return CrudEndpoints.Create(
return await CrudEndpoints.Create(
() => productContracts.CreateProductAsync(request),
p => $"{ProductsConstants.RoutePrefix}/{p.Id}"
);
Expand All @@ -78,13 +82,18 @@ public class UpdateEndpoint : IEndpoint
public void Map(IEndpointRouteBuilder app) =>
app.MapPut(
"/{id}",
(ProductId id, UpdateProductRequest request, IProductContracts productContracts) =>
async (
ProductId id,
UpdateProductRequest request,
IValidator<UpdateProductRequest> validator,
IProductContracts productContracts
) =>
{
var validation = UpdateRequestValidator.Validate(request);
var validation = await validator.ValidateAsync(request);
if (!validation.IsValid)
throw new ValidationException(validation.Errors);
throw new Core.Exceptions.ValidationException(validation.ToValidationErrors());

return CrudEndpoints.Update(() =>
return await CrudEndpoints.Update(() =>
productContracts.UpdateProductAsync(id, request)
);
}
Expand Down Expand Up @@ -142,7 +151,7 @@ public class CreateEndpoint : IViewEndpoint

## Payload Transformation

When the frontend sends a different shape than the domain request, use a private payload class. CA1812 is globally suppressed in `.editorconfig`, so no `[SuppressMessage]` is needed.
When the frontend sends a different shape than the domain request, use a private payload class. CA1812 ("internal class never instantiated") is suppressed in `.editorconfig` for `Endpoints/` and `Pages/*Endpoint.cs` files, so a payload class defined inside an endpoint file needs no `[SuppressMessage]`.

```csharp
public class CreateEndpoint : IViewEndpoint
Expand Down Expand Up @@ -347,10 +356,10 @@ app.MapDelete("/{id}", (PageId id, IPageBuilderContracts contracts) => ...)

## Permission Constants

Define in the module's constants class:
Define as a `sealed` class implementing `IModulePermissions` in the Contracts assembly (the generator auto-discovers it; SM0032 fails the build if it isn't sealed):

```csharp
public static class ProductsPermissions
public sealed class ProductsPermissions : IModulePermissions
{
public const string Create = "Products.Create";
public const string View = "Products.View";
Expand Down
6 changes: 4 additions & 2 deletions .claude/skills/new-module/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,15 @@ Create `modules/<Name>/src/SimpleModule.<Name>/`:

**SimpleModule.<Name>.csproj:**
```xml
<Project Sdk="Microsoft.NET.Sdk.Razor">
<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" />
<!-- Add only if the module owns a DbContext:
<ProjectReference Include="..\..\..\..\framework\SimpleModule.Database\SimpleModule.Database.csproj" /> -->
<ProjectReference Include="..\SimpleModule.<Name>.Contracts\SimpleModule.<Name>.Contracts.csproj" />
</ItemGroup>
</Project>
Expand Down Expand Up @@ -139,7 +141,7 @@ Run `dotnet build` to confirm the source generator discovers the new module.
## Important Constraints

- Contracts project uses `Microsoft.NET.Sdk` (plain library)
- Implementation project uses `Microsoft.NET.Sdk.Razor` (ASP.NET + Razor)
- Implementation project uses `Microsoft.NET.Sdk.StaticWebAssets` (ASP.NET + the module's static frontend bundle)
- Implementation MUST include `<FrameworkReference Include="Microsoft.AspNetCore.App" />`
- Module class MUST use `[Module]` attribute and implement `IModule`
- Constants go in the Contracts project (shared dependency)
Expand Down
74 changes: 43 additions & 31 deletions .claude/skills/simplemodule/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ description: >
"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", "IEventBus", "SettingDefinition".
"module not found", "page 404", "event bus", "settings", "IMessageBus", "Wolverine", "SettingDefinition".
---

# SimpleModule Framework Guide
Expand All @@ -25,9 +25,9 @@ SimpleModule is a **modular monolith** for .NET 10 with compile-time module disc
```
modules/{Name}/
src/SimpleModule.{Name}.Contracts/ # Public API (interfaces, DTOs, events, constants)
src/SimpleModule.{Name}/ # Implementation (module class, services, endpoints, views)
src/SimpleModule.{Name}/ # Implementation (module class, services, endpoints, pages)
tests/SimpleModule.{Name}.Tests/ # Tests (unit + integration)
framework/ # Core, Database, Generator, Hosting, Blazor, Storage
framework/ # Core, Database, Generator, Hosting, Storage(.Local/.Azure/.S3), Testing
template/SimpleModule.Host/ # Host app calling generated extension methods
```

Expand All @@ -44,7 +44,7 @@ Every module has two assemblies plus optional tests:
- `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 implementing `IEvent`
- Domain events inheriting `DomainEvent` (marker interface `IEvent`, both in `SimpleModule.Core.Events`)
- Permission constants implementing `IModulePermissions`

**Project file pattern:**
Expand All @@ -66,22 +66,23 @@ Every module has two assemblies plus optional tests:
- `{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/{Name}/` — API endpoint classes implementing `IEndpoint`
- `Views/` — view endpoint classes implementing `IViewEndpoint` + React `.tsx` components
- `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:**
```xml
<Project Sdk="Microsoft.NET.Sdk.Razor">
<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="..\..\..\..\framework\SimpleModule.Database\SimpleModule.Database.csproj" />
<!-- Add only if the module owns a DbContext:
<ProjectReference Include="..\..\..\..\framework\SimpleModule.Database\SimpleModule.Database.csproj" /> -->
<ProjectReference Include="..\SimpleModule.{Name}.Contracts\SimpleModule.{Name}.Contracts.csproj" />
</ItemGroup>
</Project>
Expand Down Expand Up @@ -136,9 +137,13 @@ public class {Name}Module : IModule
| `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)` | Health status reporting |
| `CheckHealthAsync(CancellationToken)` | Per-module health status (`Task<ModuleHealthStatus>`) |

## Endpoint Patterns

Expand All @@ -165,7 +170,7 @@ See [references/cross-module.md](references/cross-module.md) for events, contrac
When adding a new module, ensure:

1. Contracts project created with constants, interface, and DTOs
2. Implementation project uses `Microsoft.NET.Sdk.Razor` with `<FrameworkReference Include="Microsoft.AspNetCore.App" />`
2. Implementation project uses `Microsoft.NET.Sdk.StaticWebAssets` with `<FrameworkReference Include="Microsoft.AspNetCore.App" />`
3. Module class decorated with `[Module]` and implements `IModule`
4. `ProjectReference` added to `template/SimpleModule.Host/SimpleModule.Host.csproj`
5. Both projects added to `SimpleModule.slnx` under `/modules/{Name}/` folder
Expand All @@ -178,68 +183,75 @@ When adding a new module, ensure:
| 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('../Views/Page')` to Pages/index.ts |
| 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 |
| `TreatWarningsAsErrors` build failure | Nullable, unused variable, or analyzer warning | Fix the warning; suppress in `.editorconfig` only if genuinely intentional |
| Event handler never called | Handler not registered in DI | Add `services.AddScoped<IEventHandler<MyEvent>, MyHandler>()` in `ConfigureServices` |
| 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

Define events in the **Contracts** assembly as `record` types:
Cross-module events use [Wolverine](https://wolverinefx.net/) for in-process messaging. Define events in the **Contracts** assembly as `record` types inheriting `DomainEvent` (which supplies `EventId` and `OccurredAt`):

```csharp
// In SimpleModule.{Name}.Contracts
public record OrderPlaced(OrderId OrderId, decimal Total) : IEvent;
using SimpleModule.Core.Events;

public sealed record OrderPlaced(OrderId OrderId, decimal Total) : DomainEvent;
```

Publish from a service:
Publish by injecting Wolverine's `IMessageBus`:

```csharp
// Awaits all handlers before returning
await _eventBus.PublishAsync(new OrderPlaced(order.Id, order.Total), ct);
using Wolverine;

// Fire-and-forget (background task, not awaited)
_eventBus.PublishInBackground(new OrderPlaced(order.Id, order.Total));
public class OrderService(OrdersDbContext db, IMessageBus bus)
{
public async Task PlaceAsync(OrderId id, decimal total)
{
// Fire-and-forget: enqueues on the local queue, returns immediately.
await bus.PublishAsync(new OrderPlaced(id, total));

// Or run inline and await the handlers, propagating the first failure:
// await bus.InvokeAsync(new OrderPlaced(id, total));
}
}
```

Handle in any module:
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):

```csharp
public sealed class SendConfirmationEmailHandler : IEventHandler<OrderPlaced>
public class OrderPlacedHandler
{
public async Task HandleAsync(OrderPlaced evt, CancellationToken ct)
public Task Handle(OrderPlaced evt, IEmailContracts email, CancellationToken ct)
{
// use evt.OrderId, evt.Total
return Task.CompletedTask;
}
}
```

Register in `ConfigureServices`:

```csharp
services.AddScoped<IEventHandler<OrderPlaced>, SendConfirmationEmailHandler>();
```
**No DI registration needed** — Wolverine scans loaded assemblies at startup. Non-conventional classes can opt in with `[WolverineHandler]`.

**Semantics:** Handlers run sequentially. A failing handler throws `AggregateException`. Write handlers to be idempotent.
**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](references/cross-module.md) for retry/error-queue policies.

## Settings Pattern

Register setting definitions in `ConfigureSettings` on the module class:

```csharp
public void ConfigureSettings(ISettingsBuilder builder)
public void ConfigureSettings(ISettingsBuilder settings)
{
builder.AddDefinition(new SettingDefinition
settings.Add(new SettingDefinition
{
Key = "Orders.MaxItemsPerOrder",
DisplayName = "Max Items Per Order",
Group = "Orders",
Scope = SettingScope.Application, // System | Application | User
Type = SettingType.Number, // Text | Number | Bool | Json
Type = SettingType.Number, // Text | Number | Bool | Json | Select | Color | Url | Email | Password | MultilineText | DateTime
DefaultValue = "50",
});
}
Expand Down
12 changes: 7 additions & 5 deletions .claude/skills/simplemodule/references/cross-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,12 @@ messaging; inject `IMessageBus` from `Wolverine`.
### Define Events (in Contracts)

```csharp
public record OrderCreatedEvent(OrderId OrderId, UserId UserId, decimal Total) : IEvent;
public sealed record OrderCreatedEvent(OrderId OrderId, UserId UserId, decimal Total) : DomainEvent;
```

`IEvent` is a marker interface in `SimpleModule.Core.Events` — it costs
nothing and makes "this type is a domain event" visible at the type level.
`DomainEvent` (in `SimpleModule.Core.Events`) is the base record for events — it
implements the `IEvent` marker and supplies `EventId` and `OccurredAt` so
Wolverine's durable inbox can deduplicate redelivery.

### Publish Events

Expand Down Expand Up @@ -185,11 +186,12 @@ public void ConfigureMenu(IMenuBuilder menus)
For operations that can fail without exceptions:

```csharp
// _validator is an injected IValidator<CreateProductRequest>
public async Task<Result<Product>> TryCreateAsync(CreateProductRequest request)
{
var validation = CreateRequestValidator.Validate(request);
var validation = await _validator.ValidateAsync(request);
if (!validation.IsValid)
return Result<Product>.Fail("Validation failed", validation.Errors);
return Result<Product>.Fail("Validation failed", validation.ToValidationErrors());

var product = await CreateProductAsync(request);
return Result<Product>.Ok(product);
Expand Down
Loading
Loading