From 44e6bc8aa6017f84c6ce258aa57104ffe0558404 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 8 Jun 2026 16:57:43 +0200 Subject: [PATCH] docs: sync skill files and CLAUDE.md with current framework Audit the repo's Claude skill docs against the live codebase and correct drift accumulated since they were last touched. Framework API updates: - Event bus -> Wolverine: IMessageBus, DomainEvent base record, convention handlers (replaces removed IEventBus/IEventHandler). - Validation -> FluentValidation: injected IValidator, ValidateAsync, ToValidationErrors(), Core.Exceptions.ValidationException. - Views/ -> Pages/: IViewEndpoint co-located with its .tsx component. - Module impl csproj Microsoft.NET.Sdk.Razor -> Microsoft.NET.Sdk.StaticWebAssets, with SimpleModule.Database referenced only when the module owns a DbContext. - IModule hooks (ConfigureFeatureFlags/Agents/RateLimits/Host, CheckHealthAsync), settings builder .Add(), full SettingType enum, sealed IModulePermissions. CLAUDE.md "Adding a New Module": - SimpleModule.[.Contracts|.Tests] project naming, StaticWebAssets SDK, Endpoints/Pages feature dirs, Wolverine IMessageBus in the Core description. Accuracy fixes from self-review: - Correct framework/ directory list (drop non-existent DevTools/Agents/AI/Rag). - Pages feature subfolder is optional; import paths must match file location to avoid silent client-side 404s. - CA1812 is suppressed per-directory (Endpoints/, Pages/*Endpoint.cs), not global. - Show the DbContext ProjectReference as conditional. --- .claude/skills/minimal-api/SKILL.md | 33 ++++++--- .../skills/minimal-api/references/patterns.md | 31 +++++--- .claude/skills/new-module/SKILL.md | 6 +- .claude/skills/simplemodule/SKILL.md | 74 +++++++++++-------- .../simplemodule/references/cross-module.md | 12 +-- .../simplemodule/references/endpoints.md | 18 ++--- .../simplemodule/references/frontend.md | 10 +-- .../skills/simplemodule/references/testing.md | 9 ++- CLAUDE.md | 15 ++-- 9 files changed, 123 insertions(+), 85 deletions(-) diff --git a/.claude/skills/minimal-api/SKILL.md b/.claude/skills/minimal-api/SKILL.md index 3cce6bd5..c8687d53 100644 --- a/.claude/skills/minimal-api/SKILL.md +++ b/.claude/skills/minimal-api/SKILL.md @@ -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 @@ -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 @@ -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 @@ -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`, register it once per module with `services.AddValidatorsFromAssemblyContaining()` in `ConfigureServices`, then inject `IValidator` 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 +{ + public CreateRequestValidator() => + RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required."); +} + +// In the endpoint lambda (async — inject IValidator) +async (CreateRequest request, IValidator 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` 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: diff --git a/.claude/skills/minimal-api/references/patterns.md b/.claude/skills/minimal-api/references/patterns.md index 5a13c716..ff020632 100644 --- a/.claude/skills/minimal-api/references/patterns.md +++ b/.claude/skills/minimal-api/references/patterns.md @@ -55,13 +55,17 @@ public class CreateEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapPost( "/", - (CreateProductRequest request, IProductContracts productContracts) => + async ( + CreateProductRequest request, + IValidator 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}" ); @@ -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 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) ); } @@ -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 @@ -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"; diff --git a/.claude/skills/new-module/SKILL.md b/.claude/skills/new-module/SKILL.md index 5b837938..aa6cac8e 100644 --- a/.claude/skills/new-module/SKILL.md +++ b/.claude/skills/new-module/SKILL.md @@ -58,13 +58,15 @@ Create `modules//src/SimpleModule./`: **SimpleModule..csproj:** ```xml - + net10.0 + @@ -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 `` - Module class MUST use `[Module]` attribute and implement `IModule` - Constants go in the Contracts project (shared dependency) diff --git a/.claude/skills/simplemodule/SKILL.md b/.claude/skills/simplemodule/SKILL.md index 0a8d8a42..cc81719c 100644 --- a/.claude/skills/simplemodule/SKILL.md +++ b/.claude/skills/simplemodule/SKILL.md @@ -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 @@ -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 ``` @@ -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]`) 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:** @@ -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 - + net10.0 - + @@ -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`) | ## Endpoint Patterns @@ -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 `` +2. Implementation project uses `Microsoft.NET.Sdk.StaticWebAssets` with `` 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 @@ -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 `` 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, 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 +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, 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", }); } diff --git a/.claude/skills/simplemodule/references/cross-module.md b/.claude/skills/simplemodule/references/cross-module.md index 121359e5..bfccff52 100644 --- a/.claude/skills/simplemodule/references/cross-module.md +++ b/.claude/skills/simplemodule/references/cross-module.md @@ -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 @@ -185,11 +186,12 @@ public void ConfigureMenu(IMenuBuilder menus) For operations that can fail without exceptions: ```csharp +// _validator is an injected IValidator public async Task> TryCreateAsync(CreateProductRequest request) { - var validation = CreateRequestValidator.Validate(request); + var validation = await _validator.ValidateAsync(request); if (!validation.IsValid) - return Result.Fail("Validation failed", validation.Errors); + return Result.Fail("Validation failed", validation.ToValidationErrors()); var product = await CreateProductAsync(request); return Result.Ok(product); diff --git a/.claude/skills/simplemodule/references/endpoints.md b/.claude/skills/simplemodule/references/endpoints.md index ddc1bbfa..fccb4c19 100644 --- a/.claude/skills/simplemodule/references/endpoints.md +++ b/.claude/skills/simplemodule/references/endpoints.md @@ -13,7 +13,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) ## API Endpoints with CrudEndpoints Helper @@ -40,11 +40,11 @@ public class GetByIdEndpoint : IEndpoint public class CreateEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder app) => - app.MapPost("/", (CreateProductRequest request, IProductContracts contracts) => + app.MapPost("/", async (CreateProductRequest request, IValidator validator, IProductContracts contracts) => { - var validation = CreateRequestValidator.Validate(request); - if (!validation.IsValid) throw new ValidationException(validation.Errors); - return CrudEndpoints.Create( + var validation = await validator.ValidateAsync(request); + if (!validation.IsValid) throw new Core.Exceptions.ValidationException(validation.ToValidationErrors()); + return await CrudEndpoints.Create( () => contracts.CreateProductAsync(request), p => $"{ProductsConstants.RoutePrefix}/{p.Id}"); }) @@ -55,11 +55,11 @@ public class CreateEndpoint : IEndpoint public class UpdateEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder app) => - app.MapPut("/{id}", (ProductId id, UpdateProductRequest request, IProductContracts contracts) => + app.MapPut("/{id}", async (ProductId id, UpdateProductRequest request, IValidator validator, IProductContracts contracts) => { - var validation = UpdateRequestValidator.Validate(request); - if (!validation.IsValid) throw new ValidationException(validation.Errors); - return CrudEndpoints.Update(() => contracts.UpdateProductAsync(id, request)); + var validation = await validator.ValidateAsync(request); + if (!validation.IsValid) throw new Core.Exceptions.ValidationException(validation.ToValidationErrors()); + return await CrudEndpoints.Update(() => contracts.UpdateProductAsync(id, request)); }) .RequirePermission(ProductsPermissions.Update); } diff --git a/.claude/skills/simplemodule/references/frontend.md b/.claude/skills/simplemodule/references/frontend.md index ac1db0ed..de375817 100644 --- a/.claude/skills/simplemodule/references/frontend.md +++ b/.claude/skills/simplemodule/references/frontend.md @@ -14,10 +14,10 @@ Every `IViewEndpoint` with `Inertia.Render()` **must** have a matching entry in ```typescript // modules/{Name}/src/SimpleModule.{Name}/Pages/index.ts export const pages: Record = { - '{Name}/Browse': () => import('../Views/Browse'), - '{Name}/Manage': () => import('../Views/Manage'), - '{Name}/Create': () => import('../Views/Create'), - '{Name}/Edit': () => import('../Views/Edit'), + '{Name}/Browse': () => import('./Browse'), + '{Name}/Manage': () => import('./Manage'), + '{Name}/Create': () => import('./Create'), + '{Name}/Edit': () => import('./Edit'), }; ``` @@ -30,7 +30,7 @@ The key must match the first argument of `Inertia.Render()` exactly. If you forg ## React View Components ```tsx -// modules/{Name}/src/SimpleModule.{Name}/Views/Browse.tsx +// modules/{Name}/src/SimpleModule.{Name}/Pages/Browse.tsx import { PageShell } from '@simplemodule/ui/components'; import type { Product } from '../types'; diff --git a/.claude/skills/simplemodule/references/testing.md b/.claude/skills/simplemodule/references/testing.md index 71e6069c..0ba58854 100644 --- a/.claude/skills/simplemodule/references/testing.md +++ b/.claude/skills/simplemodule/references/testing.md @@ -102,21 +102,24 @@ public class ProductServiceTests : IDisposable ```csharp public class CreateRequestValidatorTests { + // FluentValidation validators are instantiated and called via the instance API + private readonly CreateRequestValidator _validator = new(); + [Fact] public void Validate_EmptyName_ReturnsError() { var request = new CreateProductRequest { Name = "", Price = 10 }; - var result = CreateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("Name"); + result.Errors.Should().Contain(e => e.PropertyName == "Name"); } [Fact] public void Validate_ValidRequest_ReturnsValid() { var request = new CreateProductRequest { Name = "Widget", Price = 5.99m }; - var result = CreateRequestValidator.Validate(request); + var result = _validator.Validate(request); result.IsValid.Should().BeTrue(); } diff --git a/CLAUDE.md b/CLAUDE.md index 11ac09e0..33307a11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,7 @@ HTTP load tests using real OAuth Bearer tokens acquired via ROPC (password grant ### .NET Backend -- **SimpleModule.Core** — `IModule` interface, `[Module]` attribute, `IEndpoint` interface, `[Dto]` attribute, menu system (`IMenuRegistry`), event bus (`IEventBus`), Inertia integration. +- **SimpleModule.Core** — `IModule` interface, `[Module]` attribute, `IEndpoint` interface, `[Dto]` attribute, menu system (`IMenuRegistry`), domain event base types (`IEvent`/`DomainEvent` in `SimpleModule.Core.Events`, dispatched in-process via Wolverine `IMessageBus`), Inertia integration. - **SimpleModule.Generator** — Roslyn `IIncrementalGenerator` (netstandard2.0). Scans referenced assemblies for `[Module]` classes, `IEndpoint` implementors, and `[Dto]` types. Generates: `AddModules()`, `MapModuleEndpoints()`, `CollectModuleMenuItems()`, JSON serializers, TypeScript interface definitions. - **SimpleModule.Host** — Host app (net10.0). Calls generated extension methods in `Program.cs`. Inertia middleware renders static HTML shell with embedded JSON props for React hydration. @@ -221,20 +221,21 @@ sm doctor [--fix] # validate project structure, auto-fix issues Use `sm new module ` (CLI) or manually: 1. Create `modules//` -2. Create `modules//src/.Contracts/` with: - - `.Contracts.csproj` (references Core only, `Microsoft.NET.Sdk`) +2. Create `modules//src/SimpleModule..Contracts/` with: + - `SimpleModule..Contracts.csproj` (`Microsoft.NET.Sdk`; references `framework/SimpleModule.Core` only) - `IContracts.cs` — public interface for cross-module use - Shared DTO types marked with `[Dto]` -3. Create `modules//src//` with: - - `.csproj` (references Core + Contracts; `Microsoft.NET.Sdk` with ``) +3. Create `modules//src/SimpleModule./` with: + - `SimpleModule..csproj` (`Microsoft.NET.Sdk.StaticWebAssets`; references Core + Contracts with ``; add a `SimpleModule.Database` reference only if the module owns a DbContext) - `Module.cs` — implements `IModule` with `[Module("Name", RoutePrefix = "...")]` - - `Endpoints//` — endpoint classes implementing `IEndpoint` (auto-discovered) + - `Endpoints//` — endpoint classes implementing `IEndpoint` (auto-discovered) + - `Pages/` — `IViewEndpoint` classes co-located with their React `.tsx` components (optionally grouped in feature subfolders) - `Pages/index.ts` — exports `pages` record mapping route names to React components - `vite.config.ts` — library mode build targeting `Pages/index.ts` - `package.json` — declare React/Inertia as peerDependencies - Register contract interface in `ConfigureServices` - **Escape hatch**: For non-standard routes, implement `ConfigureEndpoints` on the module class -4. Create `modules//tests/.Tests/` with xUnit test project +4. Create `modules//tests/SimpleModule..Tests/` with xUnit test project 5. Add `ProjectReference` to `template/SimpleModule.Host/SimpleModule.Host.csproj` 6. Add all projects to `SimpleModule.slnx`