From e9c73e371a3b285f60143b42a135127d1eb9a10f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 13:15:28 +0000 Subject: [PATCH] docs: add brownfield integration guide for existing .NET apps --- docs/site/.vitepress/config.ts | 1 + .../getting-started/brownfield-integration.md | 361 ++++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 docs/site/getting-started/brownfield-integration.md diff --git a/docs/site/.vitepress/config.ts b/docs/site/.vitepress/config.ts index 532bcd3f..a314a783 100644 --- a/docs/site/.vitepress/config.ts +++ b/docs/site/.vitepress/config.ts @@ -31,6 +31,7 @@ export default defineConfig({ { text: 'Introduction', link: '/getting-started/introduction' }, { text: 'Quick Start', link: '/getting-started/quick-start' }, { text: 'Project Structure', link: '/getting-started/project-structure' }, + { text: 'Brownfield Integration', link: '/getting-started/brownfield-integration' }, ], }, ], diff --git a/docs/site/getting-started/brownfield-integration.md b/docs/site/getting-started/brownfield-integration.md new file mode 100644 index 00000000..55309ff2 --- /dev/null +++ b/docs/site/getting-started/brownfield-integration.md @@ -0,0 +1,361 @@ +--- +outline: deep +--- + +# Brownfield Integration + +This guide is for teams who already have a large, production .NET application and want to adopt SimpleModule **without rewriting the existing codebase**. It walks through bringing the framework in alongside whatever you already have, then migrating features into modules at a pace that suits your team. + +If you are starting a new application, follow the [Quick Start](/getting-started/quick-start) instead. The CLI scaffolds a clean solution in seconds. + +## Why this is realistic + +SimpleModule is composed of a few independent capabilities: + +- A **Roslyn source generator** that discovers `[Module]` classes from referenced assemblies at compile time. +- A small set of **infrastructure NuGet packages** (`SimpleModule.Hosting`, `SimpleModule.Database`, `SimpleModule.Storage.*`). +- A growing set of **optional cross-cutting modules** (Users, Permissions, Settings, AuditLogs, FileStorage, FeatureFlags, Tenants, Email, BackgroundJobs, Localization, RateLimiting, OpenIddict, Admin, Dashboard). + +There is **no required base class for your existing controllers, no global filter, no startup interceptor**. Adding `builder.AddSimpleModule()` is purely additive: it registers services, maps routes under module-defined prefixes, and otherwise leaves your application untouched. Your existing MVC controllers, minimal API endpoints, middleware, EF Core contexts, and authentication schemes keep working exactly as before. + +This means you can integrate the framework in a single afternoon and migrate features over months. + +## What "brownfield integration" looks like + +A typical migration goes through four phases. You can stop at any phase — each one is a stable end-state. + +| Phase | Goal | Time investment | +|-------|------|-----------------| +| **1. Coexist** | SimpleModule installed and starting up next to your existing code. No features migrated. | An afternoon | +| **2. First module** | One small, isolated bounded context (e.g. "Notifications" or "Audit") moved into a module. | A few days | +| **3. Adopt cross-cutting modules** | Replace home-grown pieces with `Permissions`, `Settings`, `AuditLogs`, `FileStorage`, etc. | Per-module, weeks | +| **4. Module-first by default** | New features are written as modules; old code is migrated opportunistically. | Ongoing | + +Most teams stop at Phase 3. A full rewrite is rarely the right goal. + +## Phase 1: Coexist + +The objective of Phase 1 is to get SimpleModule loaded into your existing host process with **zero behavioural change**. After this phase your application starts up, every existing route still works, and `builder.AddSimpleModule()` resolves successfully. + +### Prerequisites + +- **.NET 10 SDK** for your host project. If your host is on an older TFM, you have two options: + - Multi-target the host (`net8.0;net10.0`) and run on net10.0. + - Stand up a new net10.0 host project that proxies/calls into your existing app. Many teams find this easier than retargeting a large solution at once. +- **A modern `WebApplication`-style `Program.cs`**. The framework hooks into `WebApplicationBuilder` and `WebApplication`. If you are still on `Startup.cs`, port `Program.cs` to the minimal hosting model first — it is a mechanical change. + +### Step 1: Add the framework projects to your solution + +You have two choices: + +**Option A — Reference the framework as projects (recommended while it pre-1.0).** Clone or submodule the SimpleModule repository into your tree and add the framework `.csproj` files to your `.slnx`/`.sln`: + +- `framework/SimpleModule.Core` +- `framework/SimpleModule.Database` +- `framework/SimpleModule.Generator` +- `framework/SimpleModule.Hosting` +- `framework/SimpleModule.Storage.Local` (or `.S3` / `.Azure`) + +**Option B — NuGet packages** once you publish your own builds to a private feed. The `` shape is identical to the `` shape below; substitute as needed. + +### Step 2: Wire the host `.csproj` + +Add the following to your existing host project file. The two important lines are the `Generator` reference (which **must** be marked as an Analyzer) and `SimpleModule.Hosting`: + +```xml + + net10.0 + + true + + $(NoWarn);SM0025;SM0028 + + + + + + + + + + + + +``` + +::: warning +`OutputItemType="Analyzer"` and `ReferenceOutputAssembly="false"` are both required. Without them the generator either doesn't run or its assembly leaks into the host's runtime references and causes load failures. +::: + +### Step 3: Update `Program.cs` + +Add three lines to your existing host bootstrap. Place them **after** your own service registrations but **before** `builder.Build()`: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +// --- Your existing registrations (controllers, EF, MediatR, auth, etc.) --- +builder.Services.AddControllers(); +builder.Services.AddDbContext(opts => opts.UseSqlServer(/*...*/)); +builder.Services.AddAuthentication(/*...*/); + +// --- SimpleModule additions --- +builder.Services.AddLocalStorage(builder.Configuration); // or AddS3Storage / AddAzureStorage +builder.AddSimpleModule(); // generated extension method + +var app = builder.Build(); + +// --- Your existing pipeline --- +app.UseHttpsRedirection(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); + +// --- SimpleModule middleware + endpoints --- +await app.UseSimpleModule(); // generated extension method + +await app.RunAsync(); +``` + +Both `AddSimpleModule` and `UseSimpleModule` are emitted by the source generator. They are the only two calls you need; the generator wires every discovered module's services, endpoints, and middleware behind them. + +### Step 4: Provide minimal configuration + +`SimpleModule.Database` reads from a `Database` section in your `IConfiguration`: + +```json +{ + "Database": { + "DefaultConnection": "Server=localhost;Database=MyApp;Trusted_Connection=true;TrustServerCertificate=true", + "Provider": "SqlServer" + } +} +``` + +`Provider` accepts `SqlServer`, `PostgreSQL`, or `SQLite`. Per-module overrides are supported via `Database:ModuleConnections:`. + +::: tip Reuse your existing connection string +You can point `DefaultConnection` at the same database your legacy `DbContext` already uses. Modules will create their tables in dedicated **schemas** (SQL Server / PostgreSQL) or with **table prefixes** (SQLite), so they will not collide with your existing tables. +::: + +### Step 5: Build and verify + +```bash +dotnet build +dotnet run --project YourCompany.Host +``` + +If the build succeeds and the application starts, Phase 1 is complete. The generator has produced `AddSimpleModule()` and `UseSimpleModule()` based on **zero modules** — they are no-ops at this point. Your existing routes still work; nothing else has changed. + +A useful sanity check: open `obj/Debug/net10.0/generated/SimpleModule.Generator/` in your host project. You should see `SimpleModuleExtensions.g.cs`, `ModuleExtensions.g.cs`, and `EndpointExtensions.g.cs`. These are the generated wiring files. + +## Phase 2: Your first module + +The point of Phase 2 is to prove the migration pattern on a small, low-risk piece of your domain. Pick something with **clear boundaries and few callers** — internal admin tools, a notifications subsystem, or a reporting dashboard are good first candidates. Avoid your core transactional flow on the first try. + +### Pick a candidate + +Good candidates have most of these properties: + +- One bounded responsibility (sending emails, exporting reports, managing a list of products). +- A small number of database tables, ideally with no foreign keys to your core tables. +- A finite set of endpoints, ideally already grouped under a route prefix like `/api/notifications`. +- Few external callers — primarily your own UI or a single integration partner. + +### Scaffold the module + +You can scaffold by hand, but the CLI saves an hour of boilerplate: + +```bash +dotnet tool install -g SimpleModule.Cli +sm new module Notifications --solution-path . --host YourCompany.Host +``` + +This creates: + +``` +modules/Notifications/ +├── src/ +│ ├── YourCompany.Notifications.Contracts/ # interfaces + DTOs other modules can reference +│ └── YourCompany.Notifications/ # IModule, endpoints, EF entities, services +└── tests/ + └── YourCompany.Notifications.Tests/ +``` + +…and adds a `` to your host's `.csproj`. The generator picks up the new module on the next build — there is no central registry to update. + +### Move logic in, not data (yet) + +The cheapest migration path is to **leave the existing tables and connection alone** and have the new module call into your legacy code through an interface. This lets you cut over the API surface without touching the database. + +```csharp +// In your module's ConfigureServices +public override void ConfigureServices(IServiceCollection services, IConfiguration config) +{ + // Bridge to legacy code via an interface owned by this module + services.AddScoped(); +} +``` + +Then implement `LegacyNotificationStoreAdapter` in your host project, where it can reach the legacy `DbContext`. When you are ready, move the data into the module's own `DbContext` and delete the adapter. + +### Redirect existing callers + +If old controllers exposed `/api/notifications/*`, configure your module to use the same prefix: + +```csharp +[Module("Notifications", RoutePrefix = "/api/notifications")] +public sealed class NotificationsModule : IModule +{ + // ... +} +``` + +…and **delete the old controller**. The module's endpoints take over the route. External callers see no change. + +If you cannot delete the old controller yet (perhaps it still serves a partner API you have to support unchanged), leave it in place and give the module a different prefix like `/api/v2/notifications`. + +### Verify + +Run `dotnet test` against the new module's test project. Run your existing integration tests. Hit a few endpoints with curl. Phase 2 is done when the migrated feature is in production behind the new module. + +## Phase 3: Adopt cross-cutting modules + +Most large applications carry home-grown implementations of permissions, audit logs, settings, file uploads, feature flags, and so on. The cross-cutting modules in `modules/` are designed as drop-in replacements. **Each one is independent.** You can adopt them one at a time, in any order, and you can ignore the ones you do not need. + +### How to choose what to adopt + +Adopt a module when **all** of these are true: + +1. You currently have a custom implementation of the same concern. +2. The custom implementation has bugs, missing features, or is expensive to maintain. +3. The module's contract covers your real use cases (read its `*.Contracts` project before deciding). + +Do **not** adopt a module just because it exists. If your custom feature flag system works fine, leave it. + +### The cross-cutting catalogue + +| Module | Replaces | When to adopt | +|--------|----------|---------------| +| `Permissions` | Custom claim-checking, role lookups, authorization handlers | You want declarative `[Authorize]`-style permission checks across modules | +| `Settings` | `appsettings.json` for runtime-tunable values, custom admin pages | Operators need to change values without a deploy | +| `AuditLogs` | Manual `_logger.LogInformation("user X did Y")` | You need queryable audit history with entity diffs | +| `FileStorage` | Direct `IWebHostEnvironment.ContentRootPath` writes, custom S3 wrappers | Multiple modules need to upload/download files | +| `FeatureFlags` | `if (config["Features:X"] == "true")` checks | You want per-user / per-tenant flag evaluation | +| `Tenants` | Custom `tenantId` filtering in repositories | You operate a multi-tenant SaaS | +| `Email` | Direct `SmtpClient` use, custom Mailkit wrappers | You want templated, queued email with retries | +| `BackgroundJobs` | Hangfire, Quartz, raw `Task.Run` | You want CRON + on-demand jobs with progress tracking | +| `Localization` | `IStringLocalizer` resource files | You want JSON-based locale files modules can ship independently | +| `RateLimiting` | ASP.NET rate limiter middleware tuned by hand | You want per-module rate-limit policies | +| `Users` | ASP.NET Identity setup glue, custom user pages | You want passkeys + a user management UI out of the box | +| `OpenIddict` | Hand-rolled JWT issuance, IdentityServer | You want a maintained OAuth2 / OIDC server | + +### Pattern: parallel run + +The safest way to adopt a cross-cutting module is to **run it alongside your existing implementation**, then cut over once you trust it. + +Example — adopting `Permissions`: + +1. Add a `` to `SimpleModule.Permissions` and `SimpleModule.Permissions.Contracts`. +2. Define your permissions in a `IModulePermissions` class. The generator picks them up at build time. +3. **Do not delete your existing authorization code yet.** Add module-level permission checks in parallel. +4. After a release cycle of dual-running, switch your existing endpoints to call `IPermissionService` and remove the legacy code. + +The same pattern applies to `Settings`, `AuditLogs`, `FeatureFlags`, etc. The cost of running both for a release is small; the cost of a botched cutover is large. + +## Phase 4: Module-first by default + +After a few cross-cutting modules land, the team's instincts shift. New features get scaffolded as modules because the boilerplate is shorter than spinning up a new controller folder. Old code gets migrated whenever someone is already touching it for another reason. + +There is no specific milestone for "Phase 4 complete." A healthy end-state is: + +- 80%+ of new code lives in modules. +- The legacy host project mostly hosts infrastructure (auth, telemetry, top-level middleware) and a shrinking set of legacy controllers. +- Cross-module communication happens through `Contracts` interfaces and the event bus, not direct method calls. + +## Authentication in a brownfield host + +The framework does **not** require you to use OpenIddict, Identity, or any specific auth scheme. Your existing authentication setup is preserved. The only contract is that `HttpContext.User` produces an `IPrincipal` after authentication runs. + +A common pattern: keep your existing JWT bearer or cookie scheme, and tell modules they can authorize via standard ASP.NET policies. Modules that need to issue tokens (e.g. `OpenIddict`) are opt-in via `` — if you do not reference them, they do not load. + +::: warning Default fallback policy +`SimpleModule.Hosting` registers a fallback authorization policy of `RequireAuthenticatedUser()`. This means **every module endpoint requires authentication unless it explicitly opts out** with `.AllowAnonymous()`. This applies to module endpoints only — your existing controllers are governed by whatever fallback policy you previously configured. +::: + +## Database in a brownfield host + +The single biggest decision in a brownfield migration is how modules interact with your existing database. + +### Recommended: shared connection, separate schemas + +Point `Database:DefaultConnection` at the same database your legacy `DbContext` uses. The framework's generated `HostDbContext` places module entities in module-named schemas (SQL Server / PostgreSQL) or with prefixed table names (SQLite). Your legacy tables stay in `dbo` (or whatever schema they live in) and never collide. + +This is the recommended starting point because: + +- One connection string to manage. +- One transaction scope is possible if you ever need to span legacy and module data. +- Migrations from each side are independent (modules use EF Core migrations against their schema; legacy migrations target `dbo`). + +### Alternative: per-module connection strings + +For modules that need a separate database (perhaps for compliance, scaling, or eventual extraction into a microservice), set a per-module override: + +```json +{ + "Database": { + "DefaultConnection": "Server=...;Database=MyApp;...", + "ModuleConnections": { + "AuditLogs": "Server=audit-db;Database=Audit;..." + }, + "Provider": "SqlServer" + } +} +``` + +Each module can target a different database without any code changes. + +### What to do with your existing `DbContext` + +Leave it alone. Register it the way you always have. Modules do not require you to fold legacy entities into the framework's `HostDbContext`. The two contexts coexist. + +## Frontend integration + +If your existing app already has a frontend (Razor pages, MVC views, a SPA built separately), you do not have to adopt Inertia + React. Module **API endpoints** (`IEndpoint`) work without any frontend at all. + +You only need the Inertia / React stack if you want to use **view endpoints** (`IViewEndpoint`) and the admin UI provided by `SimpleModule.Dashboard`, `SimpleModule.Admin`, etc. Three options: + +1. **Skip the UI entirely.** Reference only modules that expose APIs. Do not reference `Dashboard`, `Admin`, or any module with `IViewEndpoint` implementations. You will not need `npm`, Vite, or `ClientApp/`. +2. **Run the framework UI on a sub-path.** Mount it under `/admin` or `/sm` and let your existing frontend keep serving the rest of the app. The default Inertia setup is a static HTML shell — easy to scope to a route. +3. **Adopt the framework UI fully.** Replace your existing frontend over time. This is a separate, much larger migration; do not bundle it with the backend integration. + +For options 1 and 2 you can ignore the entire `ClientApp/`, `package.json`, and `npm run dev` workflow described in [Quick Start](/getting-started/quick-start). + +## Common pitfalls + +**The generator does not see my module.** Every module assembly must be reachable as a `` (or ``) from the host `.csproj`. The generator scans `compilation.References` and the host assembly itself — it does not scan the filesystem. If a module project compiles fine on its own but is not referenced by the host, it will not be discovered. + +**`AddSimpleModule` does not exist.** This usually means the generator is referenced incorrectly. Check that the reference includes both `OutputItemType="Analyzer"` and `ReferenceOutputAssembly="false"`. Then run `dotnet build` once — analyzers do not run on `dotnet restore` alone. + +**Schema migrations conflict.** If your legacy `DbContext` and the framework's `HostDbContext` both target the default schema, EF Core will refuse to run migrations. Move legacy tables to an explicit schema (`dbo` is fine) and ensure module entities use their module schema (the framework does this automatically via `ApplyModuleSchema`). + +**Auth redirects to a sign-in page that does not exist.** The Users module configures cookie redirects to `/account/login`. If you reference Users but have your own login page elsewhere, override the redirect in your `Program.cs` after `AddSimpleModule`: + +```csharp +builder.Services.ConfigureApplicationCookie(opts => +{ + opts.LoginPath = "/your/login/path"; +}); +``` + +**The fallback `RequireAuthenticatedUser` policy breaks anonymous endpoints.** Module endpoints are authenticated by default. Add `.AllowAnonymous()` per endpoint, or override the fallback policy in your host before `AddSimpleModule`. + +## Where to go next + +- [Modules](/guide/modules) — the `IModule` contract in detail. +- [Endpoints](/guide/endpoints) — implementing `IEndpoint` and `IViewEndpoint`. +- [Database](/guide/database) — schema isolation, migrations, multi-provider support. +- [Source Generator](/advanced/source-generator) — what the generator actually emits.