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
203 changes: 10 additions & 193 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,20 @@ Test stack: xUnit.v3, FluentAssertions, Bogus, Microsoft.AspNetCore.Mvc.Testing.
3. React ClientApp dynamically imports module's `pages.js` bundle
4. Component hydrates with server-provided props

## Module Rules & Architecture

See [docs/CONSTITUTION.md](docs/CONSTITUTION.md) for the authoritative reference on:
- Module boundaries, dependencies, and data ownership
- Communication patterns (contracts and events)
- Endpoint, frontend, and authorization rules
- Compiler-enforced diagnostics (SM0001-SM0043)
- Framework contributor guidelines

## Key Constraints

- **Source generator must target netstandard2.0** with `IIncrementalGenerator` (not `ISourceGenerator`).
- Modules need `<FrameworkReference Include="Microsoft.AspNetCore.App" />`.
- **Module Vite builds use library mode** — externalize React, React-DOM, @inertiajs/react. Inline dynamic imports.
- **Module Vite builds use library mode** — externalize React, React-DOM, @inertiajs/react.
- **TreatWarningsAsErrors is enabled** globally via `Directory.Build.props` with `AnalysisLevel=latest-all` and `AnalysisMode=All`. Suppressed rules are listed in `.editorconfig`.

## C# Conventions (enforced by .editorconfig)
Expand All @@ -96,61 +105,6 @@ Test stack: xUnit.v3, FluentAssertions, Bogus, Microsoft.AspNetCore.Mvc.Testing.
- **Style**: File-scoped namespaces (error), usings outside namespace (error), prefer `var`.
- **Tests**: Underscore method names allowed (`Method_Scenario_Expected`). CA2234 (Uri overload) and xUnit1051 (CancellationToken) suppressed in test projects.

## Module Communication

- **Contracts pattern** — each module has a `.Contracts` project with a public interface (e.g., `IProductContracts`) and `[Dto]` types. Other modules depend on contracts, never implementations.
- **Event bus** — `IEventBus.PublishAsync<T>()` broadcasts to all `IEventHandler<T>` implementations. Handler failures are isolated (collected in `AggregateException`).

### Event Handler Patterns & Exception Isolation

The EventBus implements **partial success semantics**: all handlers execute sequentially in registration order, even if some fail. Exceptions are collected and rethrown as `AggregateException` after all handlers complete.

**Handler Execution Guarantees:**

- All registered `IEventHandler<T>` implementations execute for each event
- Handlers run in registration order
- If handler A throws, handler B still runs
- Side effects from successful handlers are preserved
- All exceptions are collected and thrown together as `AggregateException`

**Handler Implementation Best Practices:**

- **Stateless**: Avoid storing mutable state; handlers may be called concurrently in future versions
- **Independent**: Don't rely on side effects from other handlers; they may execute in any order or be skipped
- **No throwing for expected failures**: Use result types or early returns instead; thrown exceptions interrupt and must be handled by the caller
- **Idempotent when possible**: The same event may be reprocessed in retry scenarios; design handlers to handle duplicate calls gracefully
- **Avoid long-running work**: For expensive operations, use background jobs or a reliable message queue instead of synchronous handlers

**Exception Handling Pattern:**

```csharp
public sealed class AuditLogEventHandler : IEventHandler<OrderCreatedEvent>
{
public async Task HandleAsync(OrderCreatedEvent @event, CancellationToken cancellationToken)
{
try
{
// Log the event
await _logger.LogEventAsync(@event, cancellationToken);
}
catch (Exception ex)
{
// Don't throw: audit logging must never disrupt the primary operation
_logger.LogError(ex, "Failed to log event");
// Optionally: queue for retry, alert, etc.
}
}
}
```

**Testing Partial Failure:**
Use `EventBusPartialFailureTests` as a reference. Key scenarios:

- Verify successful handlers complete their work even if others fail
- Verify exceptions are collected and rethrown as `AggregateException`
- Verify handler execution order is preserved even when some fail
- Verify cancellation tokens propagate to all handlers

## Pages Registry Pattern (Pages/index.ts)

When you add a new `IViewEndpoint`, you **must** register it in your module's `Pages/index.ts` immediately. This is a manual, critical step.
Expand Down Expand Up @@ -203,55 +157,6 @@ sm new feature <name> # add feature to existing module
sm doctor [--fix] # validate project structure, auto-fix issues
```

## Database

- Multi-provider: SQLite (table prefixes), PostgreSQL/SQL Server (schemas per module).
- Each module registers `ModuleDbContextInfo` for schema isolation.
- Uses `EnsureCreated()` — for production migrations, use EF Core migrations per module.

### EF Core Interceptor DI Patterns

**Problem:** SaveChangesInterceptors registered in DI can cause circular dependencies when they depend on services that themselves depend on DbContext. For example: `SaveChangesInterceptor → ISettings → SettingsService → SettingsDbContext` creates a deadlock during construction.

**Solution:** Resolve runtime dependencies at interception time, not in the constructor.

**Pattern:**

```csharp
// CORRECT: Constructor has minimal dependencies (only required for interceptor itself)
public sealed class MyInterceptor(
IServiceProvider? serviceProvider = null // Injected via DI, nullable for optionality
) : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default
)
{
// Resolve optional services at interception time
var settings = serviceProvider?.GetService<ISettingsContracts>();
if (settings is not null)
{
// Use the service
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}

// WRONG: Constructor tries to resolve ISettingsContracts
public sealed class BadInterceptor(
ISettingsContracts settings // WRONG: Causes circular dependency!
) : SaveChangesInterceptor { }
```

**Key Guidelines:**

- Never inject services that transitively depend on DbContext into interceptor constructors
- Inject `IServiceProvider?` as an optional dependency if runtime service resolution is needed
- Resolve services only within `SavingChangesAsync`, `SavedChangesAsync`, or `SaveChangesFailed` methods
- The framework resolves all registered `ISaveChangesInterceptor` instances lazily during DbContext options construction

## Adding a New Module

Use `sm new module <name>` (CLI) or manually:
Expand All @@ -274,94 +179,6 @@ Use `sm new module <name>` (CLI) or manually:
5. Add `ProjectReference` to `template/SimpleModule.Host/SimpleModule.Host.csproj`
6. Add all projects to `SimpleModule.slnx`

## Minimal API Parameter Binding

Reference: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding?view=aspnetcore-10.0

### Binding Source Priority

| HTTP method | Binding order (implicit) |
|-------------|--------------------------|
| GET, HEAD, OPTIONS, DELETE | Route → Query → Header → DI services |
| POST, PUT, PATCH | Route → Query → Header → Body (JSON) → DI services |

**Key rule:** GET/HEAD/OPTIONS/DELETE never implicitly bind from body. Use `[FromBody]` explicitly if needed.

### Implicit Binding (no attribute needed)

- **Simple types** (`int`, `string`, etc.): Route → Query (implicit)
- **Complex types** (classes/records): Body (JSON) for POST/PUT/DELETE (implicit)
- **DI services**: Auto-injected when registered in the container. `[FromServices]` is noise.
- **Special types**: `HttpContext`, `HttpRequest`, `HttpResponse`, `CancellationToken`, `ClaimsPrincipal`, `Stream`, `PipeReader` are auto-bound
- **Arrays from query strings**: `int[]`, `string[]`, `StringValues` bind from repeated keys: `?q=1&q=2` → `int[] q`

### Optional Parameters

Parameters are **required by default**. Missing required params → 400 Bad Request. Make optional via:
- Nullable type: `int? pageNumber` (null if missing)
- Default value: `int pageNumber = 1` (uses default if missing)
- `string[]` from query: missing key → empty array (not null)

### When to Use Attributes

- `[FromForm]` — **required** for scalar form data (never implicit). Supports scalars, enums, complex types, `IFormFile`.
- `[FromForm]` on complex types — binds class properties from form fields: `([FromForm] Todo todo) => ...`
- `[FromBody]` — explicit body binding (usually implicit for complex types in POST/PUT)
- `[FromQuery]` — when a parameter name conflicts with a route parameter, or to rename: `[FromQuery(Name = "p")]`
- `[FromRoute]` — when disambiguation needed
- `[FromHeader(Name = "X-Header")]` — for HTTP headers. Supports `int[]`/`string[]` from repeated headers.
- `[AsParameters]` — bind a struct/class from multiple sources (route + query + header + DI)

### Form Binding Limitations (IMPORTANT)

`[FromForm]` supports:
- ✅ Scalar types, enums, complex types, `IFormFile`/`IFormFileCollection`
- ❌ **`List<string>`, `string[]` from repeated form keys** — does NOT work in Minimal APIs

For multi-value form fields (e.g., `permissions=A&permissions=B`), use `ReadFormAsync()`:
```csharp
var form = await context.Request.ReadFormAsync();
var permissions = form["permissions"].Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p!).ToList();
```

### Correct Patterns

```csharp
// API: complex type auto-binds from JSON body, service auto-injected
app.MapPost("/", async (CreateProductRequest request, IProductContracts products) => ...);

// API: route param + body + DI
app.MapPut("/{id}", async (int id, UpdateProductRequest request, IProductContracts products) => ...);

// View: scalar form data requires [FromForm]
app.MapPost("/", async ([FromForm] string name, [FromForm] decimal price, IProductContracts products) => ...);

// View: complex type from form
app.MapPost("/todo", async ([FromForm] Todo todo) => TypedResults.Ok(todo));

// Query: arrays bind from repeated keys
app.MapGet("/tags", (int[] q) => $"tag1: {q[0]}, tag2: {q[1]}");
```

### Anti-patterns (Avoid)

```csharp
// BAD: manual form reading for SCALAR values (use [FromForm] instead)
app.MapPost("/", async (HttpContext context, IService svc) => {
var form = await context.Request.ReadFormAsync();
var name = form["name"].ToString(); // Use [FromForm] string name
});

// BAD: manual JSON deserialization (let model binding handle it)
app.MapPost("/", async (HttpContext context, IService svc) => {
var body = await JsonSerializer.DeserializeAsync<MyType>(context.Request.Body);
});

// OK: ReadFormAsync for multi-value form fields (no [FromForm] equivalent)
var form = await context.Request.ReadFormAsync();
var permissions = form["permissions"].ToList(); // This is the correct approach
```

## Linting & Formatting

Biome is configured at repo root (`biome.json`). Covers `modules/**`, `packages/**`, `template/**` except `**/wwwroot/**`. Settings: single quotes, semicolons always, 2-space indent, trailing commas, 100-char line width. Tailwind CSS directives enabled.
Expand Down
1 change: 1 addition & 0 deletions SimpleModule.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
</Folder>
<Folder Name="/modules/" />
<Folder Name="/modules/Dashboard/">
<Project Path="modules/Dashboard/src/SimpleModule.Dashboard.Contracts/SimpleModule.Dashboard.Contracts.csproj" />
<Project Path="modules/Dashboard/src/SimpleModule.Dashboard/SimpleModule.Dashboard.csproj" />
</Folder>
<Folder Name="/modules/Orders/">
Expand Down
Loading