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
115 changes: 93 additions & 22 deletions docs/site/advanced/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,42 +10,80 @@ SimpleModule applications deploy as standard ASP.NET applications. This guide co

### Dockerfile

The project includes a multi-stage Dockerfile that produces a minimal runtime image:
The project includes a multi-stage Dockerfile that produces a minimal runtime image. The snippet below is a simplified reference — see the repository's actual `Dockerfile` for the full script, which lists every module's `.csproj` and workspace `package.json` explicitly for optimal layer caching:

```dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
# Stage 1: Base — .NET SDK + Node.js 22
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS base
WORKDIR /src

# Copy project files and restore (layer caching)
COPY Directory.Build.props Directory.Packages.props ./
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get clean && rm -rf /var/lib/apt/lists/*

# Stage 2: .NET restore (cached unless .csproj / props files change)
FROM base AS restore
WORKDIR /src
COPY global.json Directory.Build.props Directory.Packages.props .editorconfig ./
COPY *.slnx ./
COPY framework/SimpleModule.Core/*.csproj framework/SimpleModule.Core/
COPY framework/SimpleModule.Database/*.csproj framework/SimpleModule.Database/
COPY framework/SimpleModule.Generator/*.csproj framework/SimpleModule.Generator/
COPY template/SimpleModule.Host/*.csproj template/SimpleModule.Host/
# ... module project files ...
# Copy every framework/module/package .csproj individually for layer caching
# ... (see real Dockerfile for the full list) ...
RUN dotnet restore template/SimpleModule.Host/SimpleModule.Host.csproj

# Build and publish
# Stage 3: npm restore (cached unless package.json / lockfile changes)
FROM restore AS npm-restore
WORKDIR /src
COPY package.json package-lock.json ./
# Copy every workspace package.json individually for layer caching
# ... (see real Dockerfile for the full list) ...
RUN npm ci

# Stage 4: Frontend build + .NET publish
FROM npm-restore AS build
WORKDIR /src
COPY . .
RUN npm run build \
&& npx @tailwindcss/cli \
-i template/SimpleModule.Host/Styles/app.css \
-o template/SimpleModule.Host/wwwroot/css/app.css \
--minify
RUN dotnet publish template/SimpleModule.Host/SimpleModule.Host.csproj \
-c Release -o /app/publish
-c Release -o /app/publish --no-restore \
-p:ErrorOnDuplicatePublishOutputFiles=false \
-p:JsBuildCommand=echo

# Stage 5: Runtime (slim image, non-root user)
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 1001 appgroup \
&& useradd --system --uid 1001 --gid appgroup --create-home appuser
COPY --from=build --chown=appuser:appgroup /app/publish .
RUN mkdir -p /app/data /app/storage && chown appuser:appgroup /app/data /app/storage

# DEPLOY_VERSION feeds Inertia's asset-version header; if empty, the framework
# falls back to the entry assembly's last-write timestamp.
ARG DEPLOY_VERSION=
ENV DEPLOYMENT_VERSION=${DEPLOY_VERSION}

USER appuser
EXPOSE 8080
ENTRYPOINT ["dotnet", "SimpleModule.Host.dll"]
```

Key points:
- **Multi-stage build** -- the SDK image is only used for building; the final image uses the smaller `aspnet` runtime image
- **Layer caching** -- project files are copied and restored before the full source copy, so NuGet restore is cached across builds
- **Five stages** -- `base` (SDK + Node 22), `restore` (.NET restore), `npm-restore` (workspace npm ci), `build` (frontend + dotnet publish), `runtime` (slim aspnet image)
- **Non-root runtime** -- creates `appuser` (UID 1001) and runs the container as that user; owns `/app/data` (SQLite) and `/app/storage` (local files)
- **Per-project COPY for layer caching** -- every `.csproj` and workspace `package.json` is copied individually so `dotnet restore` and `npm ci` only re-run when those manifests change
- **`DEPLOY_VERSION` build arg** -- feeds `DEPLOYMENT_VERSION` for Inertia cache-busting; override with `--build-arg DEPLOY_VERSION=$(git rev-parse --short HEAD)` for deterministic versions
- The runtime container exposes port **8080**

### Docker Compose

For local testing or simple deployments, use `docker-compose.yml`:
For local testing or simple deployments, use `docker-compose.yml`. The real compose file defines three services — `api`, `worker`, and `postgres` — so background jobs run in a dedicated consumer process while the web tier stays in producer mode:

```yaml
services:
Expand All @@ -54,17 +92,46 @@ services:
ports:
- "8080:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Production
- Database__DefaultConnection=Host=postgres;Database=simplemodule;Username=simplemodule;Password=simplemodule
ASPNETCORE_ENVIRONMENT: Development
Database__DefaultConnection: "Host=postgres;Port=5432;Database=simplemodule;Username=simplemodule;Password=${POSTGRES_PASSWORD:-simplemodule}"
Database__Provider: PostgreSQL
OpenIddict__BaseUrl: ${APP_BASE_URL:-http://localhost:8080}
# api enqueues jobs but never executes them — the worker does.
BackgroundJobs__WorkerMode: Producer
volumes:
- storage_data:/app/storage
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8080/health/live || exit 1"]
interval: 30s
timeout: 5s
start_period: 15s
retries: 3
restart: unless-stopped

worker:
build:
context: .
dockerfile: Dockerfile.worker
environment:
DOTNET_ENVIRONMENT: Development
Database__DefaultConnection: "Host=postgres;Port=5432;Database=simplemodule;Username=simplemodule;Password=${POSTGRES_PASSWORD:-simplemodule}"
Database__Provider: PostgreSQL
BackgroundJobs__WorkerMode: Consumer
volumes:
- storage_data:/app/storage
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped

postgres:
image: postgres:16
image: postgres:17
environment:
POSTGRES_USER: simplemodule
POSTGRES_PASSWORD: simplemodule
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-simplemodule}
POSTGRES_DB: simplemodule
ports:
- "5432:5432"
Expand All @@ -75,9 +142,11 @@ services:
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped

volumes:
pgdata:
storage_data:
```

Start with:
Expand All @@ -86,7 +155,7 @@ Start with:
docker compose up -d
```

The API service waits for PostgreSQL to pass its health check before starting.
The API service waits for PostgreSQL to pass its health check before starting. The `storage_data` volume is shared between `api` and `worker` so uploaded files are visible from both the web upload path and the background job path.

## CI/CD Pipeline

Expand Down Expand Up @@ -190,6 +259,8 @@ For production, always use environment variables or a secrets manager for connec
| `ASPNETCORE_ENVIRONMENT` | Runtime environment | `Production`, `Development` |
| `Database__DefaultConnection` | Database connection string | `Host=...;Database=...` |
| `Database__Provider` | Database provider | `Sqlite`, `PostgreSQL` |
| `OpenIddict__BaseUrl` | Public base URL used to register OpenIddict redirect URIs | `https://app.example.com` |
| `BackgroundJobs__WorkerMode` | Role in the background-job pipeline | `Producer` (web tier) or `Consumer` (worker tier) |

### Docker Environment

Expand All @@ -205,7 +276,7 @@ docker run -d \

## Database Initialization

SimpleModule uses `EnsureCreated()` by default, which creates the database schema if it does not exist. For production environments with evolving schemas, use EF Core migrations per module:
SimpleModule relies on EF Core migrations per module — there is no `EnsureCreated()` bootstrap in the framework. Generate and apply migrations for each module's DbContext:

```bash
# Add a migration for a specific module's DbContext
Expand Down
24 changes: 18 additions & 6 deletions docs/site/advanced/interceptors.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ outline: deep

SimpleModule supports EF Core `SaveChangesInterceptor` for cross-cutting concerns like audit logging, soft deletes, and timestamp management. The source generator auto-discovers interceptors and wires them into the DbContext pipeline.

## Shipped Interceptors

The framework ships three interceptors under `framework/SimpleModule.Database/Interceptors/`, all auto-registered:

| Interceptor | Purpose |
|-------------|---------|
| `EntityInterceptor` | Populates `CreatedAt`/`UpdatedAt`, audit user fields, concurrency stamps, versioning, tenant IDs, and converts hard deletes into soft deletes based on the interfaces an entity implements (`IHasCreationTime`, `IHasModificationTime`, `IAuditable`, `IHasConcurrencyStamp`, `IVersioned`, `IMultiTenant`, `ISoftDelete`). |
| `DomainEventInterceptor` | Collects events from `IHasDomainEvents` entities before save and dispatches them via Wolverine's `IMessageBus` after a successful save. |
| `EntityChangeInterceptor` | Captures entity changes before save and dispatches them to typed `IEntityChangeHandler<T>` implementations after save. |

## Overview

A `SaveChangesInterceptor` hooks into the EF Core save pipeline, allowing you to inspect or modify entities before or after they are persisted. Common use cases include:
Expand Down Expand Up @@ -33,7 +43,7 @@ This happens because:

## The Solution: Lazy Resolution

Inject `IServiceProvider?` as an optional parameter and resolve dependencies at interception time -- not at construction time.
Inject `IServiceProvider` and resolve dependencies at interception time -- not at construction time. The framework's own interceptors do exactly this: `DomainEventInterceptor` and `EntityChangeInterceptor` take a non-optional `IServiceProvider` and resolve services (like Wolverine's `IMessageBus` or typed change handlers) inside `SavingChangesAsync`/`SavedChangesAsync`. `EntityInterceptor` takes `IHttpContextAccessor` and an optional `ITenantContext?` directly — both are safe because they don't depend on a DbContext.

### Correct Pattern

Expand Down Expand Up @@ -83,9 +93,11 @@ public sealed class BadInterceptor(

### Constructor Parameters

- **Never** inject services that transitively depend on a DbContext into the interceptor constructor
- **Do** inject `IServiceProvider?` as an optional dependency when runtime service resolution is needed
- **Do** inject simple services (like `ILogger<T>`, `TimeProvider`) that have no DbContext dependency
- **Never** inject services that transitively depend on a DbContext into the interceptor constructor — that's what causes the deadlock
- **Do** inject `IServiceProvider` (optional via `IServiceProvider? = null` if you want the interceptor to work without DI in unit tests) when runtime service resolution is needed
- **Do** inject simple services (like `ILogger<T>`, `TimeProvider`, `IHttpContextAccessor`) that have no DbContext dependency — the framework's `EntityInterceptor` does this with `IHttpContextAccessor` and `ITenantContext?`

The `SM0039` diagnostic flags interceptors whose constructor parameters transitively depend on a DbContext, so you'll see a compile-time warning before you hit the runtime deadlock.

### Service Resolution Timing

Expand Down Expand Up @@ -114,12 +126,12 @@ public sealed class TimestampInterceptor(

foreach (var entry in eventData.Context.ChangeTracker.Entries())
{
if (entry.State == EntityState.Added && entry.Entity is IHasCreatedAt created)
if (entry.State == EntityState.Added && entry.Entity is IHasCreationTime created)
{
created.CreatedAt = now;
}

if (entry.State == EntityState.Modified && entry.Entity is IHasUpdatedAt updated)
if (entry.State == EntityState.Modified && entry.Entity is IHasModificationTime updated)
{
updated.UpdatedAt = now;
}
Expand Down
6 changes: 3 additions & 3 deletions docs/site/advanced/source-generator.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ The generator feeds `DiscoveryData` through a pipeline of **emitters**, each res
| `LocalizationExtensionsEmitter` | `LocalizationExtensions.g.cs` | Aggregates localization resources across modules |
| `RoutesEmitter` | `ModuleRoutes.g.cs` | Strongly-typed C# route constants |
| `TypeScriptRoutesEmitter` | `TypeScriptRoutes.g.cs` | Embedded TypeScript route constants for the ClientApp |
| `HostingExtensionsEmitter` | `HostingExtensions.g.cs` | Top-level `AddSimpleModule()` that orchestrates all registrations |
| `HostingExtensionsEmitter` | `HostingExtensions.g.cs` | Top-level `AddSimpleModule()` (service registration) and `UseSimpleModule()` (middleware + endpoint mapping) that orchestrate all framework wiring |

### Frontend Integration

Expand All @@ -158,7 +158,6 @@ The generator feeds `DiscoveryData` through a pipeline of **emitters**, each res
| Emitter | Generated File | Purpose |
|---------|---------------|---------|
| `JsonResolverEmitter` | `ModulesJsonResolver.g.cs` | AOT-compatible JSON type info resolver for all DTO types |
| `ViewPagesEmitter` | `ViewPages.g.cs` | Maps view endpoints to React component names |
| `DiagnosticEmitter` | _(diagnostics only)_ | Reports compiler warnings/errors for illegal module references and other issues |

## The Discovery-Emit Pipeline
Expand Down Expand Up @@ -224,7 +223,8 @@ public class ModuleDiscovererGenerator : IIncrementalGenerator
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var dataProvider = context.CompilationProvider.Select(
static (compilation, _) => SymbolDiscovery.Extract(compilation)
static (compilation, cancellationToken) =>
SymbolDiscovery.Extract(compilation, cancellationToken)
);

context.RegisterSourceOutput(
Expand Down
6 changes: 3 additions & 3 deletions docs/site/advanced/type-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ extract-ts-types.mjs (build tool)
│ Writes types.ts into each module's src/ directory
modules/{Module}/src/{Module}/types.ts
modules/{Module}/src/SimpleModule.{Module}/types.ts
Ready for import in React components
```

Expand Down Expand Up @@ -127,13 +127,13 @@ You must build the .NET project before running type generation, since the tool r
Each module gets its own `types.ts` file at:

```
modules/{ModuleName}/src/{ModuleName}/types.ts
modules/{ModuleName}/src/SimpleModule.{ModuleName}/types.ts
```

For example, the Products module produces:

```
modules/Products/src/Products/types.ts
modules/Products/src/SimpleModule.Products/types.ts
```

The file is marked as auto-generated and should not be edited manually:
Expand Down
21 changes: 18 additions & 3 deletions docs/site/cli/doctor.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,24 @@ sm doctor [--fix]

| Option | Description |
|--------|-------------|
| `--fix` | Auto-fix missing `.slnx` entries and project references |
| `--fix` | Auto-fix missing `.slnx` entries, host project references, `Pages/index.ts` entries, and root `package.json` npm workspace globs |

## Checks Performed

The doctor command runs five categories of checks:
The doctor command runs twelve checks, grouped below by concern:

- **SolutionStructure** -- foundational directories and the `.slnx` solution file
- **ProjectReference** -- host project references each module's implementation
- **SlnxEntries** -- `.slnx` contains every module's contracts, implementation, and tests
- **CsprojConvention** -- `.csproj` files follow SimpleModule conventions
- **ContractsIsolation** -- contracts projects do not reference other modules
- **ModulePattern** -- module directory layout matches the expected pattern
- **ModuleAttribute** -- module class is decorated with `[Module]`
- **ViewEndpointNaming** -- view endpoints follow naming conventions
- **PagesRegistry** -- every view endpoint has an entry in `Pages/index.ts`
- **ViteConfig** -- module has a valid `vite.config.ts`
- **PackageJson** -- module has a valid `package.json`
- **NpmWorkspace** -- the root `package.json` workspaces array includes each module

### 1. Solution Structure

Expand Down Expand Up @@ -76,6 +89,8 @@ When you pass `--fix`, the doctor attempts to repair the following issues:

- **Missing `.slnx` entries** -- adds folder entries for the module's three projects
- **Missing project references** -- adds a `<ProjectReference>` in the host `.csproj` pointing to the module
- **Missing `Pages/index.ts` entries** -- adds a registry entry for each view endpoint that lacks one
- **Missing npm workspace globs** -- adds the module's workspace paths to the root `package.json`

After auto-fixing, all checks are re-run and the results table reflects the current state.

Expand All @@ -87,7 +102,7 @@ sm doctor --fix
```

::: info
Auto-fix only handles structural wiring (slnx entries and project references). It does not create missing files like `Module.cs` or `DbContext.cs` -- use `sm new module` for that.
Auto-fix handles structural wiring: `.slnx` entries, host project references, `Pages/index.ts` registry entries for view endpoints, and npm workspace globs in the root `package.json`. It does not create missing files like `Module.cs` or `DbContext.cs` -- use `sm new module` for that.
:::

## Output
Expand Down
24 changes: 20 additions & 4 deletions docs/site/cli/new-feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,21 @@ If you omit required options, the CLI prompts you interactively with selection m
| `--method <method>` | HTTP method: `GET`, `POST`, `PUT`, or `DELETE`. Prompted if omitted. |
| `-r, --route <pattern>` | Route pattern (e.g., `/{id}`). Defaults to `/{id}` when prompted. |
| `--validator` | Include a request validator class alongside the endpoint. |
| `--no-view` | Skip creating the React view component and its `Pages/index.ts` entry. |
| `--dry-run` | Preview the files that would be created or modified without writing anything to disk. |

## What Gets Created

Running `sm new feature UpdateInvoice --module Invoices --method PUT --route /{id} --validator` generates:

```
modules/Invoices/src/Invoices/Endpoints/Invoices/
UpdateInvoiceEndpoint.cs # IEndpoint implementation
UpdateInvoiceRequestValidator.cs # Request validator (when --validator is used)
src/modules/Invoices/src/Invoices/
Endpoints/Invoices/
UpdateInvoiceEndpoint.cs # IEndpoint implementation
UpdateInvoiceRequestValidator.cs # Request validator (when --validator is used)
Views/
UpdateInvoice.tsx # React view component (unless --no-view)
Pages/index.ts # Updated with a new entry mapping "Invoices/UpdateInvoice" to the view
```

### Endpoint Auto-Discovery
Expand All @@ -42,6 +48,16 @@ The generated endpoint implements `IEndpoint`, which the Roslyn source generator

When you pass `--validator`, the CLI generates a companion validator class that validates the request before the endpoint logic runs.

### View + Pages Registry (Default)

By default the CLI also creates `Views/{Feature}.tsx` and appends an entry to the module's `Pages/index.ts`, e.g.:

```ts
'Invoices/UpdateInvoice': () => import('@/Views/UpdateInvoice'),
```

Pass `--no-view` to skip both steps (useful for pure API endpoints that never render a page).

## Interactive Mode

When run without flags, the CLI walks you through each option:
Expand All @@ -61,7 +77,7 @@ sm new feature
- At least one module must exist. If no modules are found, the CLI directs you to run `sm new module` first

::: tip View Endpoints
If your feature is a page (view endpoint using `Inertia.Render`), remember to add a corresponding entry in your module's `Pages/index.ts`. See the [Pages Registry Pattern](/guide/modules#pages-registry) for details.
`sm new feature` automatically adds the `Pages/index.ts` entry for the view it scaffolds. If you later add an `IViewEndpoint` by hand (or pass `--no-view`), you must register it yourself. See the [Pages Registry Pattern](/guide/modules#pages-registry) for details.
:::

## Example
Expand Down
Loading
Loading