diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af0124b..9f50d60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,3 +55,46 @@ jobs: - run: dotnet format samples/Loom.Samples.slnx --verify-no-changes - run: dotnet build samples/Loom.Samples.slnx --no-restore - run: dotnet test samples/Loom.Samples.slnx --no-build + + # Scaffolds a solution from the template and builds it, against the published Loom packages rather + # than project references. Three defects reached the first template that only this catches: a using + # for a namespace that does not exist, an Aspire resource name invalid for any dotted project name, + # and a generated Projects class the template named wrongly. None of them fail the Loom build. + # + # The dotted name is deliberate. It is the case that breaks, and the one people actually use. + template: + runs-on: ubuntu-latest + + # Every archetype, because they are separate trees: a fix applied to one is not applied to the + # other, and only building both notices. + strategy: + fail-fast: false + matrix: + archetype: [loom-api, loom-worker] + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Pack the template + run: dotnet pack src/Loom.Templates --configuration Release --output artifacts/templates + + - name: Install it + run: dotnet new install artifacts/templates/*.nupkg + + - name: Scaffold a solution + run: dotnet new ${{ matrix.archetype }} --name Acme.Billing --output "$RUNNER_TEMP/scaffold/Acme.Billing" + + - name: Build what it produced + working-directory: ${{ runner.temp }}/scaffold/Acme.Billing + run: dotnet build + + - name: Test what it produced + working-directory: ${{ runner.temp }}/scaffold/Acme.Billing + run: dotnet test --no-build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0edecd3..dde7ee8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,8 +54,11 @@ jobs: - run: dotnet build --no-restore --configuration Release - run: dotnet test --no-build --configuration Release + # PinTemplatesToPackageVersion rewrites the Loom version a scaffolded solution references to the + # version being released. Only here: an untagged build's packages are never published, so a + # template pinned to one could not restore, and CI scaffolds and builds one on every push. - name: Pack - run: dotnet pack --no-build --configuration Release --output artifacts + run: dotnet pack --no-build --configuration Release --output artifacts -p:PinTemplatesToPackageVersion=true # MinVer is configured, not magic: a wrong tag prefix or a shallow clone yields # 0.0.0-alpha.0.N instead of the tagged version, and pushing that would burn the version diff --git a/AGENTS.md b/AGENTS.md index 73d57b0..a140e40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,17 @@ Packages that exist today: | `Loom.Handlers.FluentValidation` | A decorator that validates requests before a handler runs, returning an `Invalid` failure. | Any validation rule of its own. | | `Loom.Persistence.EntityFrameworkCore` | The single EF Core seam: identity conversion, specification eager loading, `ToPageAsync`, domain event dispatch, an optional outbox with administration over it, and a decorator turning an abandoned save back into the failure that caused it. | A database provider — the consumer picks one. Cursor paging. Any endpoint, command or dashboard over the outbox. | +`Loom.Templates` sits outside the tier table: it ships no assembly, only `dotnet new` content. Its +`AGENTS.md` is generated from `docs/agents/` and committed — edit the fragments, then regenerate **every** +template: + +```bash +scripts/new-agents-md.sh --out src/Loom.Templates/templates/loom-api/AGENTS.md api --force +scripts/new-agents-md.sh --out src/Loom.Templates/templates/loom-worker/AGENTS.md worker --force +``` + +`scripts/check-docs.sh` fails if you forget. + Every package listed has code. There are no placeholder projects left. Layout: `src/Loom./` and `tests/Loom..Tests/`. The `.slnx` groups these into diff --git a/Directory.Build.props b/Directory.Build.props index b1217f5..993395d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -19,6 +19,8 @@ project's own properties, so a flag set there would be read here as unset. --> true false + true + false @@ -68,4 +70,29 @@ true + + + netstandard2.0 + false + Template + true + false + + false + content + true + false + + true + + $(NoWarn);NU5128 + + diff --git a/Loom.slnx b/Loom.slnx index b835db2..87c7409 100644 --- a/Loom.slnx +++ b/Loom.slnx @@ -76,6 +76,13 @@ + + + + + + + diff --git a/README.md b/README.md index dbe0b82..8843b89 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,23 @@ # The Loom Project -## What is Loom? -##### Loom is a group of foundational packages for multiple project types. -Heavily based on Clean Architecture with vertical slice structure enforcement, but very opinionated in my own vision. +Loom is a group of foundational packages for multiple project types. Heavily based on Clean +Architecture with vertical slice structure enforcement, but very opinionated in my own vision. + +Ten packages, versioned in lockstep, each doing one thing and depending only on packages below it. +The opinions are the point: there is one way to report a failure, one way to run a handler, one way +to turn a failure into a status code. Where a decision is forced, Loom makes it. Where it is taste, +Loom leaves you the object and gets out of the way. ## Installing -Nine packages, versioned in lockstep. **Identifiers carry a `CodeByDylan.` prefix; namespaces do not:** +Start a whole solution from a template, or add packages to one you have. + +```bash +dotnet new install CodeByDylan.Loom.Templates +dotnet new loom-api --name Acme.Billing # or loom-worker +``` + +**Identifiers carry a `CodeByDylan.` prefix; namespaces do not:** ```bash dotnet add package CodeByDylan.Loom.Results @@ -20,8 +31,132 @@ That split is deliberate. Only the identifier needs the prefix, because the iden nuget.org reserves — and `Loom.` cannot be reserved, being a common word with packages already published under it by other authors. The namespace stays short because it is the part you type. -Each of these installs as `CodeByDylan.`: +## The packages + +Each installs as `CodeByDylan.`. Take only the ones you need; nothing pulls in a framework you +did not ask for. `Loom.Templates` is the exception — it is installed with `dotnet new install`, not +referenced by a project. + +| Package | What it gives you | +| --- | --- | +| `Loom.Results` | `Result`, `Result`, `Error` and a closed six-member `ErrorCategory`. The vocabulary every other package's signatures are written in. Ships an analyzer that warns when a result is discarded. Depends on nothing. | +| `Loom.Entities` | `Id`, `Entity`, `AggregateRoot`, `IDomainEvent`. Strongly-typed identities over UUID v7, identity equality, and domain event collection. | +| `Loom.Specifications` | Named business rules over a type — a predicate, optional eager loading, optional ordering — as pure expression trees, applied to an `IQueryable` you still own. | +| `Loom.Paging` | `PageRequest` and `Page`, so no project reinvents the paging envelope. | +| `Loom.Handlers.Abstractions` | `IHandler` as pure types, so a domain project can declare handlers without referencing a container. | +| `Loom.Handlers` | Registers handlers and wraps each in an explicit, ordered decorator chain. Carries the logging decorator. | +| `Loom.Handlers.FluentValidation` | A decorator that validates a request before the handler runs, returning an `Invalid` failure. | +| `Loom.Results.AspNetCore` | `result.ToHttpResult()` — the category-to-status-code mapping, as RFC 9457 problem details. | +| `Loom.Templates` | `dotnet new` templates that scaffold a whole solution — `loom-api` and `loom-worker` — with the guidance for working in it already assembled. Ships no assembly. | +| `Loom.Persistence.EntityFrameworkCore` | The single EF Core seam: identity conversion, specification eager loading, `ToPageAsync`, domain event dispatch inside the save's transaction, and an optional transactional outbox with administration over it. | + +## What it looks like + +One operation is one file: its request, response, handler and entry point together. + +```csharp +internal sealed record Request(Id OrderId); + +internal sealed record Response(Guid OrderId, string Status, int Total); + +internal sealed class Handler(OrderingDbContext database, ICurrentCustomer customer) + : IHandler +{ + public async Task> HandleAsync(Request request, CancellationToken cancellationToken) + { + // Eagerly loaded, because the total is computed from the lines. An aggregate reporting state + // it has not loaded is quietly wrong rather than loudly broken. + Order? order = await database.Orders + .Include(candidate => candidate.Lines) + .SingleOrDefaultAsync(candidate => candidate.Id == request.OrderId, cancellationToken); + + if (order is null) + { + return OrderErrors.NotFound; + } + + // Needs the order to decide, so it cannot be an attribute or a policy. + if (order.CustomerId != customer.Id) + { + return OrderErrors.NotYours; + } + + return new Response(order.Id.Value, order.Status.ToString(), order.Total); + } +} +``` + +Errors are values with a stable code and a category: + +```csharp +public static Error NotFound { get; } = Errors.NotFound("orders.not_found", "No such order."); +public static Error NotYours { get; } = Errors.Forbidden("orders.not_yours", "That order belongs to another customer."); +``` + +The category is what decides the status code, the log level and whether a retry makes sense — so the +endpoint is a thin adapter with no mapping of its own: + +```csharp +routes.MapGet("/orders/{orderId}", async ( + Id orderId, + IHandler handler, + CancellationToken cancellationToken) => + (await handler.HandleAsync(new Request(orderId), cancellationToken)).ToHttpResult()); +``` + +Cross-cutting behaviour is declared once, globally, and applies to every handler — declaration order +is nesting order: + +```csharp +services.AddLoomHandlers(chain => chain + .WithLogging() + .WithValidation() + .WithDomainEventFailures()); +``` + +## What it deliberately does not have + +Often the more useful half of an opinion: + +- **No dispatcher or mediator.** Handlers are injected as `IHandler` and called + directly, so "go to definition" reaches the handler rather than a registry. +- **No repository.** `DbContext` is already a unit of work and `DbSet` is already a repository; + wrapping them produces passthrough interfaces and destroys the `IQueryable` composition that makes + specifications work. +- **No exceptions for expected failures.** A failure a caller can anticipate is a `Result`. +- **No way to log a request's contents.** Not an option that defaults to off — it does not exist. +- **No options type on the problem details mapping.** What is forced is fixed; what is taste is + reachable by building the object and changing it. +- **No MediatR, AutoMapper, Wolverine, FastEndpoints or Hangfire**, and no cursor paging. + +## The analyzer + +`Loom.Results` ships `LOOM0001`, which warns when a `Result` is computed and thrown away — the one +mistake this style makes easy. It arrives with the package; there is nothing to install or enable. + +```csharp +Compute(); // warning LOOM0001: This Result is discarded, so a failure would go unnoticed +_ = Compute(); // fine — discarding on purpose is visible +``` + +Its first run on existing code found eight unchecked discards in this repository's own tests. + +## Building projects on Loom + +[`docs/agents/`](https://github.com/CodeByDylan/Loom/tree/main/docs/agents) holds an opinionated stack +and structure for projects *built on* Loom — a shared core plus one delta per archetype (API, worker, +CLI), written to be read by an AI coding agent as much as by a person. + +[`samples/`](https://github.com/CodeByDylan/Loom/tree/main/samples) is a working ordering API that +exercises every package: five slices, real HTTP, real Postgres, domain events inside the transaction +and an outbox after it. + +## Versioning + +Every package ships in lockstep from one repository-wide tag, so the versions always match. **Loom is +pre-1.0 deliberately** — while on `0.x`, breaking changes may land on a minor bump. The design is +still allowed to be wrong. + +## Licence -`Loom.Results` · `Loom.Entities` · `Loom.Specifications` · `Loom.Paging` · `Loom.Handlers` · -`Loom.Handlers.Abstractions` · `Loom.Handlers.FluentValidation` · `Loom.Results.AspNetCore` · -`Loom.Persistence.EntityFrameworkCore` \ No newline at end of file +[MIT](https://github.com/CodeByDylan/Loom/blob/main/LICENSE). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index feb9f9b..485f1e7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,10 +8,49 @@ outgrown this file. | Package | Tier | Why | | --- | --- | --- | -| `Loom.Templates` | n/a | `dotnet new` templates that scaffold a solution and assemble its `AGENTS.md` from `docs/agents/`. Replaces `scripts/new-agents-md.sh`. | +| `loom-cli` template | n/a | The last archetype. Needs `System.CommandLine` and a category-to-exit-code mapping that no package provides yet. | ## Built +`loom-worker` followed, so two of the three archetypes exist. Its schedule is tested by advancing a +fake clock rather than sleeping, and building it exposed two defects in that test harness worth +recording: asserting one dispatch hid the fact that the loop only ever ran once, and `PeriodicTimer` +coalesces ticks, so advancing a fake clock in a tight burst collapses every tick into a single +iteration. Both tests now require a second dispatch, which is what actually proves the loop survived +the first. + +The worker has no equivalent of `ToHttpResult()`. Its category-to-disposition mapping — retry, dead +letter, log once — lives in `RunOnceAsync`, alongside the scope it resolves and the handler it +dispatches to; `ExecuteAsync` above it only schedules and keeps the loop alive. If a second worker project +ever wants the same mapping, that is the moment to consider a package for it, not before. + +`Loom.Templates` ships **two archetypes**, `loom-api` and `loom-worker`; `loom-cli` remains planned. + +The API went first because it was the only one with a reference implementation: `samples/Ordering` +builds, runs and had already had four defects shaken out of it, so that template was derived from +something proven rather than invented to match a document. The worker followed without one, which is +why its own tests carry the weight instead — the pass and one guarded iteration are asserted directly, +with no clock and no timer. + +The CLI still has neither a reference implementation nor a category-to-exit-code mapping in any package, +and inventing structure inside a package whose entire purpose is that people copy it unexamined is how a +guess becomes everyone's convention. + +So `scripts/new-agents-md.sh` survives. This entry originally said the templates replace it; that is not +true while `loom-cli` still needs it, and it stays until that archetype exists. + +The `AGENTS.md` is assembled at pack time rather than at scaffold time, and the assembled copy is +committed so the package content is exactly what the repository shows. `check-docs.sh` regenerates it and +fails if it has drifted, because a template that ships stale guidance is worse than one that ships none. + +Building it turned up three defects that no Loom test could have caught, because they only exist in +generated output: a `using` for a namespace that does not exist (`Loom.Results.AspNetCore` is a package +identifier, not a namespace), an Aspire resource name derived from the project name and therefore +invalid for any name containing a dot, and the class the Aspire SDK generates for a project reference, +which turns dots into underscores and so cannot be produced by the template engine's name substitution +alone. CI now scaffolds and builds a solution called `Acme.Billing` on every push — the dotted name is +the case that breaks and the one people actually use. + `Loom.Outbox.Diagnostics` shipped as `OutboxAdministration` **inside `Loom.Persistence.EntityFrameworkCore`**, for the same reason as the logging decorator: it needs the outbox types and the mapper from a package in its own tier, and references may not reach sideways. diff --git a/docs/agents/00-core.md b/docs/agents/00-core.md index d358c90..a8c2c5a 100644 --- a/docs/agents/00-core.md +++ b/docs/agents/00-core.md @@ -8,7 +8,7 @@ is yours now. Edit it freely. ## 1. Orientation -``` +```text src/MyApp.Domain/ pure domain; Loom packages + BCL only src/MyApp./ host; every slice lives here src/MyApp.AppHost/ Aspire orchestration; dev-time only, ships nothing @@ -27,7 +27,7 @@ A **slice** is one operation. One file, one namespace, holding its request, resp handler, and entry point together. **The stack.** Versions live in `Directory.Packages.props`, never here and never in a `.csproj`. -Loom packages install as `CodeByDylan.`; the identifier is prefixed, the namespace named below is not. +Loom packages install under their full identifier, `CodeByDylan.Loom.`; the namespace named below drops the `CodeByDylan.` prefix. | Concern | Choice | Notes | | --- | --- | --- | @@ -130,7 +130,7 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura - **Typed options only.** One `Options` class per concern with a `const string SectionName`. - **Validate at startup:** `.ValidateDataAnnotations().ValidateOnStart()`. A misconfigured app must fail to boot, not fail on the first request that touches the bad setting. -- **`IConfiguration` appears only in `Program.cs`.** Injecting it anywhere else is a defect. +- **`IConfiguration` is read only in the composition root** — `Program.cs`, and the startup extensions it calls on the builder, such as `ServiceDefaults`. **Never inject it into a type resolved from the container**; bind a typed options class and inject that. The distinction is what the rule protects: reading a value while composing the application is composition, whereas a service reaching for configuration at run time hides a dependency the constructor does not declare. - **Secrets:** user-secrets locally, environment variables when deployed. Never in `appsettings*.json`, including `Development`. - **Authorization policies are named constants** in a `Policies` static class. No inline role or claim strings at call sites. - **Authorization that depends on domain state belongs in the handler**, returning a `Forbidden` error. "Can this user cancel *this* order" needs the order, so it cannot be an attribute. @@ -138,6 +138,11 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura > **UNDECIDED:** Which identity provider issues tokens. Driven by the deployment environment, > so the template does not choose. ASP.NET Core Identity is out of scope — self-hosting > accounts, resets, and MFA is a project-defining decision, not a default. +> +> The routes are already closed: endpoints map into a group carrying `RequireAuthorization()`, and +> **no authentication scheme is registered until you add one**. Any endpoint that does not +> `AllowAnonymous()` will fault rather than refuse until that is done. Register the scheme first, +> then remove the opt-outs from the example slices. ## 9. Observability @@ -161,7 +166,7 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura 1. `Domain` references nothing but Loom packages and the BCL. 2. No slice namespace depends on another slice namespace. 3. Domain entities appear in no request or response type's public surface. -4. `IConfiguration` is referenced only from `Program.cs`. +4. `IConfiguration` is a constructor parameter of no type — it is read in the composition root or not at all. 5. Every `IHandler<,>` implementation has a matching DI registration. A structural rule that is not in this list is a rule that will erode. If you add a structural diff --git a/docs/agents/10-worker.md b/docs/agents/10-worker.md index d1471c8..60ae18a 100644 --- a/docs/agents/10-worker.md +++ b/docs/agents/10-worker.md @@ -8,10 +8,13 @@ Applies to `src/MyApp.Worker/`. - **`BackgroundService` + `PeriodicTimer`.** No scheduling framework by default — most workers are "every N minutes" or "drain this," and the BCL does both with no dependencies. - **Escalate to Quartz.NET only for a stated need:** cron expressions, clustering, or persistent job state. Adding it speculatively buys a database table and a configuration surface you do not want. - **Hangfire is out.** `Hangfire.Core` is LGPL v3, which is a licence to adopt deliberately rather than inherit, and its Pro tier is paid. Its dashboard was compensating for missing observability, which Aspire already provides. -- **`ExecuteAsync` contains no business logic.** It is a loop that resolves a scope and dispatches to a handler — exactly the position an endpoint occupies in the API archetype. +- **`ExecuteAsync` contains no business logic.** It ticks, runs one pass, and refuses to let a failure end the loop. Resolving a scope and dispatching to a handler happens a level down in `RunOnceAsync` — that is the position an endpoint occupies in the API archetype, and keeping the two apart is what makes either testable without the other. ```csharp -internal sealed class ReconcileOrdersWorker(IServiceScopeFactory scopes, TimeProvider clock) +internal sealed partial class ReconcileOrdersWorker( + IServiceScopeFactory scopes, + TimeProvider clock, + ILogger logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken ct) @@ -19,12 +22,37 @@ internal sealed class ReconcileOrdersWorker(IServiceScopeFactory scopes, TimePro using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5), clock); while (await timer.WaitForNextTickAsync(ct)) { - await using var scope = scopes.CreateAsyncScope(); - var handler = scope.ServiceProvider.GetRequiredService>(); - var result = await handler.HandleAsync(new Request(), ct); - // map result category to retry / dead-letter / log — never throw to signal it + await RunGuardedAsync(ct); } } + + // internal, not private: the testing rules below call this directly, so the test assembly needs + // an InternalsVisibleTo. + internal async Task RunGuardedAsync(CancellationToken ct) + { + try + { + await RunOnceAsync(ct); + } + catch (Exception exception) + when (exception is not OperationCanceledException || !ct.IsCancellationRequested) + { + // Swallowed, never silent. Only cancellation that shutdown asked for ends the loop. + Failed(logger, exception); + } + } + + // ExecuteAsync schedules and keeps the loop alive; one pass lives here. + internal async Task RunOnceAsync(CancellationToken ct) + { + await using var scope = scopes.CreateAsyncScope(); + var handler = scope.ServiceProvider.GetRequiredService>(); + var result = await handler.HandleAsync(new Request(), ct); + // map result category to retry / dead-letter / log — never throw to signal it + } + + [LoggerMessage(Level = LogLevel.Error, Message = "A pass threw and was swallowed to keep the worker alive.")] + private static partial void Failed(ILogger logger, Exception exception); } ``` @@ -41,4 +69,5 @@ internal sealed class ReconcileOrdersWorker(IServiceScopeFactory scopes, TimePro ### Testing - **Handlers are tested directly**, against Testcontainers Postgres, with Respawn between tests. There is no transport to go through, so the handler *is* the entry point. -- **Test the schedule separately from the work.** Inject a fake `TimeProvider` and assert the loop dispatches; do not sleep in tests. +- **Separate the pass from the schedule, and test the pass.** `RunOnceAsync` holds everything decided by a result — the scope, the dispatch, retry against dead-letter — so it can be exercised against a stub handler with no clock and no timer. Make the retry backoff a setting so a test can set it to zero. +- **Test one guarded iteration, not the timer.** Extract the `try`/`catch` into an `internal` method — with `` on the host project — so a test can call it directly and assert which exceptions survive it. That a failing pass does not end the loop is your logic; that `PeriodicTimer` fires is the BCL's. Driving a real `BackgroundService` from a fake clock needs the scheduler to hand off between advancing the clock and the loop resuming — a test that does it is flaky unless it sleeps, and then it is a slow test of someone else's code. diff --git a/scripts/check-docs.sh b/scripts/check-docs.sh index 2eea0ef..3cc2c0e 100755 --- a/scripts/check-docs.sh +++ b/scripts/check-docs.sh @@ -53,12 +53,29 @@ else fi fi -# 3. The repository's own guidance starts with its heading. +# 3. The AGENTS.md baked into each template still matches what the fragments would produce. The +# template ships a finished file rather than assembling one at scaffold time, so an edit to +# docs/agents/ would otherwise reach consumers only whenever someone happened to regenerate it. +for template in src/Loom.Templates/templates/*/; do + archetype="${template#src/Loom.Templates/templates/loom-}" + archetype="${archetype%/}" + + if ! scripts/new-agents-md.sh --out "$assembled/template-$archetype.md" "$archetype" >/dev/null 2>&1; then + fail "cannot assemble the $archetype template's guidance" + continue + fi + + if ! diff -q "$assembled/template-$archetype.md" "$template/AGENTS.md" >/dev/null 2>&1; then + fail "$template/AGENTS.md is stale; regenerate it with: scripts/new-agents-md.sh --out $template/AGENTS.md $archetype --force" + fi +done + +# 4. The repository's own guidance starts with its heading. if ! head -1 AGENTS.md | grep -qxF '# AGENTS.md'; then fail "AGENTS.md: line 1 must be '# AGENTS.md'" fi -# 4. Fused-line signatures, across every tracked Markdown file. Concatenating a table row onto +# 5. Fused-line signatures, across every tracked Markdown file. Concatenating a table row onto # something else leaves no space, which is what makes these precise rather than heuristic. while IFS= read -r document; do if grep -nE '\|(#| + + + + + + + + + + false + + + + + <_PinnedTemplates>$(IntermediateOutputPath)pinned\ + + + + + + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-api/.editorconfig b/src/Loom.Templates/templates/loom-api/.editorconfig new file mode 100644 index 0000000..f049525 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/.editorconfig @@ -0,0 +1,106 @@ +root = true + +# Formatting and code style for Loom. This file — not AGENTS.md — is the authority +# on style. `dotnet format` fixes violations; CI fails on any remaining difference. +# If you want to change how code looks, change it here, not in prose. + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{json,yml,yaml,md}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.{csproj,props,targets,slnx}] +indent_size = 4 + +[*.cs] +indent_size = 4 + +#### C# language conventions #### + +csharp_style_namespace_declarations = file_scoped:error +csharp_using_directive_placement = outside_namespace:error +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:suggestion + +csharp_prefer_braces = true:error +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_accessors = true:suggestion + +csharp_style_prefer_pattern_matching = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_throw_expression = true:suggestion + +dotnet_style_readonly_field = true:error +dotnet_style_require_accessibility_modifiers = for_non_interface_members:error +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion + +# Noise. These fire constantly on correct code and derail agents under +# TreatWarningsAsErrors; they are style preferences, not defects. +dotnet_diagnostic.IDE0058.severity = none +dotnet_diagnostic.IDE0022.severity = none +dotnet_diagnostic.IDE0055.severity = suggestion + +#### Naming #### + +dotnet_naming_rule.interfaces_start_with_i.severity = error +dotnet_naming_rule.interfaces_start_with_i.symbols = interfaces +dotnet_naming_rule.interfaces_start_with_i.style = prefix_i_pascal +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_style.prefix_i_pascal.required_prefix = I +dotnet_naming_style.prefix_i_pascal.capitalization = pascal_case + +dotnet_naming_rule.types_are_pascal_case.severity = error +dotnet_naming_rule.types_are_pascal_case.symbols = types_and_members +dotnet_naming_rule.types_are_pascal_case.style = pascal +dotnet_naming_symbols.types_and_members.applicable_kinds = class,struct,enum,property,method,event,delegate +dotnet_naming_style.pascal.capitalization = pascal_case + +# Declared before the general private-field rule: the first matching rule wins, and constants and +# static readonly fields are PascalCase by convention throughout the BCL. +dotnet_naming_rule.private_constants_are_pascal.severity = error +dotnet_naming_rule.private_constants_are_pascal.symbols = private_constants +dotnet_naming_rule.private_constants_are_pascal.style = pascal +dotnet_naming_symbols.private_constants.applicable_kinds = field +dotnet_naming_symbols.private_constants.applicable_accessibilities = private +dotnet_naming_symbols.private_constants.required_modifiers = const + +dotnet_naming_rule.private_static_readonly_fields_are_pascal.severity = error +dotnet_naming_rule.private_static_readonly_fields_are_pascal.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_are_pascal.style = pascal +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = static,readonly + +dotnet_naming_rule.private_fields_are_underscore_camel.severity = error +dotnet_naming_rule.private_fields_are_underscore_camel.symbols = private_fields +dotnet_naming_rule.private_fields_are_underscore_camel.style = underscore_camel +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_style.underscore_camel.required_prefix = _ +dotnet_naming_style.underscore_camel.capitalization = camel_case + +#### Tests #### + +[tests/**/*.cs] +# Test method names are prose, not identifiers: Returns_Failure_When_Value_Is_Null. +dotnet_naming_rule.types_are_pascal_case.severity = none diff --git a/src/Loom.Templates/templates/loom-api/.gitignore b/src/Loom.Templates/templates/loom-api/.gitignore new file mode 100644 index 0000000..0198775 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +TestResults/ +artifacts/ +.vs/ +.idea/ +*.user diff --git a/src/Loom.Templates/templates/loom-api/.template.config/template.json b/src/Loom.Templates/templates/loom-api/.template.config/template.json new file mode 100644 index 0000000..309f1dd --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/.template.config/template.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://json.schemastore.org/template", + "author": "Dylan de Beer", + "classifications": [ + "Web", + "WebAPI", + "Solution", + "Loom" + ], + "identity": "CodeByDylan.Loom.Api.CSharp", + "name": "Loom HTTP API solution", + "shortName": "loom-api", + "description": "A solution built on Loom: a pure domain, one host of vertical slices, Aspire orchestration, and the guidance for working in it already assembled into AGENTS.md.", + "tags": { + "language": "C#", + "type": "solution" + }, + "preferNameDirectory": true, + "defaultName": "MyApp", + "symbols": { + "safeName": { + "type": "generated", + "generator": "regex", + "dataType": "string", + "replaces": "MyApp", + "fileRename": "MyApp", + "parameters": { + "source": "name", + "steps": [ + { + "regex": "[^A-Za-z0-9_.]", + "replacement": "_" + }, + { + "regex": "(^|\\.)([0-9])", + "replacement": "$1_$2" + } + ] + } + }, + "userSecretsId": { + "type": "generated", + "generator": "guid", + "replaces": "9f2a6c1e-0000-0000-0000-000000000001", + "parameters": { + "format": "D" + } + }, + "hostProjectClass": { + "type": "generated", + "generator": "regex", + "dataType": "string", + "replaces": "HostProject", + "parameters": { + "source": "name", + "steps": [ + { + "regex": "[^A-Za-z0-9_]", + "replacement": "_" + }, + { + "regex": "^([0-9])", + "replacement": "_$1" + } + ] + } + }, + "skipRestore": { + "type": "parameter", + "datatype": "bool", + "defaultValue": "false", + "displayName": "Skip restore", + "description": "Do not run dotnet restore after the solution is created." + } + }, + "postActions": [ + { + "id": "restore", + "condition": "(!skipRestore)", + "description": "Restoring the solution.", + "manualInstructions": [ + { + "text": "Run 'dotnet restore'." + } + ], + "actionId": "210D431B-A78B-4D2F-B762-4ED3E3EA9025", + "continueOnError": true + } + ] +} diff --git a/src/Loom.Templates/templates/loom-api/AGENTS.md b/src/Loom.Templates/templates/loom-api/AGENTS.md new file mode 100644 index 0000000..63f0aa0 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/AGENTS.md @@ -0,0 +1,240 @@ +# AGENTS.md + +Rules for working on this project. Assembled from Loom's `docs/agents/` templates — this file +is yours now. Edit it freely. + +> **FILL IN:** One sentence on what this service does, and which archetypes it contains. + +## 1. Orientation + +```text +src/MyApp.Domain/ pure domain; Loom packages + BCL only +src/MyApp./ host; every slice lives here +src/MyApp.AppHost/ Aspire orchestration; dev-time only, ships nothing +src/MyApp.ServiceDefaults/ Aspire wiring; your code, edit it +tests/MyApp.Domain.Tests/ +tests/MyApp..Tests/ +tests/MyApp.ArchitectureTests/ +``` + +This is Clean Architecture reduced to its one boundary worth enforcing at compile time — +`Domain` purity — with vertical slices for everything else. The `Application`/`Infrastructure` +split is deliberately absent: it shreds a slice across two projects and cancels the point of +slicing. + +A **slice** is one operation. One file, one namespace, holding its request, response, validator, +handler, and entry point together. + +**The stack.** Versions live in `Directory.Packages.props`, never here and never in a `.csproj`. +Loom packages install under their full identifier, `CodeByDylan.Loom.`; the namespace named below drops the `CodeByDylan.` prefix. + +| Concern | Choice | Notes | +| --- | --- | --- | +| Data | EF Core + Npgsql, `Loom.Persistence.EntityFrameworkCore` | Postgres. No repositories. | +| Queries | `Loom.Specifications`, `Loom.Paging` | Named rules applied to a query the slice owns. | +| Validation | FluentValidation | Request shape only, never domain rules. | +| Dispatch | `Loom.Handlers` | No mediator. Handlers + one global decorator chain. | +| Mapping | Manual | Mapperly only for large mechanical maps. | +| Logging | `ILogger` + OpenTelemetry | No Serilog. | +| Orchestration | Aspire | Dev-time. Not in the request path. | +| Tests | TUnit, Testcontainers, Respawn | Plus NetArchTest for structure. | + +Deliberately absent: MediatR and AutoMapper (both now require paid licences), Wolverine and +FastEndpoints (frameworks that would own request handling), Hangfire (LGPL, and Aspire covers +the observability its dashboard compensated for). + +## 2. Hard constraints + +1. **`Domain` references only Loom packages and the BCL.** No EF Core, no ASP.NET, no FluentValidation, no DI container. +2. **Never throw for expected failures.** Return a `Loom.Results` value. +3. **No slice references another slice.** Shared code within an aggregate goes in `_Shared.cs`. Anything two aggregates want belongs in `Domain`. +4. **Entities never cross the transport boundary.** Requests and responses are slice-owned types. +5. **`UNDECIDED` and `FILL IN` mean stop and ask.** Do not resolve them yourself and do not silently pick a convention. +6. **Run the §3 verify block before claiming done.** CI runs the same block; a green claim over a red build is a lie. +7. **Never edit this file to resolve a conflict between a rule and your code.** If a rule blocks you, say so. + +## 3. Build and verify + +```bash +dotnet format # fixes formatting in place +dotnet build # warnings are errors +dotnet test # TUnit +dotnet format --verify-no-changes # confirms nothing is left unformatted +``` + +- Run `dotnet format` (fixing), not verify-only. Never hand-edit whitespace to satisfy the check. +- Revert formatting-only changes to files your work didn't otherwise touch. +- `.editorconfig` is the authority on code style. To change how code looks, edit it there. Do not add style rules to this file. + +## 4. Architecture + +- **One file per operation:** `Features//.cs`. +- **One namespace per slice:** `namespace MyApp.Features.Orders.CreateOrder;`. This is what makes slice isolation mechanically enforceable (§11) rather than a review convention. +- **A slice over ~250 lines means the operation is doing too much.** Split the operation, not the file. A genuine helper gets a sibling file in the same folder, never a new folder. +- **`Features//_Shared.cs`** is the only permitted cross-slice sharing, and only within one aggregate. +- **Entry points are thin adapters.** An endpoint, a `BackgroundService`, or a CLI command validates nothing, decides nothing, and queries nothing — it adapts input and dispatches to a handler. +- **Request and response types are private to their slice.** If two slices want the same shape, they get two identical types. This looks wasteful and is the rule that keeps slices independent; a shared response DTO is how one slice's requirements start dictating another's. + +## 5. Domain + +- **Invariants live in `Domain` and return `Loom.Results`.** Not in validators, not in handlers, and never signalled by an exception. +- **Specifications live in `Domain` and derive from `Specification`.** A specification is a *named business rule* — `OverdueOrders(customerId)` — configured entirely in its constructor. It may carry a predicate, eager-loading, and ordering; never paging. +- **Never give a specification a flag that toggles part of its query.** That is two rules sharing a name. Write two specifications. +- **Combine predicates with `Criteria.And`/`Or`/`Not`, not whole specifications.** Two specifications with conflicting ordering have no sensible combination. +- **No persistence attributes on entities.** Mapping is configured host-side via `IEntityTypeConfiguration`. +- **`Domain` has no async I/O.** No `Task`-returning methods that reach outside memory. +- **Entities derive from `Entity`; aggregate roots from `AggregateRoot`.** Identity is `Id`, assigned at construction, never default. +- **Give every entity a private parameterless constructor for EF to materialise through.** EF writes the identity from the column, so the one minted in that constructor is discarded and the object stays attached to its row. Reconstructing through an `Id` constructor instead makes EF unable to choose between it and any other one-parameter constructor, and the model fails to build. +- **Entities are classes. Value objects and domain events are `sealed record`s.** Entities compare by identity, so structural equality is wrong for them; everything else in the domain is value-like and records are right. +- **Only aggregate roots get a `DbSet<>`.** Child entities are reached through their root. +- **`Id` ordering is not creation order.** Version 7 GUIDs are only millisecond-granular and are not monotonic within a millisecond. Never paginate on an id, and never use one to decide what happened first — sort on an explicit timestamp column. +- **Declare `WithLogging()` first in the chain, so it sits outermost.** Anything declared before it goes unrecorded — including a request refused by validation, which is the outcome most worth seeing. The level follows the failure's category: a refusal is information, an unavailable dependency is a warning, a success is debug. The request's *type name* is recorded and its contents never are, with no option to change that. +- **Declare `WithDomainEventFailures()` last in the chain, after `WithValidation()`.** A domain event handler reporting a failure abandons the save, which an object-relational mapper can only express by throwing; this decorator turns it back into the failure the handler reported, so the caller sees the right category instead of a server error. It must sit innermost, or it will also swallow exceptions from other decorators and report them as domain event failures. +- **An outbox needs a retention schedule, or it grows forever.** Nothing deletes a delivered message on its own. Call `OutboxAdministration.PurgeDeliveredAsync` on a timer, with an interval you have chosen; it never touches a message that is still owed or one that was abandoned. +- **Recover an abandoned message through `OutboxAdministration`, never by hand.** Retrying resets the attempt count and keeps the error, which is easy to get backwards in a hand-written statement: clear the error and the evidence is gone, leave the count and it abandons again on the first failure. +- **Ordinary domain events dispatch before the commit; deferred ones after.** An ordinary handler may change data atomically with the operation but must not reach outside the process, because a rollback cannot unsend an email. A `IDeferredDomainEvent` handler may reach outside the process and **must be idempotent**, since delivery is at least once. Which one applies is declared on the event. + +Register identity conversion once per assembly, from `ConfigureConventions` — not `OnModelCreating`, where discovery has already skipped identities that are not keys: + +```csharp +protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) => + configurationBuilder.UseLoomIdentities(typeof(Order).Assembly); +``` + +## 6. Persistence + +- **One `AppDbContext`**, in the host. `IEntityTypeConfiguration` classes co-located per aggregate. +- **Inject `DbContext` into handlers directly. No repositories.** `DbContext` is already a unit of work and `DbSet` is already a repository; wrapping them produces passthrough interfaces and destroys the `IQueryable` composition that makes `Loom.Specifications` work. +- **Reads project in the query:** `AsNoTracking()` then `.Select(...)` straight into the slice's response type. Never materialise an entity in order to map it — that is the most common performance defect in EF codebases. +- **Migrations are generated with `dotnet ef`, reviewed by a human, and applied deliberately.** Never `EnsureCreated()`, never auto-migrate on startup in a deployed environment. +- Dapper is permitted for a specific query that demands it, as a documented exception. It is not a second default. +- **Apply specifications to the query the slice owns.** `db.Orders.ApplySpecification(new OverdueOrders(id))` — never hand a specification to something that queries on your behalf. Reintroducing a repository is the one way this design fails. The name differs from the specification package's own `Apply` deliberately; that one cannot honour eager loading and refuses rather than silently dropping it. +- **Paging is the caller's decision, applied after the specification:** `.ApplySpecification(spec).ToPageAsync(request, ct)`. Return `Page` so every endpoint reports paging identically. +- **`PageRequest` validates itself, including a maximum size.** Never accept a raw page size from a query string without it — `?size=1000000` returns the table. + +## 7. Validation and errors + +- **FluentValidation for request shape** — format, ranges, required fields, cross-field consistency. One validator per slice, in the slice file. +- **Validation runs through `Loom.Handlers.FluentValidation`'s decorator**, not an endpoint filter. A decorator behaves identically in an API, a worker, and a CLI; a filter only exists in the first. It is enabled once, for every handler: `services.AddLoomHandlers(chain => chain.WithValidation())`. +- **Domain rules are never FluentValidation.** If a rule needs domain knowledge, it belongs in §5. +- **Every error carries one of six categories:** `NotFound`, `Conflict`, `Invalid`, `Unauthorized`, `Forbidden`, `Unavailable`. These are semantic, not transport-specific, which is what lets one category become a status code in an API, a retry-or-dead-letter decision in a worker, and an exit code in a CLI. +- **The set is closed.** A seventh category means the taxonomy has become a status-code enum. Carry the specifics as metadata on `Error` instead. +- **A failed result carries exactly one error.** Several validation failures are one `ValidationError` whose metadata is a field→messages map, which maps straight onto the ProblemDetails `errors` extension. +- **Never serialize a `Result`.** It is a control-flow type; response types cross the wire. Serializers reflect over public members, and reading `Value` on a failure throws from inside the serializer. +- **Never ignore a returned `Result`.** Write `_ = ...` when the outcome really is of no interest — at which point you have said so, which is the whole point. Do not silence the rule to avoid the sentence. +- **`LOOM0001` covers a `Result` discarded directly, and only that.** A statement whose value is a `Result` — including an awaited one — fails the build, since warnings are errors here. Two cases it does not see: a `Task` that is never awaited, and a `Result` discarded as the body of a void-returning lambda. Those still need reading for, so do not treat a clean build as proof that no outcome was dropped. +- **Category-to-transport mapping comes from a Loom package, never inline in a slice.** In an API that is `Loom.Results.AspNetCore`: `result.ToHttpResult()`. If a project wants different titles or extra members, it builds the problem details with `ToProblemDetails()` and changes it — the package has no options type on purpose. + +## 8. Configuration and authorization + +- **Typed options only.** One `Options` class per concern with a `const string SectionName`. +- **Validate at startup:** `.ValidateDataAnnotations().ValidateOnStart()`. A misconfigured app must fail to boot, not fail on the first request that touches the bad setting. +- **`IConfiguration` is read only in the composition root** — `Program.cs`, and the startup extensions it calls on the builder, such as `ServiceDefaults`. **Never inject it into a type resolved from the container**; bind a typed options class and inject that. The distinction is what the rule protects: reading a value while composing the application is composition, whereas a service reaching for configuration at run time hides a dependency the constructor does not declare. +- **Secrets:** user-secrets locally, environment variables when deployed. Never in `appsettings*.json`, including `Development`. +- **Authorization policies are named constants** in a `Policies` static class. No inline role or claim strings at call sites. +- **Authorization that depends on domain state belongs in the handler**, returning a `Forbidden` error. "Can this user cancel *this* order" needs the order, so it cannot be an attribute. + +> **UNDECIDED:** Which identity provider issues tokens. Driven by the deployment environment, +> so the template does not choose. ASP.NET Core Identity is out of scope — self-hosting +> accounts, resets, and MFA is a project-defining decision, not a default. +> +> The routes are already closed: endpoints map into a group carrying `RequireAuthorization()`, and +> **no authentication scheme is registered until you add one**. Any endpoint that does not +> `AllowAnonymous()` will fault rather than refuse until that is done. Register the scheme first, +> then remove the opt-outs from the example slices. + +## 9. Observability + +- **Always an injected `ILogger`.** Never a static logger, never `LoggerFactory.Create` at a call site. +- **`[LoggerMessage]` source-generated log methods, not interpolated strings.** Interpolation allocates and boxes even when the level is disabled, and produces unstructured output. +- OpenTelemetry is configured once, in `ServiceDefaults`. That file is your code — edit it rather than working around it. + +## 10. Testing + +- **`Domain` gets unit tests.** Pure, fast, no infrastructure. +- **Every slice gets at least one integration test through its real entry point.** This is the load-bearing rule; correctness lives here, because there are no repository seams to unit-test against. +- **`WebApplicationFactory` + Testcontainers Postgres**, one container and database per test assembly, **Respawn between tests**. Not transaction-rollback isolation — handlers own their transactions, so rollback-based isolation will lie to you. +- **`Aspire.Hosting.Testing` for a handful of smoke tests only.** Booting the AppHost per test destroys the feedback loop. +- **Never mock `DbContext`.** Mocking your own code is a design smell; mocking a genuine external dependency is fine. +- **Outbound HTTP is stubbed at `HttpMessageHandler`**, or WireMock.Net when you need protocol fidelity. + +## 11. Enforcement + +`tests/MyApp.ArchitectureTests` asserts, with NetArchTest: + +1. `Domain` references nothing but Loom packages and the BCL. +2. No slice namespace depends on another slice namespace. +3. Domain entities appear in no request or response type's public surface. +4. `IConfiguration` is a constructor parameter of no type — it is read in the composition root or not at all. +5. Every `IHandler<,>` implementation has a matching DI registration. + +A structural rule that is not in this list is a rule that will erode. If you add a structural +rule to this file, add its test. + +## 12. Adding a slice + +1. Create `Features//.cs` with `namespace MyApp.Features..;`. +2. Write the request, the response, the validator, and the handler in that file. +3. Register the handler and its decorator chain. +4. Wire the entry point (see the archetype section below). +5. Write at least one integration test through the real entry point. +6. Run the §3 verify block. + +## 13. How to add a rule + +- One rule per bullet, imperative, self-contained. +- Add a **rationale only if the rule is surprising** — if the obvious instinct is the opposite. Obvious rules need no defending. +- Add a `// Do this` / `// Not this` snippet if a rule is easy to satisfy in letter and violate in spirit. +- Unsettled rules get a `> **UNDECIDED:**` callout, not a guess. +- **Budget: ~400 lines assembled.** Over budget means something moves out — into `.editorconfig`, an analyzer, or an architecture test. Growing past it is not an option; agents stop reading. +## A. HTTP API archetype + +Applies to `src/MyApp.Api/`. + +### Endpoints + +- **Minimal APIs.** No controllers — a controller groups operations horizontally, which fights the slice layout. +- **Each slice exposes its own route registration.** The slice implements the `IEndpoint` marker and its `Map` method registers exactly one route. +- **Endpoints are discovered by assembly scan at startup.** One call in `Program.cs` maps every `IEndpoint`. This is convenient and it is also invisible — nothing in `Program.cs` links to your endpoint, so a slice that fails to register does not fail to compile. +- **Because discovery is reflective, the route-table snapshot test is mandatory.** One test asserts the complete set of registered routes; a slice that silently fails to register breaks a test rather than 404-ing in production. Do not skip it, and do not "fix" a failure by deleting the assertion — update the snapshot deliberately. +- Reflective discovery means **native AOT and trimming are not available** in this archetype. Do not add `PublishAot`. + +```csharp +// Features/Orders/CreateOrder.cs +namespace MyApp.Features.Orders.CreateOrder; + +internal sealed record Request(string Sku, int Quantity); +internal sealed record Response(Guid OrderId); + +internal sealed class Validator : AbstractValidator { /* shape only */ } + +internal sealed class Handler(AppDbContext db) : IHandler +{ + public async Task> HandleAsync(Request request, CancellationToken ct) { /* ... */ } +} + +internal sealed class Endpoint : IEndpoint +{ + public static void Map(IEndpointRouteBuilder routes) => + routes.MapPost("/orders", async (Request request, IHandler handler, CancellationToken ct) + => (await handler.HandleAsync(request, ct)).ToHttpResult()); +} +``` + +### Responses + +- **Every non-2xx response is `ProblemDetails`** (RFC 9457). No bespoke error envelopes. +- **Validation failures populate the `errors` extension** rather than inventing a parallel shape. +- **`ToHttpResult()` is the single mapping point** from a `Loom.Results` category to a status code, and it comes from `Loom.Results.AspNetCore`. A slice never writes a status code itself. +- **A trace identifier comes from the framework, not from Loom.** `AddProblemDetails(options => options.CustomizeProblemDetails = ...)` runs for these responses, so enrich there rather than in a slice. +- Typed results (`Results, ProblemHttpResult>`) where they add OpenAPI accuracy; not as ceremony. + +### Security + +- **Route groups carry `RequireAuthorization()` by default**, with explicit `AllowAnonymous()` as the opt-out. The failure you want is "I forgot to open this up," never the reverse. +- Rate limiting and CORS are configured in `Program.cs`, never per-slice. + +### Testing + +- `WebApplicationFactory` against a Testcontainers Postgres, Respawn between tests. +- **Every slice gets an integration test that goes through HTTP**, not one that calls the handler directly. Calling the handler skips model binding, validation, authorization, and the result mapping — which is most of what can break. diff --git a/src/Loom.Templates/templates/loom-api/Directory.Build.props b/src/Loom.Templates/templates/loom-api/Directory.Build.props new file mode 100644 index 0000000..53fdca8 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/Directory.Build.props @@ -0,0 +1,15 @@ + + + + + true + true + net10.0 + enable + enable + latest + true + false + + + diff --git a/src/Loom.Templates/templates/loom-api/Directory.Packages.props b/src/Loom.Templates/templates/loom-api/Directory.Packages.props new file mode 100644 index 0000000..26a6fff --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/Directory.Packages.props @@ -0,0 +1,53 @@ + + + + true + true + + 0.1.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-api/MyApp.slnx b/src/Loom.Templates/templates/loom-api/MyApp.slnx new file mode 100644 index 0000000..f57a3fd --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/MyApp.slnx @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-api/global.json b/src/Loom.Templates/templates/loom-api/global.json new file mode 100644 index 0000000..9893006 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/global.json @@ -0,0 +1,9 @@ +{ + "sdk": { + "version": "10.0.302", + "rollForward": "latestFeature" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Features/Widgets/CreateWidget.cs b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Features/Widgets/CreateWidget.cs new file mode 100644 index 0000000..5f23ffc --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Features/Widgets/CreateWidget.cs @@ -0,0 +1,54 @@ +using FluentValidation; +using Loom.Entities; +using Loom.Handlers; +using Loom.Results; +using MyApp.Api.Infrastructure; +using MyApp.Domain.Widgets; + +namespace MyApp.Api.Features.Widgets.CreateWidget; + +// One operation, one file, one namespace. Request, response, validator, handler and route together, +// so everything an operation needs is in front of you and nothing else can reach it. + +internal sealed record Request(string Name, int Size); + +internal sealed record Response(Guid WidgetId); + +// Shape only. Whether the domain permits this widget is Widget.Create's business. +internal sealed class Validator : AbstractValidator +{ + public Validator() + { + RuleFor(request => request.Name).NotEmpty().MaximumLength(200); + RuleFor(request => request.Size).GreaterThan(0); + } +} + +internal sealed class Handler(AppDbContext database) : IHandler +{ + public async Task> HandleAsync(Request request, CancellationToken cancellationToken) + { + Result created = Widget.Create(request.Name, request.Size); + + if (created.IsFailure) + { + return created.Error; + } + + database.Widgets.Add(created.Value); + await database.SaveChangesAsync(cancellationToken); + + return new Response(created.Value.Id.Value); + } +} + +internal sealed class Endpoint : IEndpoint +{ + public static void Map(IEndpointRouteBuilder routes) => routes + .MapPost("/widgets", async ( + Request request, + IHandler handler, + CancellationToken cancellationToken) => + (await handler.HandleAsync(request, cancellationToken)).ToHttpResult()) + .AllowAnonymous(); +} diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Features/Widgets/GetWidget.cs b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Features/Widgets/GetWidget.cs new file mode 100644 index 0000000..3d87c73 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Features/Widgets/GetWidget.cs @@ -0,0 +1,39 @@ +using Loom.Entities; +using Loom.Handlers; +using Loom.Results; +using Microsoft.EntityFrameworkCore; +using MyApp.Api.Infrastructure; +using MyApp.Domain.Widgets; + +namespace MyApp.Api.Features.Widgets.GetWidget; + +internal sealed record Request(Id WidgetId); + +internal sealed record Response(Guid WidgetId, string Name, int Size); + +internal sealed class Handler(AppDbContext database) : IHandler +{ + public async Task> HandleAsync(Request request, CancellationToken cancellationToken) + { + // Projected in the query rather than materialised and mapped. + Response? found = await database.Widgets + .AsNoTracking() + .Where(widget => widget.Id == request.WidgetId) + .Select(widget => new Response(widget.Id.Value, widget.Name, widget.Size)) + .SingleOrDefaultAsync(cancellationToken); + + return found is null ? WidgetErrors.NotFound : found; + } +} + +internal sealed class Endpoint : IEndpoint +{ + // Id is IParsable, so it binds from the route without a TypeConverter. + public static void Map(IEndpointRouteBuilder routes) => routes + .MapGet("/widgets/{widgetId}", async ( + Id widgetId, + IHandler handler, + CancellationToken cancellationToken) => + (await handler.HandleAsync(new Request(widgetId), cancellationToken)).ToHttpResult()) + .AllowAnonymous(); +} diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Features/Widgets/WidgetConfiguration.cs b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Features/Widgets/WidgetConfiguration.cs new file mode 100644 index 0000000..66fa536 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Features/Widgets/WidgetConfiguration.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MyApp.Domain.Widgets; + +namespace MyApp.Api.Features.Widgets; + +/// +/// How a widget is stored. +/// +/// +/// Host-side and beside the aggregate's slices, so the mapping lives where the aggregate is worked on +/// rather than accumulating in one method that grows with every entity. The domain stays free of +/// persistence: it carries no attributes and no reference to Entity Framework. +/// +internal sealed class WidgetConfiguration : IEntityTypeConfiguration +{ + void IEntityTypeConfiguration.Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.HasKey(widget => widget.Id); + builder.Property(widget => widget.Name).HasMaxLength(200).IsRequired(); + } +} diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Infrastructure/AppDbContext.cs b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Infrastructure/AppDbContext.cs new file mode 100644 index 0000000..f35f8cd --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Infrastructure/AppDbContext.cs @@ -0,0 +1,29 @@ +using Loom.Persistence; +using Microsoft.EntityFrameworkCore; +using MyApp.Domain.Widgets; + +namespace MyApp.Api.Infrastructure; + +/// +/// The one context for the application. +/// +public sealed class AppDbContext(DbContextOptions options) : DbContext(options) +{ + /// Gets the widgets. Only aggregate roots get a set of their own. + public DbSet Widgets => Set(); + + // Conventions, not model creation. Property discovery skips types it does not recognise, so an + // identity that is not a primary key would never enter the model at all. + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) => + configurationBuilder.UseLoomIdentities(typeof(Widget).Assembly); + + // Discovered rather than listed, so adding an aggregate means adding its configuration beside its + // slices and nothing here. A mapping written inline would grow this method with every entity and + // put it a long way from the code that uses it. + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + } +} diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Infrastructure/DesignTimeDbContextFactory.cs b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Infrastructure/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..996fbb7 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Infrastructure/DesignTimeDbContextFactory.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace MyApp.Api.Infrastructure; + +/// +/// Builds a context for the migration tooling, without starting the application. +/// +/// +/// Migrations are generated against a connection string that is never connected to, so `dotnet ef` +/// needs neither a running database nor the application's configuration. +/// +internal sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + AppDbContext IDesignTimeDbContextFactory.CreateDbContext(string[] args) + { + DbContextOptionsBuilder builder = new(); + builder.UseNpgsql("Host=design-time;Database=designtime;Username=none;Password=none"); + + return new AppDbContext(builder.Options); + } +} diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Infrastructure/IEndpoint.cs b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Infrastructure/IEndpoint.cs new file mode 100644 index 0000000..c7b90b2 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Infrastructure/IEndpoint.cs @@ -0,0 +1,31 @@ +namespace MyApp.Api.Infrastructure; + +/// +/// Implemented by a slice to register its own route. +/// +/// +/// Discovery is reflective, so nothing in Program.cs links to an endpoint and a slice that +/// fails to register does not fail to compile. That is what makes the route-table test mandatory +/// rather than nice to have. +/// +internal interface IEndpoint +{ + /// Registers exactly one route. + static abstract void Map(IEndpointRouteBuilder routes); +} + +/// Maps every in an assembly. +internal static class EndpointExtensions +{ + /// Finds and maps every endpoint the assembly declares. + public static void MapEndpoints(this IEndpointRouteBuilder routes, System.Reflection.Assembly assembly) + { + ArgumentNullException.ThrowIfNull(assembly); + + foreach (Type type in assembly.GetTypes().Where(candidate => + candidate is { IsAbstract: false, IsInterface: false } && candidate.IsAssignableTo(typeof(IEndpoint)))) + { + type.GetMethod(nameof(IEndpoint.Map))?.Invoke(null, [routes]); + } + } +} diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Migrations/.editorconfig b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Migrations/.editorconfig new file mode 100644 index 0000000..71803a5 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Migrations/.editorconfig @@ -0,0 +1,8 @@ +# Migrations are emitted by the tooling, not written by hand, so the repository's style rules do not +# apply to them. Reformatting them would only be undone by the next generated migration. +[*.cs] +generated_code = true +dotnet_analyzer_diagnostic.severity = none +dotnet_diagnostic.IDE0161.severity = none +dotnet_diagnostic.IDE0300.severity = none +dotnet_diagnostic.IDE0028.severity = none diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Migrations/20260731040620_InitialSchema.Designer.cs b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Migrations/20260731040620_InitialSchema.Designer.cs new file mode 100644 index 0000000..c1d0133 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Migrations/20260731040620_InitialSchema.Designer.cs @@ -0,0 +1,48 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyApp.Api.Infrastructure; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MyApp.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260731040620_InitialSchema")] + partial class InitialSchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MyApp.Domain.Widgets.Widget", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Widgets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Migrations/20260731040620_InitialSchema.cs b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Migrations/20260731040620_InitialSchema.cs new file mode 100644 index 0000000..d793e57 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Migrations/20260731040620_InitialSchema.cs @@ -0,0 +1,35 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MyApp.Api.Migrations +{ + /// + public partial class InitialSchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Widgets", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Size = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Widgets", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Widgets"); + } + } +} diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Migrations/AppDbContextModelSnapshot.cs b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..a59f635 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,45 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyApp.Api.Infrastructure; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MyApp.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MyApp.Domain.Widgets.Widget", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Widgets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/MyApp.Api.csproj b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/MyApp.Api.csproj new file mode 100644 index 0000000..fda5a12 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/MyApp.Api.csproj @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + all + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Program.cs b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Program.cs new file mode 100644 index 0000000..3018c0b --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Program.cs @@ -0,0 +1,70 @@ +using FluentValidation; +using Loom.Handlers; +using Loom.Persistence; +using Microsoft.EntityFrameworkCore; +using MyApp.Api.Infrastructure; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Telemetry, health, resilience and service discovery, from the scaffolded defaults project. +builder.AddServiceDefaults(); + +// Read once and refused here, so the failure lands at startup rather than on the first request. +// The AppHost supplies this in development. +string connectionString = builder.Configuration.GetConnectionString("database") + ?? throw new InvalidOperationException( + "No 'database' connection string was configured. The AppHost provides one when running locally; " + + "a deployment supplies it through configuration."); + +builder.Services.AddDbContext((serviceProvider, options) => options + .UseNpgsql(connectionString) + .AddInterceptors(serviceProvider.GetRequiredService())); + +builder.Services.AddLoomPersistence(); + +// The chain is declared once and applies to every handler, so a slice cannot be registered without +// validation by forgetting a call. Declaration order is nesting order: logging outermost, so nothing +// goes unrecorded — including a request refused by validation, which is the outcome most worth seeing. +builder.Services + .AddLoomHandlers(chain => chain + .WithLogging() + .WithValidation() + // Innermost, so it only sees the handler's own save. Without it, a domain event handler that + // reports a failure escapes as an unhandled exception and every such failure surfaces as a + // 500 — even though nothing exceptional happened. + .WithDomainEventFailures()) + .AddHandler() + .AddHandler(); + +// includeInternalTypes matters: slice validators are internal, and without it none are registered. +// The validating decorator treats a missing validator as nothing to validate, so the omission would +// be silent — which is why ValidatorRegistrationTests asserts every validator is resolvable. +builder.Services.AddValidatorsFromAssemblyContaining(ServiceLifetime.Scoped, includeInternalTypes: true); + +builder.Services.AddProblemDetails(); + +builder.Services.AddAuthorization(); + +WebApplication app = builder.Build(); + +app.UseAuthorization(); + +app.MapDefaultEndpoints(); + +// Every slice is mapped into a group that requires authorization, so the failure you get is "I forgot +// to open this up" rather than the reverse. A slice opts out with AllowAnonymous, as both examples do. +// +// > **UNDECIDED:** which identity provider issues tokens. Until one is chosen no authentication scheme +// > is registered, so an endpoint that does not opt out has nothing to authenticate against. +app.MapGroup(string.Empty) + .RequireAuthorization() + .MapEndpoints(typeof(Program).Assembly); + +await app.RunAsync(); + +// Exposed so the test host can reference this assembly through WebApplicationFactory. +public sealed partial class Program; diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/appsettings.json b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/appsettings.json new file mode 100644 index 0000000..d25b09f --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/appsettings.json @@ -0,0 +1,3 @@ +{ + "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } } +} diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.AppHost/AppHost.cs b/src/Loom.Templates/templates/loom-api/src/MyApp.AppHost/AppHost.cs new file mode 100644 index 0000000..4f052ca --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.AppHost/AppHost.cs @@ -0,0 +1,21 @@ +using Aspire.Hosting; + +// Development-time orchestration only. Nothing here ships: it starts a database and the service, and +// wires the connection string between them so that no developer has to keep one in a file. +IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args); + +// Resource names are deliberately not derived from the project name. Aspire allows only letters, +// digits and hyphens, and a solution called Acme.Billing would produce "acme.billing" — rejected at +// build time. These are local to the AppHost and referenced by name, so nothing is gained by +// repeating the application's name in them. +var postgres = builder.AddPostgres("postgres").WithDataVolume(); + +var database = postgres.AddDatabase("database"); + +// HostProject is substituted with the solution name, dots replaced by underscores, because that is +// how the Aspire SDK names the class it generates for a project reference. +builder.AddProject("api") + .WithReference(database) + .WaitFor(database); + +await builder.Build().RunAsync(); diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.AppHost/MyApp.AppHost.csproj b/src/Loom.Templates/templates/loom-api/src/MyApp.AppHost/MyApp.AppHost.csproj new file mode 100644 index 0000000..1dfcef2 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.AppHost/MyApp.AppHost.csproj @@ -0,0 +1,22 @@ + + + + + + + Exe + 9f2a6c1e-0000-0000-0000-000000000001 + + + + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Domain/MyApp.Domain.csproj b/src/Loom.Templates/templates/loom-api/src/MyApp.Domain/MyApp.Domain.csproj new file mode 100644 index 0000000..c514aad --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Domain/MyApp.Domain.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Domain/Widgets/Widget.cs b/src/Loom.Templates/templates/loom-api/src/MyApp.Domain/Widgets/Widget.cs new file mode 100644 index 0000000..7e60d80 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Domain/Widgets/Widget.cs @@ -0,0 +1,49 @@ +using Loom.Entities; +using Loom.Results; + +namespace MyApp.Domain.Widgets; + +/// +/// An example aggregate. Replace it — it exists to show the shape, not to be kept. +/// +public sealed class Widget : AggregateRoot +{ + private Widget(string name, int size) + { + Name = name; + Size = size; + } + + // Entity Framework materialises through this. It writes the identity from the column, so a + // parameterless constructor stays unambiguous whatever the domain constructors look like. + private Widget() + { + } + + public string Name { get; private set; } = string.Empty; + + public int Size { get; private set; } + + /// + /// Creates a widget, or reports why it could not be created. + /// + /// + /// A factory returning a result rather than a constructor throwing: an invalid request is an + /// expected outcome, and expected outcomes are values. The invariant lives here, not in a + /// validator — a validator checks the shape of a request, not whether the domain permits it. + /// + public static Result Create(string name, int size) + { + if (string.IsNullOrWhiteSpace(name)) + { + return WidgetErrors.NameRequired; + } + + if (size <= 0) + { + return WidgetErrors.SizeMustBePositive; + } + + return new Widget(name, size); + } +} diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Domain/Widgets/WidgetErrors.cs b/src/Loom.Templates/templates/loom-api/src/MyApp.Domain/Widgets/WidgetErrors.cs new file mode 100644 index 0000000..05fb0ca --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Domain/Widgets/WidgetErrors.cs @@ -0,0 +1,21 @@ +using Loom.Results; + +namespace MyApp.Domain.Widgets; + +/// +/// The failures a widget can report. Codes are stable and greppable; messages are free to be reworded. +/// +public static class WidgetErrors +{ + /// A widget was created without a name. + public static Error NameRequired { get; } = + Errors.Invalid("widgets.name_required", "A widget needs a name."); + + /// A widget was created with a size of zero or less. + public static Error SizeMustBePositive { get; } = + Errors.Invalid("widgets.size_must_be_positive", "A widget's size must be greater than zero."); + + /// No widget exists under that identifier. + public static Error NotFound { get; } = + Errors.NotFound("widgets.not_found", "No such widget."); +} diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.ServiceDefaults/MyApp.ServiceDefaults.csproj b/src/Loom.Templates/templates/loom-api/src/MyApp.ServiceDefaults/MyApp.ServiceDefaults.csproj new file mode 100644 index 0000000..96679be --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.ServiceDefaults/MyApp.ServiceDefaults.csproj @@ -0,0 +1,18 @@ + + + + true + + + + + + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.ServiceDefaults/ServiceDefaults.cs b/src/Loom.Templates/templates/loom-api/src/MyApp.ServiceDefaults/ServiceDefaults.cs new file mode 100644 index 0000000..9edde50 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.ServiceDefaults/ServiceDefaults.cs @@ -0,0 +1,120 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Telemetry, health, resilience and service discovery, configured once for every service. +/// +/// +/// This file is part of the application rather than a package: it is scaffolded in so that it can be +/// read and changed. That is why the orchestration choice survives the objection that ruled out other +/// frameworks — nothing here sits in the request path as a dependency you cannot see. +/// +public static class ServiceDefaults +{ + /// + /// Adds telemetry, a liveness check, service discovery and resilient HTTP defaults. + /// + /// The builder being configured. + /// The application builder. + /// The same builder, so calls can be chained. + /// is . + /// + /// The one call every service makes. Outgoing HTTP clients get retries and a circuit breaker by + /// default, so a service that forgets to ask for resilience still has it. + /// + public static TBuilder AddServiceDefaults(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ConfigureOpenTelemetry(); + + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + return builder; + } + + /// + /// Turns on structured logs, metrics and traces, exporting them only if a collector is configured. + /// + /// The builder being configured. + /// The application builder. + /// The same builder, so calls can be chained. + /// is . + /// + /// Instrumentation is always collected; the OTLP exporter is added only when + /// OTEL_EXPORTER_OTLP_ENDPOINT is set. That way a service run on its own does not spend the + /// application's startup failing to reach a collector that is not there. + /// + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation()) + .WithTracing(tracing => tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation()); + + if (!string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"])) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + return builder; + } + + /// + /// Maps the health endpoints, in Development only. + /// + /// The application to map onto. + /// The same application, so calls can be chained. + /// is . + /// + /// Deliberately not mapped elsewhere: these endpoints are unauthenticated and describe the service's + /// internals, so exposing them in a deployment is a decision to make on purpose, with whatever + /// network restriction that deployment has. + /// + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + ArgumentNullException.ThrowIfNull(app); + + if (app.Environment.IsDevelopment()) + { + app.MapHealthChecks("/health"); + app.MapHealthChecks("/alive", new AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions + { + Predicate = registration => registration.Tags.Contains("live"), + }); + } + + return app; + } +} diff --git a/src/Loom.Templates/templates/loom-api/tests/MyApp.Api.Tests/MyApp.Api.Tests.csproj b/src/Loom.Templates/templates/loom-api/tests/MyApp.Api.Tests/MyApp.Api.Tests.csproj new file mode 100644 index 0000000..695bd6c --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/tests/MyApp.Api.Tests/MyApp.Api.Tests.csproj @@ -0,0 +1,19 @@ + + + + Exe + + + + + + + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-api/tests/MyApp.Api.Tests/RegistrationTests.cs b/src/Loom.Templates/templates/loom-api/tests/MyApp.Api.Tests/RegistrationTests.cs new file mode 100644 index 0000000..7f8eeaf --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/tests/MyApp.Api.Tests/RegistrationTests.cs @@ -0,0 +1,66 @@ +using FluentValidation; +using Loom.Handlers; +using Microsoft.Extensions.DependencyInjection; + +namespace MyApp.Api.Tests; + +/// +/// Every handler and every validator resolves. +/// +/// +/// A validator that is not registered is silently ignored — the decorator treats a missing one as +/// nothing to validate, which is right for a slice with no rules and disastrous for one with them. +/// Assembly scanning excludes internal types unless asked, and slice validators are internal. +/// +[NotInParallel] +public sealed class RegistrationTests +{ + [Test] + public async Task Every_Validator_In_The_Application_Resolves() + { + using IServiceScope scope = AppFixture.App.Services.CreateScope(); + + Type[] requestTypes = + [ + .. typeof(Program).Assembly.GetTypes() + .Where(type => type is { IsClass: true, IsAbstract: false } + && type.BaseType is { IsGenericType: true } baseType + && baseType.GetGenericTypeDefinition() == typeof(AbstractValidator<>)) + .Select(type => type.BaseType!.GetGenericArguments()[0]), + ]; + + await Assert.That(requestTypes).IsNotEmpty(); + + foreach (Type requestType in requestTypes) + { + Type validatorType = typeof(IValidator<>).MakeGenericType(requestType); + await Assert.That(scope.ServiceProvider.GetService(validatorType)).IsNotNull(); + } + } + + [Test] + public async Task Every_Handler_Resolves_Through_Its_Decorator_Chain() + { + using IServiceScope scope = AppFixture.App.Services.CreateScope(); + + // Discovered rather than listed. Naming one handler would only ever prove that handler is + // registered, so a new slice whose AddHandler call was forgotten would still pass — and a + // handler that is never registered fails at the first request instead of at the first test. + Type[] handlerInterfaces = + [ + .. typeof(Program).Assembly.GetTypes() + .Where(type => type is { IsClass: true, IsAbstract: false }) + .SelectMany(type => type.GetInterfaces()) + .Where(contract => contract.IsGenericType + && contract.GetGenericTypeDefinition() == typeof(IHandler<,>)) + .Distinct(), + ]; + + await Assert.That(handlerInterfaces).IsNotEmpty(); + + foreach (Type contract in handlerInterfaces) + { + await Assert.That(scope.ServiceProvider.GetService(contract)).IsNotNull(); + } + } +} diff --git a/src/Loom.Templates/templates/loom-api/tests/MyApp.Api.Tests/RouteTableTests.cs b/src/Loom.Templates/templates/loom-api/tests/MyApp.Api.Tests/RouteTableTests.cs new file mode 100644 index 0000000..bfba0a8 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/tests/MyApp.Api.Tests/RouteTableTests.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace MyApp.Api.Tests; + +/// +/// The complete set of registered routes, asserted. +/// +/// +/// Mandatory, not optional. Endpoints are discovered by assembly scan, so nothing in Program.cs links +/// to a slice: one that fails to register does not fail to compile, and would 404 in production +/// instead. This turns that into a failing build. +/// +/// When this fails because you added a slice, update the expected set deliberately. Do not delete the +/// assertion. +/// +/// +[NotInParallel] +public sealed class RouteTableTests +{ + [Test] + public async Task The_Route_Table_Is_What_We_Expect() + { + EndpointDataSource routes = AppFixture.App.Services.GetRequiredService(); + + string[] actual = + [ + .. routes.Endpoints + .OfType() + .Select(endpoint => endpoint.RoutePattern.RawText ?? string.Empty) + .Where(pattern => !pattern.StartsWith("/health", StringComparison.Ordinal) + && !pattern.StartsWith("/alive", StringComparison.Ordinal)) + .Distinct() + .Order(StringComparer.Ordinal), + ]; + + string[] expected = ["/widgets", "/widgets/{widgetId}"]; + + await Assert.That(actual).IsEquivalentTo(expected); + } +} diff --git a/src/Loom.Templates/templates/loom-api/tests/MyApp.Api.Tests/TestApp.cs b/src/Loom.Templates/templates/loom-api/tests/MyApp.Api.Tests/TestApp.cs new file mode 100644 index 0000000..c14b98b --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/tests/MyApp.Api.Tests/TestApp.cs @@ -0,0 +1,124 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MyApp.Api.Infrastructure; +using Npgsql; +using Respawn; +using Testcontainers.PostgreSql; + +namespace MyApp.Api.Tests; + +/// +/// One Postgres container and one database for the whole assembly, reset between tests. +/// +/// +/// Respawn rather than a transaction per test: handlers own their transactions, so rollback-based +/// isolation would lie about what was actually committed. +/// +public sealed class TestApp : IAsyncDisposable +{ + private readonly PostgreSqlContainer _container; + private readonly WebApplicationFactory _factory; + private readonly Respawner _respawner; + private readonly NpgsqlConnection _resetConnection; + + private TestApp( + PostgreSqlContainer container, + WebApplicationFactory factory, + Respawner respawner, + NpgsqlConnection resetConnection) + { + _container = container; + _factory = factory; + _respawner = respawner; + _resetConnection = resetConnection; + } + + public static async Task StartAsync() + { + PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build(); + await container.StartAsync(); + + WebApplicationFactory factory = new AppFactory(container.GetConnectionString()); + + using (IServiceScope scope = factory.Services.CreateScope()) + { + AppDbContext database = scope.ServiceProvider.GetRequiredService(); + // The schema comes from the same migrations a deployment applies, so the tests exercise the path + // that ships rather than one EF derives from the model. + await database.Database.MigrateAsync(); + } + + NpgsqlConnection resetConnection = new(container.GetConnectionString()); + await resetConnection.OpenAsync(); + + Respawner respawner = await Respawner.CreateAsync(resetConnection, new RespawnerOptions + { + DbAdapter = DbAdapter.Postgres, + SchemasToInclude = ["public"], + }); + + return new TestApp(container, factory, respawner, resetConnection); + } + + public Task ResetAsync() => _respawner.ResetAsync(_resetConnection); + + public IServiceProvider Services => _factory.Services; + + public HttpClient Client() => _factory.CreateClient(); + + public async Task InDatabaseAsync(Func work) + { + using IServiceScope scope = _factory.Services.CreateScope(); + await work(scope.ServiceProvider.GetRequiredService()); + } + + public async ValueTask DisposeAsync() + { + await _resetConnection.DisposeAsync(); + await _factory.DisposeAsync(); + await _container.DisposeAsync(); + } + + private sealed class AppFactory(string connectionString) : WebApplicationFactory + { + protected override IHost CreateHost(IHostBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ConfigureHostConfiguration(configuration => configuration.AddInMemoryCollection( + [ + new KeyValuePair("ConnectionStrings:database", connectionString), + ])); + + // Pinned so the route table is deterministic: the health endpoints are development-only. + builder.UseEnvironment("Development"); + + return base.CreateHost(builder); + } + } +} + +/// Starts one container for the whole assembly. +public static class AppFixture +{ + private static TestApp? _app; + + public static TestApp App => _app ?? throw new InvalidOperationException("The fixture has not started."); + + [Before(Assembly)] + public static async Task StartAsync() => _app = await TestApp.StartAsync(); + + [After(Assembly)] + public static async Task StopAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + _app = null; + } + } +} diff --git a/src/Loom.Templates/templates/loom-api/tests/MyApp.Api.Tests/WidgetSliceTests.cs b/src/Loom.Templates/templates/loom-api/tests/MyApp.Api.Tests/WidgetSliceTests.cs new file mode 100644 index 0000000..5b0bac6 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/tests/MyApp.Api.Tests/WidgetSliceTests.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Net.Http.Json; +using MyApp.Api.Infrastructure; + +namespace MyApp.Api.Tests; + +/// +/// Every slice is tested through HTTP, not by calling the handler. +/// +/// +/// Calling a handler directly skips model binding, validation and the result mapping — which is most +/// of what can actually break. +/// +[NotInParallel] +public sealed class WidgetSliceTests +{ + private TestApp _app = null!; + + [Before(Test)] + public async Task ResetAsync() + { + _app = AppFixture.App; + await _app.ResetAsync(); + } + + [Test] + public async Task A_Widget_Is_Created_And_Read_Back() + { + using HttpClient client = _app.Client(); + + HttpResponseMessage created = await client.PostAsJsonAsync("/widgets", new { name = "bolt", size = 3 }); + await Assert.That(created.StatusCode).IsEqualTo(HttpStatusCode.OK); + + WidgetResponse? body = await created.Content.ReadFromJsonAsync(); + await Assert.That(body).IsNotNull(); + + HttpResponseMessage read = await client.GetAsync(new Uri($"/widgets/{body!.WidgetId}", UriKind.Relative)); + await Assert.That(read.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + [Test] + public async Task An_Invalid_Request_Is_Refused_Before_The_Handler_Runs() + { + using HttpClient client = _app.Client(); + + // Too long for the validator, but acceptable to Widget.Create — which is the point. An empty + // name would be refused by the domain as well, so the test would pass with the validating + // decorator removed and prove nothing. Only the length rule separates the two, so this fails + // if the decorator is ever dropped from the chain. + HttpResponseMessage response = await client.PostAsJsonAsync( + "/widgets", + new { name = new string('w', 201), size = 3 }); + + // The decorator turns this into an Invalid failure, which maps to 400 with the errors + // extension populated. Nothing in the slice writes a status code. + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task A_Missing_Widget_Reports_Not_Found() + { + using HttpClient client = _app.Client(); + + HttpResponseMessage response = await client.GetAsync( + new Uri($"/widgets/{Guid.CreateVersion7()}", UriKind.Relative)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + private sealed record WidgetResponse(Guid WidgetId); +} diff --git a/src/Loom.Templates/templates/loom-api/tests/MyApp.ArchitectureTests/ConfigurationBoundaryTests.cs b/src/Loom.Templates/templates/loom-api/tests/MyApp.ArchitectureTests/ConfigurationBoundaryTests.cs new file mode 100644 index 0000000..43b91a1 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/tests/MyApp.ArchitectureTests/ConfigurationBoundaryTests.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using Microsoft.Extensions.Configuration; +using MyApp.Api.Infrastructure; + +namespace MyApp.ArchitectureTests; + +/// +/// Configuration is read while the application is composed, never by something the container built. +/// +/// +/// Reading a value in Program.cs or a startup extension is composition. A service taking +/// is different in kind: it hides a dependency its constructor does not +/// declare, and it cannot be tested without standing up configuration. Constructor parameters are what +/// separates the two, so that is what this asserts. +/// +public sealed class ConfigurationBoundaryTests +{ + [Test] + public async Task Nothing_Takes_IConfiguration_As_A_Constructor_Parameter() + { + string[] offenders = + [ + .. typeof(AppDbContext).Assembly.GetTypes() + .SelectMany(type => type.GetConstructors( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Select(constructor => new { type, constructor })) + .Where(candidate => candidate.constructor.GetParameters() + .Any(parameter => typeof(IConfiguration).IsAssignableFrom(parameter.ParameterType))) + .Select(candidate => candidate.type.FullName ?? candidate.type.Name) + .Distinct(), + ]; + + await Assert.That(offenders).IsEmpty(); + } +} diff --git a/src/Loom.Templates/templates/loom-api/tests/MyApp.ArchitectureTests/DomainPurityTests.cs b/src/Loom.Templates/templates/loom-api/tests/MyApp.ArchitectureTests/DomainPurityTests.cs new file mode 100644 index 0000000..7cbf1fa --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/tests/MyApp.ArchitectureTests/DomainPurityTests.cs @@ -0,0 +1,46 @@ +using System.Reflection; +using MyApp.Domain.Widgets; +using NetArchTest.Rules; +using ArchTestResult = NetArchTest.Rules.TestResult; + +namespace MyApp.ArchitectureTests; + +/// +/// The structural rules, asserted rather than reviewed. +/// +public sealed class DomainPurityTests +{ + private static readonly Assembly Domain = typeof(Widget).Assembly; + + [Test] + public async Task The_Domain_Depends_On_Nothing_But_Loom_And_The_Base_Class_Library() + { + string[] forbidden = + [ + "Microsoft.EntityFrameworkCore", + "Microsoft.AspNetCore", + "FluentValidation", + "Npgsql", + "Microsoft.Extensions.DependencyInjection", + ]; + + ArchTestResult result = Types.InAssembly(Domain) + .Should() + .NotHaveDependencyOnAny(forbidden) + .GetResult(); + + // Named, because "false" does not say which type reached for what. + await Assert.That(result.FailingTypeNames ?? []).IsEmpty(); + } + + [Test] + public async Task Entities_Are_Sealed() + { + ArchTestResult result = Types.InAssembly(Domain) + .That().AreClasses().And().ArePublic() + .Should().BeSealed() + .GetResult(); + + await Assert.That(result.IsSuccessful).IsTrue(); + } +} diff --git a/src/Loom.Templates/templates/loom-api/tests/MyApp.ArchitectureTests/EnforcementTests.cs b/src/Loom.Templates/templates/loom-api/tests/MyApp.ArchitectureTests/EnforcementTests.cs new file mode 100644 index 0000000..8b8429b --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/tests/MyApp.ArchitectureTests/EnforcementTests.cs @@ -0,0 +1,116 @@ +using System.Reflection; +using Loom.Entities; +using MyApp.Api.Infrastructure; +using NetArchTest.Rules; +using ArchTestResult = NetArchTest.Rules.TestResult; + +namespace MyApp.ArchitectureTests; + +/// +/// The structural rules the guidance lists, asserted rather than reviewed. +/// +/// +/// A structural rule with no test is a rule that erodes: nothing about a slice reaching into another +/// one fails to compile, and nothing about an entity crossing a contract boundary does either. +/// +public sealed class EnforcementTests +{ + private static readonly Assembly Host = typeof(AppDbContext).Assembly; + + private static readonly string SliceRoot = $"{Host.GetName().Name}.Features."; + + [Test] + public async Task No_Slice_Depends_On_Another_Slice() + { + string[] slices = Slices(); + + await Assert.That(slices).IsNotEmpty(); + + List offenders = []; + + foreach (string slice in slices) + { + string[] others = [.. slices.Where(candidate => candidate != slice)]; + + if (others.Length is 0) + { + continue; + } + + ArchTestResult result = Types.InAssembly(Host) + .That().ResideInNamespace(slice) + .Should().NotHaveDependencyOnAny(others) + .GetResult(); + + offenders.AddRange(result.FailingTypeNames ?? []); + } + + // Slices are independent by construction, not by convention. One reaching into another is how + // a vertical slice quietly becomes a layer. + await Assert.That(offenders).IsEmpty(); + } + + [Test] + public async Task Domain_Entities_Do_Not_Cross_A_Contract_Boundary() + { + Type[] contracts = [.. Host.GetTypes().Where(type => type.Name is "Request" or "Response")]; + + await Assert.That(contracts).IsNotEmpty(); + + List offenders = + [ + .. from contract in contracts + from property in contract.GetProperties() + where NamesAnEntity(property.PropertyType) + select $"{contract.FullName}.{property.Name}", + ]; + + // An entity on a request or a response leaks persistence and invariants into the wire format, + // and couples the two to each other for good. + await Assert.That(offenders).IsEmpty(); + } + + // A slice is a namespace with a handler in it. The aggregate folder above them holds shared + // pieces such as the entity configuration and is not one — and namespace matching is by prefix, so + // counting a parent would report it as depending on its own children. + private static string[] Slices() => + [.. Host.GetTypes() + .Where(type => type.Name is "Handler" + && type.Namespace is not null + && type.Namespace.StartsWith(SliceRoot, StringComparison.Ordinal)) + .Select(type => type.Namespace!) + .Distinct() + .Order(StringComparer.Ordinal)]; + + private static bool NamesAnEntity(Type type) + { + // Id names an entity without being one. Unwrapping through it would flag every + // correctly typed identifier, which is the opposite of the rule. + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Id<>)) + { + return false; + } + + // An array is not generic and its base chain runs to Array, so without this a Widget[] on a + // contract would go unnoticed. + if (type.IsArray) + { + return NamesAnEntity(type.GetElementType()!); + } + + if (type.IsGenericType && type.GetGenericArguments().Any(NamesAnEntity)) + { + return true; + } + + for (Type? candidate = type; candidate is not null; candidate = candidate.BaseType) + { + if (candidate.IsGenericType && candidate.GetGenericTypeDefinition() == typeof(Entity<>)) + { + return true; + } + } + + return false; + } +} diff --git a/src/Loom.Templates/templates/loom-api/tests/MyApp.ArchitectureTests/MyApp.ArchitectureTests.csproj b/src/Loom.Templates/templates/loom-api/tests/MyApp.ArchitectureTests/MyApp.ArchitectureTests.csproj new file mode 100644 index 0000000..2c7c0f0 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/tests/MyApp.ArchitectureTests/MyApp.ArchitectureTests.csproj @@ -0,0 +1,17 @@ + + + + Exe + + + + + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-api/tests/MyApp.Domain.Tests/MyApp.Domain.Tests.csproj b/src/Loom.Templates/templates/loom-api/tests/MyApp.Domain.Tests/MyApp.Domain.Tests.csproj new file mode 100644 index 0000000..6830ef7 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/tests/MyApp.Domain.Tests/MyApp.Domain.Tests.csproj @@ -0,0 +1,15 @@ + + + + Exe + + + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-api/tests/MyApp.Domain.Tests/WidgetTests.cs b/src/Loom.Templates/templates/loom-api/tests/MyApp.Domain.Tests/WidgetTests.cs new file mode 100644 index 0000000..80da7b1 --- /dev/null +++ b/src/Loom.Templates/templates/loom-api/tests/MyApp.Domain.Tests/WidgetTests.cs @@ -0,0 +1,38 @@ +using Loom.Results; +using MyApp.Domain.Widgets; + +namespace MyApp.Domain.Tests; + +/// +/// Pure, fast, no infrastructure. Invariants are testable without a database because they live in the +/// domain rather than in a handler. +/// +public sealed class WidgetTests +{ + [Test] + public async Task A_Valid_Widget_Is_Created() + { + Result created = Widget.Create("bolt", 3); + + await Assert.That(created.IsSuccess).IsTrue(); + await Assert.That(created.Value.Name).IsEqualTo("bolt"); + } + + [Test] + public async Task A_Widget_Without_A_Name_Is_Refused() + { + Result created = Widget.Create(" ", 3); + + await Assert.That(created.IsFailure).IsTrue(); + await Assert.That(created.Error.Code).IsEqualTo(WidgetErrors.NameRequired.Code); + } + + [Test] + public async Task A_Widget_Must_Have_A_Positive_Size() + { + Result created = Widget.Create("bolt", 0); + + await Assert.That(created.IsFailure).IsTrue(); + await Assert.That(created.Error.Code).IsEqualTo(WidgetErrors.SizeMustBePositive.Code); + } +} diff --git a/src/Loom.Templates/templates/loom-worker/.editorconfig b/src/Loom.Templates/templates/loom-worker/.editorconfig new file mode 100644 index 0000000..f049525 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/.editorconfig @@ -0,0 +1,106 @@ +root = true + +# Formatting and code style for Loom. This file — not AGENTS.md — is the authority +# on style. `dotnet format` fixes violations; CI fails on any remaining difference. +# If you want to change how code looks, change it here, not in prose. + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{json,yml,yaml,md}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.{csproj,props,targets,slnx}] +indent_size = 4 + +[*.cs] +indent_size = 4 + +#### C# language conventions #### + +csharp_style_namespace_declarations = file_scoped:error +csharp_using_directive_placement = outside_namespace:error +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:suggestion + +csharp_prefer_braces = true:error +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_accessors = true:suggestion + +csharp_style_prefer_pattern_matching = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_throw_expression = true:suggestion + +dotnet_style_readonly_field = true:error +dotnet_style_require_accessibility_modifiers = for_non_interface_members:error +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion + +# Noise. These fire constantly on correct code and derail agents under +# TreatWarningsAsErrors; they are style preferences, not defects. +dotnet_diagnostic.IDE0058.severity = none +dotnet_diagnostic.IDE0022.severity = none +dotnet_diagnostic.IDE0055.severity = suggestion + +#### Naming #### + +dotnet_naming_rule.interfaces_start_with_i.severity = error +dotnet_naming_rule.interfaces_start_with_i.symbols = interfaces +dotnet_naming_rule.interfaces_start_with_i.style = prefix_i_pascal +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_style.prefix_i_pascal.required_prefix = I +dotnet_naming_style.prefix_i_pascal.capitalization = pascal_case + +dotnet_naming_rule.types_are_pascal_case.severity = error +dotnet_naming_rule.types_are_pascal_case.symbols = types_and_members +dotnet_naming_rule.types_are_pascal_case.style = pascal +dotnet_naming_symbols.types_and_members.applicable_kinds = class,struct,enum,property,method,event,delegate +dotnet_naming_style.pascal.capitalization = pascal_case + +# Declared before the general private-field rule: the first matching rule wins, and constants and +# static readonly fields are PascalCase by convention throughout the BCL. +dotnet_naming_rule.private_constants_are_pascal.severity = error +dotnet_naming_rule.private_constants_are_pascal.symbols = private_constants +dotnet_naming_rule.private_constants_are_pascal.style = pascal +dotnet_naming_symbols.private_constants.applicable_kinds = field +dotnet_naming_symbols.private_constants.applicable_accessibilities = private +dotnet_naming_symbols.private_constants.required_modifiers = const + +dotnet_naming_rule.private_static_readonly_fields_are_pascal.severity = error +dotnet_naming_rule.private_static_readonly_fields_are_pascal.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_are_pascal.style = pascal +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = static,readonly + +dotnet_naming_rule.private_fields_are_underscore_camel.severity = error +dotnet_naming_rule.private_fields_are_underscore_camel.symbols = private_fields +dotnet_naming_rule.private_fields_are_underscore_camel.style = underscore_camel +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_style.underscore_camel.required_prefix = _ +dotnet_naming_style.underscore_camel.capitalization = camel_case + +#### Tests #### + +[tests/**/*.cs] +# Test method names are prose, not identifiers: Returns_Failure_When_Value_Is_Null. +dotnet_naming_rule.types_are_pascal_case.severity = none diff --git a/src/Loom.Templates/templates/loom-worker/.gitignore b/src/Loom.Templates/templates/loom-worker/.gitignore new file mode 100644 index 0000000..0198775 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +TestResults/ +artifacts/ +.vs/ +.idea/ +*.user diff --git a/src/Loom.Templates/templates/loom-worker/.template.config/template.json b/src/Loom.Templates/templates/loom-worker/.template.config/template.json new file mode 100644 index 0000000..d2e2550 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/.template.config/template.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://json.schemastore.org/template", + "author": "Dylan de Beer", + "classifications": [ + "Worker", + "Service", + "Solution", + "Loom" + ], + "identity": "CodeByDylan.Loom.Worker.CSharp", + "name": "Loom background worker solution", + "shortName": "loom-worker", + "description": "A solution built on Loom: a pure domain, a background worker of vertical slices, Aspire orchestration, and the guidance for working in it already assembled into AGENTS.md.", + "tags": { + "language": "C#", + "type": "solution" + }, + "preferNameDirectory": true, + "defaultName": "MyApp", + "symbols": { + "safeName": { + "type": "generated", + "generator": "regex", + "dataType": "string", + "replaces": "MyApp", + "fileRename": "MyApp", + "parameters": { + "source": "name", + "steps": [ + { + "regex": "[^A-Za-z0-9_.]", + "replacement": "_" + }, + { + "regex": "(^|\\.)([0-9])", + "replacement": "$1_$2" + } + ] + } + }, + "userSecretsId": { + "type": "generated", + "generator": "guid", + "replaces": "9f2a6c1e-0000-0000-0000-000000000001", + "parameters": { + "format": "D" + } + }, + "hostProjectClass": { + "type": "generated", + "generator": "regex", + "dataType": "string", + "replaces": "HostProject", + "parameters": { + "source": "name", + "steps": [ + { + "regex": "[^A-Za-z0-9_]", + "replacement": "_" + }, + { + "regex": "^([0-9])", + "replacement": "_$1" + } + ] + } + }, + "skipRestore": { + "type": "parameter", + "datatype": "bool", + "defaultValue": "false", + "displayName": "Skip restore", + "description": "Do not run dotnet restore after the solution is created." + } + }, + "postActions": [ + { + "id": "restore", + "condition": "(!skipRestore)", + "description": "Restoring the solution.", + "manualInstructions": [ + { + "text": "Run 'dotnet restore'." + } + ], + "actionId": "210D431B-A78B-4D2F-B762-4ED3E3EA9025", + "continueOnError": true + } + ] +} diff --git a/src/Loom.Templates/templates/loom-worker/AGENTS.md b/src/Loom.Templates/templates/loom-worker/AGENTS.md new file mode 100644 index 0000000..98e846f --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/AGENTS.md @@ -0,0 +1,261 @@ +# AGENTS.md + +Rules for working on this project. Assembled from Loom's `docs/agents/` templates — this file +is yours now. Edit it freely. + +> **FILL IN:** One sentence on what this service does, and which archetypes it contains. + +## 1. Orientation + +```text +src/MyApp.Domain/ pure domain; Loom packages + BCL only +src/MyApp./ host; every slice lives here +src/MyApp.AppHost/ Aspire orchestration; dev-time only, ships nothing +src/MyApp.ServiceDefaults/ Aspire wiring; your code, edit it +tests/MyApp.Domain.Tests/ +tests/MyApp..Tests/ +tests/MyApp.ArchitectureTests/ +``` + +This is Clean Architecture reduced to its one boundary worth enforcing at compile time — +`Domain` purity — with vertical slices for everything else. The `Application`/`Infrastructure` +split is deliberately absent: it shreds a slice across two projects and cancels the point of +slicing. + +A **slice** is one operation. One file, one namespace, holding its request, response, validator, +handler, and entry point together. + +**The stack.** Versions live in `Directory.Packages.props`, never here and never in a `.csproj`. +Loom packages install under their full identifier, `CodeByDylan.Loom.`; the namespace named below drops the `CodeByDylan.` prefix. + +| Concern | Choice | Notes | +| --- | --- | --- | +| Data | EF Core + Npgsql, `Loom.Persistence.EntityFrameworkCore` | Postgres. No repositories. | +| Queries | `Loom.Specifications`, `Loom.Paging` | Named rules applied to a query the slice owns. | +| Validation | FluentValidation | Request shape only, never domain rules. | +| Dispatch | `Loom.Handlers` | No mediator. Handlers + one global decorator chain. | +| Mapping | Manual | Mapperly only for large mechanical maps. | +| Logging | `ILogger` + OpenTelemetry | No Serilog. | +| Orchestration | Aspire | Dev-time. Not in the request path. | +| Tests | TUnit, Testcontainers, Respawn | Plus NetArchTest for structure. | + +Deliberately absent: MediatR and AutoMapper (both now require paid licences), Wolverine and +FastEndpoints (frameworks that would own request handling), Hangfire (LGPL, and Aspire covers +the observability its dashboard compensated for). + +## 2. Hard constraints + +1. **`Domain` references only Loom packages and the BCL.** No EF Core, no ASP.NET, no FluentValidation, no DI container. +2. **Never throw for expected failures.** Return a `Loom.Results` value. +3. **No slice references another slice.** Shared code within an aggregate goes in `_Shared.cs`. Anything two aggregates want belongs in `Domain`. +4. **Entities never cross the transport boundary.** Requests and responses are slice-owned types. +5. **`UNDECIDED` and `FILL IN` mean stop and ask.** Do not resolve them yourself and do not silently pick a convention. +6. **Run the §3 verify block before claiming done.** CI runs the same block; a green claim over a red build is a lie. +7. **Never edit this file to resolve a conflict between a rule and your code.** If a rule blocks you, say so. + +## 3. Build and verify + +```bash +dotnet format # fixes formatting in place +dotnet build # warnings are errors +dotnet test # TUnit +dotnet format --verify-no-changes # confirms nothing is left unformatted +``` + +- Run `dotnet format` (fixing), not verify-only. Never hand-edit whitespace to satisfy the check. +- Revert formatting-only changes to files your work didn't otherwise touch. +- `.editorconfig` is the authority on code style. To change how code looks, edit it there. Do not add style rules to this file. + +## 4. Architecture + +- **One file per operation:** `Features//.cs`. +- **One namespace per slice:** `namespace MyApp.Features.Orders.CreateOrder;`. This is what makes slice isolation mechanically enforceable (§11) rather than a review convention. +- **A slice over ~250 lines means the operation is doing too much.** Split the operation, not the file. A genuine helper gets a sibling file in the same folder, never a new folder. +- **`Features//_Shared.cs`** is the only permitted cross-slice sharing, and only within one aggregate. +- **Entry points are thin adapters.** An endpoint, a `BackgroundService`, or a CLI command validates nothing, decides nothing, and queries nothing — it adapts input and dispatches to a handler. +- **Request and response types are private to their slice.** If two slices want the same shape, they get two identical types. This looks wasteful and is the rule that keeps slices independent; a shared response DTO is how one slice's requirements start dictating another's. + +## 5. Domain + +- **Invariants live in `Domain` and return `Loom.Results`.** Not in validators, not in handlers, and never signalled by an exception. +- **Specifications live in `Domain` and derive from `Specification`.** A specification is a *named business rule* — `OverdueOrders(customerId)` — configured entirely in its constructor. It may carry a predicate, eager-loading, and ordering; never paging. +- **Never give a specification a flag that toggles part of its query.** That is two rules sharing a name. Write two specifications. +- **Combine predicates with `Criteria.And`/`Or`/`Not`, not whole specifications.** Two specifications with conflicting ordering have no sensible combination. +- **No persistence attributes on entities.** Mapping is configured host-side via `IEntityTypeConfiguration`. +- **`Domain` has no async I/O.** No `Task`-returning methods that reach outside memory. +- **Entities derive from `Entity`; aggregate roots from `AggregateRoot`.** Identity is `Id`, assigned at construction, never default. +- **Give every entity a private parameterless constructor for EF to materialise through.** EF writes the identity from the column, so the one minted in that constructor is discarded and the object stays attached to its row. Reconstructing through an `Id` constructor instead makes EF unable to choose between it and any other one-parameter constructor, and the model fails to build. +- **Entities are classes. Value objects and domain events are `sealed record`s.** Entities compare by identity, so structural equality is wrong for them; everything else in the domain is value-like and records are right. +- **Only aggregate roots get a `DbSet<>`.** Child entities are reached through their root. +- **`Id` ordering is not creation order.** Version 7 GUIDs are only millisecond-granular and are not monotonic within a millisecond. Never paginate on an id, and never use one to decide what happened first — sort on an explicit timestamp column. +- **Declare `WithLogging()` first in the chain, so it sits outermost.** Anything declared before it goes unrecorded — including a request refused by validation, which is the outcome most worth seeing. The level follows the failure's category: a refusal is information, an unavailable dependency is a warning, a success is debug. The request's *type name* is recorded and its contents never are, with no option to change that. +- **Declare `WithDomainEventFailures()` last in the chain, after `WithValidation()`.** A domain event handler reporting a failure abandons the save, which an object-relational mapper can only express by throwing; this decorator turns it back into the failure the handler reported, so the caller sees the right category instead of a server error. It must sit innermost, or it will also swallow exceptions from other decorators and report them as domain event failures. +- **An outbox needs a retention schedule, or it grows forever.** Nothing deletes a delivered message on its own. Call `OutboxAdministration.PurgeDeliveredAsync` on a timer, with an interval you have chosen; it never touches a message that is still owed or one that was abandoned. +- **Recover an abandoned message through `OutboxAdministration`, never by hand.** Retrying resets the attempt count and keeps the error, which is easy to get backwards in a hand-written statement: clear the error and the evidence is gone, leave the count and it abandons again on the first failure. +- **Ordinary domain events dispatch before the commit; deferred ones after.** An ordinary handler may change data atomically with the operation but must not reach outside the process, because a rollback cannot unsend an email. A `IDeferredDomainEvent` handler may reach outside the process and **must be idempotent**, since delivery is at least once. Which one applies is declared on the event. + +Register identity conversion once per assembly, from `ConfigureConventions` — not `OnModelCreating`, where discovery has already skipped identities that are not keys: + +```csharp +protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) => + configurationBuilder.UseLoomIdentities(typeof(Order).Assembly); +``` + +## 6. Persistence + +- **One `AppDbContext`**, in the host. `IEntityTypeConfiguration` classes co-located per aggregate. +- **Inject `DbContext` into handlers directly. No repositories.** `DbContext` is already a unit of work and `DbSet` is already a repository; wrapping them produces passthrough interfaces and destroys the `IQueryable` composition that makes `Loom.Specifications` work. +- **Reads project in the query:** `AsNoTracking()` then `.Select(...)` straight into the slice's response type. Never materialise an entity in order to map it — that is the most common performance defect in EF codebases. +- **Migrations are generated with `dotnet ef`, reviewed by a human, and applied deliberately.** Never `EnsureCreated()`, never auto-migrate on startup in a deployed environment. +- Dapper is permitted for a specific query that demands it, as a documented exception. It is not a second default. +- **Apply specifications to the query the slice owns.** `db.Orders.ApplySpecification(new OverdueOrders(id))` — never hand a specification to something that queries on your behalf. Reintroducing a repository is the one way this design fails. The name differs from the specification package's own `Apply` deliberately; that one cannot honour eager loading and refuses rather than silently dropping it. +- **Paging is the caller's decision, applied after the specification:** `.ApplySpecification(spec).ToPageAsync(request, ct)`. Return `Page` so every endpoint reports paging identically. +- **`PageRequest` validates itself, including a maximum size.** Never accept a raw page size from a query string without it — `?size=1000000` returns the table. + +## 7. Validation and errors + +- **FluentValidation for request shape** — format, ranges, required fields, cross-field consistency. One validator per slice, in the slice file. +- **Validation runs through `Loom.Handlers.FluentValidation`'s decorator**, not an endpoint filter. A decorator behaves identically in an API, a worker, and a CLI; a filter only exists in the first. It is enabled once, for every handler: `services.AddLoomHandlers(chain => chain.WithValidation())`. +- **Domain rules are never FluentValidation.** If a rule needs domain knowledge, it belongs in §5. +- **Every error carries one of six categories:** `NotFound`, `Conflict`, `Invalid`, `Unauthorized`, `Forbidden`, `Unavailable`. These are semantic, not transport-specific, which is what lets one category become a status code in an API, a retry-or-dead-letter decision in a worker, and an exit code in a CLI. +- **The set is closed.** A seventh category means the taxonomy has become a status-code enum. Carry the specifics as metadata on `Error` instead. +- **A failed result carries exactly one error.** Several validation failures are one `ValidationError` whose metadata is a field→messages map, which maps straight onto the ProblemDetails `errors` extension. +- **Never serialize a `Result`.** It is a control-flow type; response types cross the wire. Serializers reflect over public members, and reading `Value` on a failure throws from inside the serializer. +- **Never ignore a returned `Result`.** Write `_ = ...` when the outcome really is of no interest — at which point you have said so, which is the whole point. Do not silence the rule to avoid the sentence. +- **`LOOM0001` covers a `Result` discarded directly, and only that.** A statement whose value is a `Result` — including an awaited one — fails the build, since warnings are errors here. Two cases it does not see: a `Task` that is never awaited, and a `Result` discarded as the body of a void-returning lambda. Those still need reading for, so do not treat a clean build as proof that no outcome was dropped. +- **Category-to-transport mapping comes from a Loom package, never inline in a slice.** In an API that is `Loom.Results.AspNetCore`: `result.ToHttpResult()`. If a project wants different titles or extra members, it builds the problem details with `ToProblemDetails()` and changes it — the package has no options type on purpose. + +## 8. Configuration and authorization + +- **Typed options only.** One `Options` class per concern with a `const string SectionName`. +- **Validate at startup:** `.ValidateDataAnnotations().ValidateOnStart()`. A misconfigured app must fail to boot, not fail on the first request that touches the bad setting. +- **`IConfiguration` is read only in the composition root** — `Program.cs`, and the startup extensions it calls on the builder, such as `ServiceDefaults`. **Never inject it into a type resolved from the container**; bind a typed options class and inject that. The distinction is what the rule protects: reading a value while composing the application is composition, whereas a service reaching for configuration at run time hides a dependency the constructor does not declare. +- **Secrets:** user-secrets locally, environment variables when deployed. Never in `appsettings*.json`, including `Development`. +- **Authorization policies are named constants** in a `Policies` static class. No inline role or claim strings at call sites. +- **Authorization that depends on domain state belongs in the handler**, returning a `Forbidden` error. "Can this user cancel *this* order" needs the order, so it cannot be an attribute. + +> **UNDECIDED:** Which identity provider issues tokens. Driven by the deployment environment, +> so the template does not choose. ASP.NET Core Identity is out of scope — self-hosting +> accounts, resets, and MFA is a project-defining decision, not a default. +> +> The routes are already closed: endpoints map into a group carrying `RequireAuthorization()`, and +> **no authentication scheme is registered until you add one**. Any endpoint that does not +> `AllowAnonymous()` will fault rather than refuse until that is done. Register the scheme first, +> then remove the opt-outs from the example slices. + +## 9. Observability + +- **Always an injected `ILogger`.** Never a static logger, never `LoggerFactory.Create` at a call site. +- **`[LoggerMessage]` source-generated log methods, not interpolated strings.** Interpolation allocates and boxes even when the level is disabled, and produces unstructured output. +- OpenTelemetry is configured once, in `ServiceDefaults`. That file is your code — edit it rather than working around it. + +## 10. Testing + +- **`Domain` gets unit tests.** Pure, fast, no infrastructure. +- **Every slice gets at least one integration test through its real entry point.** This is the load-bearing rule; correctness lives here, because there are no repository seams to unit-test against. +- **`WebApplicationFactory` + Testcontainers Postgres**, one container and database per test assembly, **Respawn between tests**. Not transaction-rollback isolation — handlers own their transactions, so rollback-based isolation will lie to you. +- **`Aspire.Hosting.Testing` for a handful of smoke tests only.** Booting the AppHost per test destroys the feedback loop. +- **Never mock `DbContext`.** Mocking your own code is a design smell; mocking a genuine external dependency is fine. +- **Outbound HTTP is stubbed at `HttpMessageHandler`**, or WireMock.Net when you need protocol fidelity. + +## 11. Enforcement + +`tests/MyApp.ArchitectureTests` asserts, with NetArchTest: + +1. `Domain` references nothing but Loom packages and the BCL. +2. No slice namespace depends on another slice namespace. +3. Domain entities appear in no request or response type's public surface. +4. `IConfiguration` is a constructor parameter of no type — it is read in the composition root or not at all. +5. Every `IHandler<,>` implementation has a matching DI registration. + +A structural rule that is not in this list is a rule that will erode. If you add a structural +rule to this file, add its test. + +## 12. Adding a slice + +1. Create `Features//.cs` with `namespace MyApp.Features..;`. +2. Write the request, the response, the validator, and the handler in that file. +3. Register the handler and its decorator chain. +4. Wire the entry point (see the archetype section below). +5. Write at least one integration test through the real entry point. +6. Run the §3 verify block. + +## 13. How to add a rule + +- One rule per bullet, imperative, self-contained. +- Add a **rationale only if the rule is surprising** — if the obvious instinct is the opposite. Obvious rules need no defending. +- Add a `// Do this` / `// Not this` snippet if a rule is easy to satisfy in letter and violate in spirit. +- Unsettled rules get a `> **UNDECIDED:**` callout, not a guess. +- **Budget: ~400 lines assembled.** Over budget means something moves out — into `.editorconfig`, an analyzer, or an architecture test. Growing past it is not an option; agents stop reading. +## B. Worker archetype + +Applies to `src/MyApp.Worker/`. + +### Entry points + +- **`BackgroundService` + `PeriodicTimer`.** No scheduling framework by default — most workers are "every N minutes" or "drain this," and the BCL does both with no dependencies. +- **Escalate to Quartz.NET only for a stated need:** cron expressions, clustering, or persistent job state. Adding it speculatively buys a database table and a configuration surface you do not want. +- **Hangfire is out.** `Hangfire.Core` is LGPL v3, which is a licence to adopt deliberately rather than inherit, and its Pro tier is paid. Its dashboard was compensating for missing observability, which Aspire already provides. +- **`ExecuteAsync` contains no business logic.** It ticks, runs one pass, and refuses to let a failure end the loop. Resolving a scope and dispatching to a handler happens a level down in `RunOnceAsync` — that is the position an endpoint occupies in the API archetype, and keeping the two apart is what makes either testable without the other. + +```csharp +internal sealed partial class ReconcileOrdersWorker( + IServiceScopeFactory scopes, + TimeProvider clock, + ILogger logger) + : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken ct) + { + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5), clock); + while (await timer.WaitForNextTickAsync(ct)) + { + await RunGuardedAsync(ct); + } + } + + // internal, not private: the testing rules below call this directly, so the test assembly needs + // an InternalsVisibleTo. + internal async Task RunGuardedAsync(CancellationToken ct) + { + try + { + await RunOnceAsync(ct); + } + catch (Exception exception) + when (exception is not OperationCanceledException || !ct.IsCancellationRequested) + { + // Swallowed, never silent. Only cancellation that shutdown asked for ends the loop. + Failed(logger, exception); + } + } + + // ExecuteAsync schedules and keeps the loop alive; one pass lives here. + internal async Task RunOnceAsync(CancellationToken ct) + { + await using var scope = scopes.CreateAsyncScope(); + var handler = scope.ServiceProvider.GetRequiredService>(); + var result = await handler.HandleAsync(new Request(), ct); + // map result category to retry / dead-letter / log — never throw to signal it + } + + [LoggerMessage(Level = LogLevel.Error, Message = "A pass threw and was swallowed to keep the worker alive.")] + private static partial void Failed(ILogger logger, Exception exception); +} +``` + +- **Resolve a new DI scope per iteration.** A `DbContext` captured across iterations accumulates tracked entities and will eventually behave incorrectly. This is the most common worker defect. +- **Pass the `CancellationToken` everywhere and honour it.** A worker that ignores shutdown gets killed mid-transaction. +- **Inject `TimeProvider`, never `DateTime.Now`.** It is also what makes the timer testable. + +### Error handling + +- **A failed iteration must not kill the worker.** An unhandled exception out of `ExecuteAsync` stops the service silently in some hosting models. Catch, log, and continue. +- **The `Loom.Results` category decides the response:** `Unavailable` retries with backoff; `Invalid` and `Conflict` dead-letter, because retrying will not change the outcome; `NotFound` is usually a no-op worth logging once. +- **Retries are bounded and logged.** Unbounded retry against a permanent failure is an outage with extra steps. + +### Testing + +- **Handlers are tested directly**, against Testcontainers Postgres, with Respawn between tests. There is no transport to go through, so the handler *is* the entry point. +- **Separate the pass from the schedule, and test the pass.** `RunOnceAsync` holds everything decided by a result — the scope, the dispatch, retry against dead-letter — so it can be exercised against a stub handler with no clock and no timer. Make the retry backoff a setting so a test can set it to zero. +- **Test one guarded iteration, not the timer.** Extract the `try`/`catch` into an `internal` method — with `` on the host project — so a test can call it directly and assert which exceptions survive it. That a failing pass does not end the loop is your logic; that `PeriodicTimer` fires is the BCL's. Driving a real `BackgroundService` from a fake clock needs the scheduler to hand off between advancing the clock and the loop resuming — a test that does it is flaky unless it sleeps, and then it is a slow test of someone else's code. diff --git a/src/Loom.Templates/templates/loom-worker/Directory.Build.props b/src/Loom.Templates/templates/loom-worker/Directory.Build.props new file mode 100644 index 0000000..53fdca8 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/Directory.Build.props @@ -0,0 +1,15 @@ + + + + + true + true + net10.0 + enable + enable + latest + true + false + + + diff --git a/src/Loom.Templates/templates/loom-worker/Directory.Packages.props b/src/Loom.Templates/templates/loom-worker/Directory.Packages.props new file mode 100644 index 0000000..11058e7 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/Directory.Packages.props @@ -0,0 +1,56 @@ + + + + true + true + + 0.1.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-worker/MyApp.slnx b/src/Loom.Templates/templates/loom-worker/MyApp.slnx new file mode 100644 index 0000000..ca0eb66 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/MyApp.slnx @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-worker/global.json b/src/Loom.Templates/templates/loom-worker/global.json new file mode 100644 index 0000000..9893006 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/global.json @@ -0,0 +1,9 @@ +{ + "sdk": { + "version": "10.0.302", + "rollForward": "latestFeature" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.AppHost/AppHost.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.AppHost/AppHost.cs new file mode 100644 index 0000000..07fdf7f --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.AppHost/AppHost.cs @@ -0,0 +1,20 @@ +using Aspire.Hosting; + +// Development-time orchestration only. Nothing here ships: it starts a database and the worker, and +// wires the connection string between them so that no developer has to keep one in a file. +IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args); + +// Resource names are deliberately not derived from the project name. Aspire allows only letters, +// digits and hyphens, and a solution called Acme.Billing would produce "acme.billing" — rejected at +// build time. +var postgres = builder.AddPostgres("postgres").WithDataVolume(); + +var database = postgres.AddDatabase("database"); + +// HostProject is substituted with the solution name, dots replaced by underscores, because that is +// how the Aspire SDK names the class it generates for a project reference. +builder.AddProject("worker") + .WithReference(database) + .WaitFor(database); + +await builder.Build().RunAsync(); diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.AppHost/MyApp.AppHost.csproj b/src/Loom.Templates/templates/loom-worker/src/MyApp.AppHost/MyApp.AppHost.csproj new file mode 100644 index 0000000..3d12eee --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.AppHost/MyApp.AppHost.csproj @@ -0,0 +1,22 @@ + + + + + + + Exe + 9f2a6c1e-0000-0000-0000-000000000001 + + + + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Domain/MyApp.Domain.csproj b/src/Loom.Templates/templates/loom-worker/src/MyApp.Domain/MyApp.Domain.csproj new file mode 100644 index 0000000..c514aad --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Domain/MyApp.Domain.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Domain/Widgets/OversizedWidgets.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.Domain/Widgets/OversizedWidgets.cs new file mode 100644 index 0000000..2d601a4 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Domain/Widgets/OversizedWidgets.cs @@ -0,0 +1,16 @@ +using Loom.Specifications; + +namespace MyApp.Domain.Widgets; + +/// +/// Widgets still in service and larger than a given size. +/// +/// +/// A named rule rather than a predicate inlined in the query that needs it, so the definition of +/// "oversized" has one home and reads the same wherever it is applied. +/// +public sealed class OversizedWidgets : Specification +{ + public OversizedWidgets(int largerThan) => + Where(widget => !widget.IsRetired && widget.Size > largerThan); +} diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Domain/Widgets/Widget.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.Domain/Widgets/Widget.cs new file mode 100644 index 0000000..109a4c5 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Domain/Widgets/Widget.cs @@ -0,0 +1,69 @@ +using Loom.Entities; +using Loom.Results; + +namespace MyApp.Domain.Widgets; + +/// +/// An example aggregate. Replace it — it exists to show the shape, not to be kept. +/// +public sealed class Widget : AggregateRoot +{ + private Widget(string name, int size) + { + Name = name; + Size = size; + } + + // Entity Framework materialises through this. It writes the identity from the column, so a + // parameterless constructor stays unambiguous whatever the domain constructors look like. + private Widget() + { + } + + public string Name { get; private set; } = string.Empty; + + public int Size { get; private set; } + + public bool IsRetired { get; private set; } + + /// + /// Creates a widget, or reports why it could not be created. + /// + /// + /// A factory returning a result rather than a constructor throwing: an invalid request is an + /// expected outcome, and expected outcomes are values. + /// + public static Result Create(string name, int size) + { + if (string.IsNullOrWhiteSpace(name)) + { + return WidgetErrors.NameRequired; + } + + if (size <= 0) + { + return WidgetErrors.SizeMustBePositive; + } + + return new Widget(name, size); + } + + /// + /// Takes the widget out of service, or reports that it already is. + /// + /// + /// The invariant lives here rather than in the worker that calls it. A worker is an entry point, + /// and an entry point deciding what is allowed is the same defect as an endpoint doing so. + /// + public Result Retire() + { + if (IsRetired) + { + return WidgetErrors.AlreadyRetired; + } + + IsRetired = true; + + return Result.Success; + } +} diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Domain/Widgets/WidgetErrors.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.Domain/Widgets/WidgetErrors.cs new file mode 100644 index 0000000..15d2d9a --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Domain/Widgets/WidgetErrors.cs @@ -0,0 +1,29 @@ +using Loom.Results; + +namespace MyApp.Domain.Widgets; + +/// +/// The failures a widget can report. Codes are stable and greppable; messages are free to be reworded. +/// +public static class WidgetErrors +{ + /// A widget was created without a name. + public static Error NameRequired { get; } = + Errors.Invalid("widgets.name_required", "A widget needs a name."); + + /// A widget was created with a size of zero or less. + public static Error SizeMustBePositive { get; } = + Errors.Invalid("widgets.size_must_be_positive", "A widget's size must be greater than zero."); + + /// No widget exists under that identifier. + public static Error NotFound { get; } = + Errors.NotFound("widgets.not_found", "No such widget."); + + /// The widget was already out of service, so retiring it again means nothing. + public static Error AlreadyRetired { get; } = + Errors.Conflict("widgets.already_retired", "The widget is already retired."); + + /// Storage was briefly unreachable. Retrying may succeed, so the caller should. + public static Error StorageUnavailable { get; } = + Errors.Unavailable("widgets.storage_unavailable", "Widget storage is temporarily unavailable."); +} diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.ServiceDefaults/MyApp.ServiceDefaults.csproj b/src/Loom.Templates/templates/loom-worker/src/MyApp.ServiceDefaults/MyApp.ServiceDefaults.csproj new file mode 100644 index 0000000..bd075a1 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.ServiceDefaults/MyApp.ServiceDefaults.csproj @@ -0,0 +1,17 @@ + + + + true + + + + + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.ServiceDefaults/ServiceDefaults.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.ServiceDefaults/ServiceDefaults.cs new file mode 100644 index 0000000..fd2f616 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.ServiceDefaults/ServiceDefaults.cs @@ -0,0 +1,90 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Telemetry, health, resilience and service discovery, configured once for every service. +/// +/// +/// This file is part of the application rather than a package: it is scaffolded in so that it can be +/// read and changed. That is why the orchestration choice survives the objection that ruled out other +/// frameworks — nothing here sits in the request path as a dependency you cannot see. +/// +public static class ServiceDefaults +{ + /// + /// Adds telemetry, a liveness check, service discovery and resilient HTTP defaults. + /// + /// The builder being configured. + /// The application builder. + /// The same builder, so calls can be chained. + /// is . + /// + /// The one call every service makes. Outgoing HTTP clients get retries and a circuit breaker by + /// default, so a service that forgets to ask for resilience still has it. + /// + public static TBuilder AddServiceDefaults(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ConfigureOpenTelemetry(); + + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + return builder; + } + + /// + /// Turns on structured logs, metrics and traces, exporting them only if a collector is configured. + /// + /// The builder being configured. + /// The application builder. + /// The same builder, so calls can be chained. + /// is . + /// + /// Instrumentation is always collected; the OTLP exporter is added only when + /// OTEL_EXPORTER_OTLP_ENDPOINT is set. That way a service run on its own does not spend the + /// application's startup failing to reach a collector that is not there. + /// + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => metrics + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation()) + .WithTracing(tracing => tracing + .AddHttpClientInstrumentation()); + + if (!string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"])) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + return builder; + } +} diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Features/Widgets/RetireOversizedWidgets.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Features/Widgets/RetireOversizedWidgets.cs new file mode 100644 index 0000000..216b4c8 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Features/Widgets/RetireOversizedWidgets.cs @@ -0,0 +1,71 @@ +using FluentValidation; +using Loom.Handlers; +using Loom.Persistence; +using Loom.Results; +using Microsoft.EntityFrameworkCore; +using MyApp.Domain.Widgets; +using MyApp.Worker.Infrastructure; +using Npgsql; + +namespace MyApp.Worker.Features.Widgets.RetireOversizedWidgets; + +// One operation, one file, one namespace — the same slice layout an API uses. A worker has no +// transport, so the handler is the entry point and the loop is the only thing above it. + +internal sealed record Request(int LargerThan, int BatchSize); + +internal sealed record Response(int Retired); + +internal sealed class Validator : AbstractValidator +{ + public Validator() + { + RuleFor(request => request.LargerThan).GreaterThan(0); + RuleFor(request => request.BatchSize).InclusiveBetween(1, 10_000); + } +} + +internal sealed class Handler(AppDbContext database) : IHandler +{ + public async Task> HandleAsync(Request request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + // Bounded and ordered. A scheduled operation runs against whatever has accumulated since the + // last tick, so an unbounded query is one backlog away from loading the table into memory; + // ordering makes which rows a pass takes deterministic rather than whatever the plan returns. + List oversized = await database.Widgets + .ApplySpecification(new OversizedWidgets(request.LargerThan)) + .OrderBy(widget => widget.Size) + .ThenBy(widget => widget.Id) + .Take(request.BatchSize) + .ToListAsync(cancellationToken); + + int retired = 0; + + foreach (Widget widget in oversized) + { + // The domain decides whether this is allowed. A widget retired by something else between + // the query and here reports Conflict, which is information rather than a failure of the + // batch — so the loop keeps going. + if (widget.Retire().IsSuccess) + { + retired++; + } + } + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException exception) when (exception.InnerException is NpgsqlException { IsTransient: true }) + { + // Reported rather than thrown, and only when the driver says the failure is transient. This + // is the one outcome the loop retries with backoff — anything else will fail identically on + // the next attempt, so it is dead-lettered instead. + return WidgetErrors.StorageUnavailable; + } + + return new Response(retired); + } +} diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Features/Widgets/WidgetConfiguration.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Features/Widgets/WidgetConfiguration.cs new file mode 100644 index 0000000..1633b28 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Features/Widgets/WidgetConfiguration.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MyApp.Domain.Widgets; + +namespace MyApp.Worker.Features.Widgets; + +/// +/// How a widget is stored. +/// +/// +/// Host-side and beside the aggregate's slices, so the mapping lives where the aggregate is worked on. +/// The domain stays free of persistence: it carries no attributes and no reference to Entity Framework. +/// +internal sealed class WidgetConfiguration : IEntityTypeConfiguration +{ + void IEntityTypeConfiguration.Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.HasKey(widget => widget.Id); + builder.Property(widget => widget.Name).HasMaxLength(200).IsRequired(); + } +} diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Infrastructure/AppDbContext.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Infrastructure/AppDbContext.cs new file mode 100644 index 0000000..69be7ed --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Infrastructure/AppDbContext.cs @@ -0,0 +1,28 @@ +using Loom.Persistence; +using Microsoft.EntityFrameworkCore; +using MyApp.Domain.Widgets; + +namespace MyApp.Worker.Infrastructure; + +/// +/// The one context for the application. +/// +public sealed class AppDbContext(DbContextOptions options) : DbContext(options) +{ + /// Gets the widgets. Only aggregate roots get a set of their own. + public DbSet Widgets => Set(); + + // Conventions, not model creation. Property discovery skips types it does not recognise, so an + // identity that is not a primary key would never enter the model at all. + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) => + configurationBuilder.UseLoomIdentities(typeof(Widget).Assembly); + + // Discovered rather than listed, so adding an aggregate means adding its configuration beside its + // slices and nothing here. + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + } +} diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Infrastructure/DesignTimeDbContextFactory.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Infrastructure/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..4cf4a78 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Infrastructure/DesignTimeDbContextFactory.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace MyApp.Worker.Infrastructure; + +/// +/// Builds a context for the migration tooling, without starting the application. +/// +/// +/// Migrations are generated against a connection string that is never connected to, so `dotnet ef` +/// needs neither a running database nor the application's configuration. +/// +internal sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + AppDbContext IDesignTimeDbContextFactory.CreateDbContext(string[] args) + { + DbContextOptionsBuilder builder = new(); + builder.UseNpgsql("Host=design-time;Database=designtime;Username=none;Password=none"); + + return new AppDbContext(builder.Options); + } +} diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Migrations/.editorconfig b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Migrations/.editorconfig new file mode 100644 index 0000000..71803a5 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Migrations/.editorconfig @@ -0,0 +1,8 @@ +# Migrations are emitted by the tooling, not written by hand, so the repository's style rules do not +# apply to them. Reformatting them would only be undone by the next generated migration. +[*.cs] +generated_code = true +dotnet_analyzer_diagnostic.severity = none +dotnet_diagnostic.IDE0161.severity = none +dotnet_diagnostic.IDE0300.severity = none +dotnet_diagnostic.IDE0028.severity = none diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Migrations/20260731040634_InitialSchema.Designer.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Migrations/20260731040634_InitialSchema.Designer.cs new file mode 100644 index 0000000..02da8b9 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Migrations/20260731040634_InitialSchema.Designer.cs @@ -0,0 +1,51 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyApp.Worker.Infrastructure; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MyApp.Worker.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260731040634_InitialSchema")] + partial class InitialSchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MyApp.Domain.Widgets.Widget", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("IsRetired") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Widgets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Migrations/20260731040634_InitialSchema.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Migrations/20260731040634_InitialSchema.cs new file mode 100644 index 0000000..5ea4bf1 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Migrations/20260731040634_InitialSchema.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MyApp.Worker.Migrations +{ + /// + public partial class InitialSchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Widgets", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Size = table.Column(type: "integer", nullable: false), + IsRetired = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Widgets", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Widgets"); + } + } +} diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Migrations/AppDbContextModelSnapshot.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..4bddc4d --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,48 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyApp.Worker.Infrastructure; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MyApp.Worker.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MyApp.Domain.Widgets.Widget", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("IsRetired") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Widgets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/MyApp.Worker.csproj b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/MyApp.Worker.csproj new file mode 100644 index 0000000..bdf862f --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/MyApp.Worker.csproj @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + all + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Program.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Program.cs new file mode 100644 index 0000000..4da475c --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Program.cs @@ -0,0 +1,24 @@ +using MyApp.Worker; +using MyApp.Worker.Workers; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + +// Telemetry, health, resilience and service discovery, from the scaffolded defaults project. +builder.AddServiceDefaults(); + +// Read once and refused here, so the failure lands at startup rather than on the first iteration. +string connectionString = builder.Configuration.GetConnectionString("database") + ?? throw new InvalidOperationException( + "No 'database' connection string was configured. The AppHost provides one when running " + + "locally; a deployment supplies it through configuration."); + +builder.Services.AddWorkerServices(connectionString, builder.Configuration); + +builder.Services.AddHostedService(); + +IHost host = builder.Build(); + +await host.RunAsync(); + +// Exposed so the test assembly can name this one. +public sealed partial class Program; diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/WorkerServices.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/WorkerServices.cs new file mode 100644 index 0000000..5a9f1ea --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/WorkerServices.cs @@ -0,0 +1,67 @@ +using FluentValidation; +using Loom.Handlers; +using Loom.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using MyApp.Worker.Features.Widgets.RetireOversizedWidgets; +using MyApp.Worker.Infrastructure; +using MyApp.Worker.Workers; + +namespace MyApp.Worker; + +/// +/// Everything the worker needs, registered in one place. +/// +/// +/// Shared with the test host on purpose. A test that rebuilds the service graph by hand is testing a +/// graph nobody runs: the decorator chain, the validators and the interceptor can all be registered +/// differently there, and the difference only shows up in production. This is the composition root, so +/// reading configuration here is composition rather than a service reaching for it. +/// +internal static class WorkerServices +{ + public static IServiceCollection AddWorkerServices( + this IServiceCollection services, + string connectionString, + IConfiguration? configuration = null) + { + // Injected rather than taken from DateTime.Now, which is also what makes the schedule testable. + services.AddSingleton(TimeProvider.System); + + services.AddDbContext((serviceProvider, options) => options + .UseNpgsql(connectionString) + .AddInterceptors(serviceProvider.GetRequiredService())); + + services.AddLoomPersistence(); + + services.AddSingleton(); + + // Declared once and applied to every handler, so a slice cannot be registered without + // validation by forgetting a call. Declaration order is nesting order. + services + .AddLoomHandlers(chain => chain + .WithLogging() + .WithValidation() + .WithDomainEventFailures()) + .AddHandler(); + + // includeInternalTypes matters: slice validators are internal, and without it none are + // registered. The validating decorator treats a missing validator as nothing to validate, so + // the omission would be silent. + services.AddValidatorsFromAssembly( + typeof(WorkerServices).Assembly, + ServiceLifetime.Scoped, + includeInternalTypes: true); + + OptionsBuilder options = services.AddOptions(); + + if (configuration is not null) + { + options.Bind(configuration.GetSection(RetireWidgetsOptions.SectionName)); + } + + options.ValidateDataAnnotations().ValidateOnStart(); + + return services; + } +} diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Workers/RetireWidgetsOptions.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Workers/RetireWidgetsOptions.cs new file mode 100644 index 0000000..7df9a73 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Workers/RetireWidgetsOptions.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; + +namespace MyApp.Worker.Workers; + +/// +/// How often widgets are retired, and which ones count as oversized. +/// +/// +/// Both are deployment settings rather than facts about the domain, so neither belongs in the loop as +/// a constant. Validated at startup, so a misconfigured worker fails to boot rather than on its first +/// tick — which, on a five-minute interval, is a long way from the deployment that caused it. +/// +internal sealed class RetireWidgetsOptions +{ + /// The configuration section these settings are bound from. + public const string SectionName = "RetireWidgets"; + + /// Gets how long to wait between passes. + [Range(typeof(TimeSpan), "00:00:01", "1.00:00:00")] + public TimeSpan Interval { get; init; } = TimeSpan.FromMinutes(5); + + /// Gets the size above which a widget is retired. + [Range(1, int.MaxValue)] + public int LargerThan { get; init; } = 100; + + /// Gets the base wait before a transient failure is retried, doubling per attempt. + /// Zero disables waiting, which is what a test wants and a deployment never does. + [Range(typeof(TimeSpan), "00:00:00", "00:05:00")] + public TimeSpan RetryBackoff { get; init; } = TimeSpan.FromSeconds(2); + + /// Gets how many widgets one pass may retire. + /// + /// Bounded so a backlog is worked through over several ticks instead of loading every matching row + /// into memory at once. + /// + [Range(1, 10_000)] + public int BatchSize { get; init; } = 500; +} diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Workers/RetireWidgetsPass.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Workers/RetireWidgetsPass.cs new file mode 100644 index 0000000..d675bdb --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Workers/RetireWidgetsPass.cs @@ -0,0 +1,93 @@ +using Loom.Handlers; +using Loom.Results; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using MyApp.Worker.Features.Widgets.RetireOversizedWidgets; + +namespace MyApp.Worker.Workers; + +/// +/// One pass of the work: a scope, a dispatch, and what the outcome means. +/// +/// +/// Separate from the worker that schedules it, because the two fail in different ways and are worth +/// testing apart. Everything here is decided by the result, so it can be exercised against a stub +/// handler with no clock and no timer — leaving the worker with only "tick, run, do not die". +/// +internal sealed partial class RetireWidgetsPass( + IServiceScopeFactory scopes, + TimeProvider clock, + IOptions options, + ILogger logger) +{ + /// How many times a transient failure is retried before the pass is abandoned. + /// + /// Bounded on purpose. Unbounded retry against a permanent failure is an outage with extra steps, + /// and the next tick will try again anyway. + /// + internal const int MaximumAttempts = 3; + + private readonly RetireWidgetsOptions _options = options.Value; + + public async Task RunAsync(CancellationToken cancellationToken) + { + for (int attempt = 1; attempt <= MaximumAttempts; attempt++) + { + // A scope per attempt, never one held across them. A DbContext kept between attempts + // accumulates tracked entities and eventually answers from a stale graph. + await using AsyncServiceScope scope = scopes.CreateAsyncScope(); + + IHandler handler = scope.ServiceProvider + .GetRequiredService>(); + + Result result = await handler + .HandleAsync(new Request(_options.LargerThan, _options.BatchSize), cancellationToken) + .ConfigureAwait(false); + + if (result.IsSuccess) + { + Retired(logger, result.Value.Retired); + return; + } + + // The category decides what happens next, which is the same decision a status code + // expresses at an HTTP boundary. Only a dependency that might recover is worth retrying; + // anything the caller got wrong will be just as wrong next time. + if (result.Error.Category is ErrorCategory.NotFound) + { + // Nothing to do rather than something wrong: a scheduled pass finding no work is the + // ordinary case on a quiet system, so it is recorded once and not raised as a failure. + NothingToDo(logger, result.Error.Code); + return; + } + + if (result.Error.Category is not ErrorCategory.Unavailable) + { + DeadLettered(logger, result.Error.Code, result.Error.Message); + return; + } + + if (attempt == MaximumAttempts) + { + GaveUp(logger, MaximumAttempts, result.Error.Code); + return; + } + + await Task.Delay(Backoff(attempt), clock, cancellationToken).ConfigureAwait(false); + } + } + + private TimeSpan Backoff(int attempt) => _options.RetryBackoff * Math.Pow(2, attempt - 1); + + [LoggerMessage(Level = LogLevel.Information, Message = "Retired {Count} widget(s).")] + private static partial void Retired(ILogger logger, int count); + + [LoggerMessage(Level = LogLevel.Information, Message = "Nothing to do: {Code}")] + private static partial void NothingToDo(ILogger logger, string code); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Pass abandoned: {Code} {Reason}")] + private static partial void DeadLettered(ILogger logger, string code, string reason); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Gave up after {Attempts} attempts: {Code}")] + private static partial void GaveUp(ILogger logger, int attempts, string code); +} diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Workers/RetireWidgetsWorker.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Workers/RetireWidgetsWorker.cs new file mode 100644 index 0000000..afd2e42 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Workers/RetireWidgetsWorker.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.Options; + +namespace MyApp.Worker.Workers; + +/// +/// Runs one pass on a schedule. +/// +/// +/// Holds no business logic and no decision about outcomes — that is . +/// What is left is the schedule and one guarantee: a failed pass must not end the loop. +/// +internal sealed partial class RetireWidgetsWorker( + RetireWidgetsPass pass, + TimeProvider clock, + IOptions options, + ILogger logger) + : BackgroundService +{ + private readonly RetireWidgetsOptions _options = options.Value; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using PeriodicTimer timer = new(_options.Interval, clock); + + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + await RunGuardedAsync(stoppingToken).ConfigureAwait(false); + } + } + + /// Runs one pass and refuses to let it end the loop. + /// + /// Separate from so it can be called directly. This is the worker's + /// only real behaviour, and reaching it through the timer would mean driving a hosted service from + /// a fake clock — which is a test of PeriodicTimer rather than of this. + /// + internal async Task RunGuardedAsync(CancellationToken stoppingToken) + { + try + { + await pass.RunAsync(stoppingToken).ConfigureAwait(false); + } + catch (Exception exception) + when (exception is not OperationCanceledException || !stoppingToken.IsCancellationRequested) + { + // An exception leaving ExecuteAsync stops the service, and in some hosting models it does + // so silently. Cancellation is only fatal when shutdown asked for it: a timeout inside a + // pass also surfaces as OperationCanceledException, and treating that as shutdown would + // stop the worker for good over one slow query. + Failed(logger, exception); + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "A pass threw and was swallowed to keep the worker alive.")] + private static partial void Failed(ILogger logger, Exception exception); +} diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/appsettings.json b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/appsettings.json new file mode 100644 index 0000000..28554e2 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/appsettings.json @@ -0,0 +1,3 @@ +{ + "Logging": { "LogLevel": { "Default": "Information", "Microsoft.Hosting.Lifetime": "Information" } } +} diff --git a/src/Loom.Templates/templates/loom-worker/tests/MyApp.ArchitectureTests/ConfigurationBoundaryTests.cs b/src/Loom.Templates/templates/loom-worker/tests/MyApp.ArchitectureTests/ConfigurationBoundaryTests.cs new file mode 100644 index 0000000..d405e38 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/tests/MyApp.ArchitectureTests/ConfigurationBoundaryTests.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using Microsoft.Extensions.Configuration; +using MyApp.Worker.Infrastructure; + +namespace MyApp.ArchitectureTests; + +/// +/// Configuration is read while the application is composed, never by something the container built. +/// +/// +/// Reading a value in Program.cs or a startup extension is composition. A service taking +/// is different in kind: it hides a dependency its constructor does not +/// declare, and it cannot be tested without standing up configuration. Constructor parameters are what +/// separates the two, so that is what this asserts. +/// +public sealed class ConfigurationBoundaryTests +{ + [Test] + public async Task Nothing_Takes_IConfiguration_As_A_Constructor_Parameter() + { + string[] offenders = + [ + .. typeof(AppDbContext).Assembly.GetTypes() + .SelectMany(type => type.GetConstructors( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Select(constructor => new { type, constructor })) + .Where(candidate => candidate.constructor.GetParameters() + .Any(parameter => typeof(IConfiguration).IsAssignableFrom(parameter.ParameterType))) + .Select(candidate => candidate.type.FullName ?? candidate.type.Name) + .Distinct(), + ]; + + await Assert.That(offenders).IsEmpty(); + } +} diff --git a/src/Loom.Templates/templates/loom-worker/tests/MyApp.ArchitectureTests/DomainPurityTests.cs b/src/Loom.Templates/templates/loom-worker/tests/MyApp.ArchitectureTests/DomainPurityTests.cs new file mode 100644 index 0000000..7cbf1fa --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/tests/MyApp.ArchitectureTests/DomainPurityTests.cs @@ -0,0 +1,46 @@ +using System.Reflection; +using MyApp.Domain.Widgets; +using NetArchTest.Rules; +using ArchTestResult = NetArchTest.Rules.TestResult; + +namespace MyApp.ArchitectureTests; + +/// +/// The structural rules, asserted rather than reviewed. +/// +public sealed class DomainPurityTests +{ + private static readonly Assembly Domain = typeof(Widget).Assembly; + + [Test] + public async Task The_Domain_Depends_On_Nothing_But_Loom_And_The_Base_Class_Library() + { + string[] forbidden = + [ + "Microsoft.EntityFrameworkCore", + "Microsoft.AspNetCore", + "FluentValidation", + "Npgsql", + "Microsoft.Extensions.DependencyInjection", + ]; + + ArchTestResult result = Types.InAssembly(Domain) + .Should() + .NotHaveDependencyOnAny(forbidden) + .GetResult(); + + // Named, because "false" does not say which type reached for what. + await Assert.That(result.FailingTypeNames ?? []).IsEmpty(); + } + + [Test] + public async Task Entities_Are_Sealed() + { + ArchTestResult result = Types.InAssembly(Domain) + .That().AreClasses().And().ArePublic() + .Should().BeSealed() + .GetResult(); + + await Assert.That(result.IsSuccessful).IsTrue(); + } +} diff --git a/src/Loom.Templates/templates/loom-worker/tests/MyApp.ArchitectureTests/EnforcementTests.cs b/src/Loom.Templates/templates/loom-worker/tests/MyApp.ArchitectureTests/EnforcementTests.cs new file mode 100644 index 0000000..da07ace --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/tests/MyApp.ArchitectureTests/EnforcementTests.cs @@ -0,0 +1,116 @@ +using System.Reflection; +using Loom.Entities; +using MyApp.Worker.Infrastructure; +using NetArchTest.Rules; +using ArchTestResult = NetArchTest.Rules.TestResult; + +namespace MyApp.ArchitectureTests; + +/// +/// The structural rules the guidance lists, asserted rather than reviewed. +/// +/// +/// A structural rule with no test is a rule that erodes: nothing about a slice reaching into another +/// one fails to compile, and nothing about an entity crossing a contract boundary does either. +/// +public sealed class EnforcementTests +{ + private static readonly Assembly Host = typeof(AppDbContext).Assembly; + + private static readonly string SliceRoot = $"{Host.GetName().Name}.Features."; + + [Test] + public async Task No_Slice_Depends_On_Another_Slice() + { + string[] slices = Slices(); + + await Assert.That(slices).IsNotEmpty(); + + List offenders = []; + + foreach (string slice in slices) + { + string[] others = [.. slices.Where(candidate => candidate != slice)]; + + if (others.Length is 0) + { + continue; + } + + ArchTestResult result = Types.InAssembly(Host) + .That().ResideInNamespace(slice) + .Should().NotHaveDependencyOnAny(others) + .GetResult(); + + offenders.AddRange(result.FailingTypeNames ?? []); + } + + // Slices are independent by construction, not by convention. One reaching into another is how + // a vertical slice quietly becomes a layer. + await Assert.That(offenders).IsEmpty(); + } + + [Test] + public async Task Domain_Entities_Do_Not_Cross_A_Contract_Boundary() + { + Type[] contracts = [.. Host.GetTypes().Where(type => type.Name is "Request" or "Response")]; + + await Assert.That(contracts).IsNotEmpty(); + + List offenders = + [ + .. from contract in contracts + from property in contract.GetProperties() + where NamesAnEntity(property.PropertyType) + select $"{contract.FullName}.{property.Name}", + ]; + + // An entity on a request or a response leaks persistence and invariants into the wire format, + // and couples the two to each other for good. + await Assert.That(offenders).IsEmpty(); + } + + // A slice is a namespace with a handler in it. The aggregate folder above them holds shared + // pieces such as the entity configuration and is not one — and namespace matching is by prefix, so + // counting a parent would report it as depending on its own children. + private static string[] Slices() => + [.. Host.GetTypes() + .Where(type => type.Name is "Handler" + && type.Namespace is not null + && type.Namespace.StartsWith(SliceRoot, StringComparison.Ordinal)) + .Select(type => type.Namespace!) + .Distinct() + .Order(StringComparer.Ordinal)]; + + private static bool NamesAnEntity(Type type) + { + // Id names an entity without being one. Unwrapping through it would flag every + // correctly typed identifier, which is the opposite of the rule. + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Id<>)) + { + return false; + } + + // An array is not generic and its base chain runs to Array, so without this a Widget[] on a + // contract would go unnoticed. + if (type.IsArray) + { + return NamesAnEntity(type.GetElementType()!); + } + + if (type.IsGenericType && type.GetGenericArguments().Any(NamesAnEntity)) + { + return true; + } + + for (Type? candidate = type; candidate is not null; candidate = candidate.BaseType) + { + if (candidate.IsGenericType && candidate.GetGenericTypeDefinition() == typeof(Entity<>)) + { + return true; + } + } + + return false; + } +} diff --git a/src/Loom.Templates/templates/loom-worker/tests/MyApp.ArchitectureTests/MyApp.ArchitectureTests.csproj b/src/Loom.Templates/templates/loom-worker/tests/MyApp.ArchitectureTests/MyApp.ArchitectureTests.csproj new file mode 100644 index 0000000..0e2ec42 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/tests/MyApp.ArchitectureTests/MyApp.ArchitectureTests.csproj @@ -0,0 +1,17 @@ + + + + Exe + + + + + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-worker/tests/MyApp.Domain.Tests/MyApp.Domain.Tests.csproj b/src/Loom.Templates/templates/loom-worker/tests/MyApp.Domain.Tests/MyApp.Domain.Tests.csproj new file mode 100644 index 0000000..6830ef7 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/tests/MyApp.Domain.Tests/MyApp.Domain.Tests.csproj @@ -0,0 +1,15 @@ + + + + Exe + + + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-worker/tests/MyApp.Domain.Tests/WidgetTests.cs b/src/Loom.Templates/templates/loom-worker/tests/MyApp.Domain.Tests/WidgetTests.cs new file mode 100644 index 0000000..80da7b1 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/tests/MyApp.Domain.Tests/WidgetTests.cs @@ -0,0 +1,38 @@ +using Loom.Results; +using MyApp.Domain.Widgets; + +namespace MyApp.Domain.Tests; + +/// +/// Pure, fast, no infrastructure. Invariants are testable without a database because they live in the +/// domain rather than in a handler. +/// +public sealed class WidgetTests +{ + [Test] + public async Task A_Valid_Widget_Is_Created() + { + Result created = Widget.Create("bolt", 3); + + await Assert.That(created.IsSuccess).IsTrue(); + await Assert.That(created.Value.Name).IsEqualTo("bolt"); + } + + [Test] + public async Task A_Widget_Without_A_Name_Is_Refused() + { + Result created = Widget.Create(" ", 3); + + await Assert.That(created.IsFailure).IsTrue(); + await Assert.That(created.Error.Code).IsEqualTo(WidgetErrors.NameRequired.Code); + } + + [Test] + public async Task A_Widget_Must_Have_A_Positive_Size() + { + Result created = Widget.Create("bolt", 0); + + await Assert.That(created.IsFailure).IsTrue(); + await Assert.That(created.Error.Code).IsEqualTo(WidgetErrors.SizeMustBePositive.Code); + } +} diff --git a/src/Loom.Templates/templates/loom-worker/tests/MyApp.Worker.Tests/MyApp.Worker.Tests.csproj b/src/Loom.Templates/templates/loom-worker/tests/MyApp.Worker.Tests/MyApp.Worker.Tests.csproj new file mode 100644 index 0000000..4036362 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/tests/MyApp.Worker.Tests/MyApp.Worker.Tests.csproj @@ -0,0 +1,21 @@ + + + + Exe + + + + + + + + + + + + + + + + + diff --git a/src/Loom.Templates/templates/loom-worker/tests/MyApp.Worker.Tests/PassTests.cs b/src/Loom.Templates/templates/loom-worker/tests/MyApp.Worker.Tests/PassTests.cs new file mode 100644 index 0000000..8b33b4f --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/tests/MyApp.Worker.Tests/PassTests.cs @@ -0,0 +1,131 @@ +using Loom.Handlers; +using Loom.Results; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MyApp.Worker.Features.Widgets.RetireOversizedWidgets; +using MyApp.Worker.Workers; + +namespace MyApp.Worker.Tests; + +/// +/// What one pass does with each outcome. +/// +/// +/// No clock, no timer and no database. Everything here is decided by the result the handler returns, +/// so a stub handler and a zero backoff make every case deterministic — there is nothing to wait for +/// and nothing to advance. +/// +public sealed class PassTests +{ + [Test] + public async Task A_Success_Runs_Once() + { + Recorder recorder = new(); + await RunAsync(recorder, _ => Result.Success(new Response(3))); + + await Assert.That(recorder.Attempts).IsEqualTo(1); + } + + [Test] + public async Task A_Transient_Failure_Is_Retried_To_The_Limit() + { + Recorder recorder = new(); + await RunAsync(recorder, _ => WidgetErrorsUnavailable); + + // Bounded: the next tick will try again anyway, so retrying forever only turns a permanent + // failure into an outage. + await Assert.That(recorder.Attempts).IsEqualTo(RetireWidgetsPass.MaximumAttempts); + } + + [Test] + public async Task A_Transient_Failure_That_Clears_Stops_Retrying() + { + Recorder recorder = new(); + await RunAsync(recorder, attempt => + attempt == 1 ? WidgetErrorsUnavailable : Result.Success(new Response(1))); + + await Assert.That(recorder.Attempts).IsEqualTo(2); + } + + [Test] + public async Task A_Permanent_Failure_Is_Not_Retried() + { + Recorder recorder = new(); + await RunAsync(recorder, _ => Errors.Invalid("widgets.nonsense", "Nothing will change this.")); + + // Retrying an Invalid or Conflict result produces the identical answer, so it is dead-lettered + // on the first attempt rather than three times. + await Assert.That(recorder.Attempts).IsEqualTo(1); + } + + [Test] + public async Task Every_Attempt_Gets_Its_Own_Scope() + { + Recorder recorder = new(); + await RunAsync(recorder, _ => WidgetErrorsUnavailable); + + await Assert.That(recorder.Attempts).IsEqualTo(RetireWidgetsPass.MaximumAttempts); + + // A DbContext held across attempts accumulates tracked entities and eventually answers from a + // stale graph, so distinct scopes are asserted rather than assumed. + await Assert.That(recorder.DistinctScopes).IsEqualTo(recorder.Attempts); + } + + private static Result WidgetErrorsUnavailable => + Errors.Unavailable("widgets.storage_unavailable", "Widget storage is temporarily unavailable."); + + private static async Task RunAsync(Recorder recorder, Func> behaviour) + { + ServiceCollection services = new(); + services.AddLogging(); + services.AddSingleton(recorder); + services.AddScoped(); + services.AddScoped>(provider => new StubHandler( + provider.GetRequiredService(), + provider.GetRequiredService(), + behaviour)); + + await using ServiceProvider provider = services.BuildServiceProvider(); + + RetireWidgetsPass pass = new( + provider.GetRequiredService(), + TimeProvider.System, + // Zero backoff: the retry policy is what is under test, not how long it waits. + Options.Create(new RetireWidgetsOptions { RetryBackoff = TimeSpan.Zero }), + provider.GetRequiredService>()); + + await pass.RunAsync(CancellationToken.None); + } + + private sealed class StubHandler(Recorder recorder, ScopeMarker scope, Func> behaviour) + : IHandler + { + public Task> HandleAsync(Request request, CancellationToken cancellationToken) => + Task.FromResult(behaviour(recorder.Record(scope.Id))); + } + + /// Scoped, so its identity differs for every scope the pass resolves. + private sealed class ScopeMarker + { + public Guid Id { get; } = Guid.CreateVersion7(); + } + + private sealed class Recorder + { + private readonly HashSet _scopes = []; + + public int Attempts { get; private set; } + + public int DistinctScopes => _scopes.Count; + + /// Records an attempt and returns its number. + public int Record(Guid scopeId) + { + Attempts++; + _scopes.Add(scopeId); + + return Attempts; + } + } +} diff --git a/src/Loom.Templates/templates/loom-worker/tests/MyApp.Worker.Tests/ResilienceTests.cs b/src/Loom.Templates/templates/loom-worker/tests/MyApp.Worker.Tests/ResilienceTests.cs new file mode 100644 index 0000000..2d7946b --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/tests/MyApp.Worker.Tests/ResilienceTests.cs @@ -0,0 +1,82 @@ +using Loom.Handlers; +using Loom.Results; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MyApp.Worker.Features.Widgets.RetireOversizedWidgets; +using MyApp.Worker.Workers; + +namespace MyApp.Worker.Tests; + +/// +/// A failing pass must not end the loop, and shutdown must. +/// +/// +/// One guarded iteration is called directly rather than driven through the timer. That the timer +/// fires is PeriodicTimer's business; what matters here is which exceptions survive it and +/// which do not. No clock, no timer, nothing to wait for. +/// +public sealed class ResilienceTests +{ + [Test] + public async Task A_Throwing_Pass_Is_Swallowed() + { + RetireWidgetsWorker worker = Build(() => throw new InvalidOperationException("the dependency exploded")); + + // An exception leaving ExecuteAsync stops the service, in some hosting models silently. + await worker.RunGuardedAsync(CancellationToken.None); + } + + [Test] + public async Task A_Cancellation_That_Is_Not_Shutdown_Is_Swallowed() + { + RetireWidgetsWorker worker = Build(() => throw new OperationCanceledException()); + + // A timeout inside a pass surfaces the same way shutdown does. Treating it as shutdown would + // stop the worker permanently over one slow query. + await worker.RunGuardedAsync(CancellationToken.None); + } + + [Test] + public async Task Shutdown_Ends_The_Loop() + { + using CancellationTokenSource stopping = new(); + await stopping.CancelAsync(); + + RetireWidgetsWorker worker = Build(() => throw new OperationCanceledException()); + + await Assert.That(async () => await worker.RunGuardedAsync(stopping.Token)) + .Throws(); + } + + private static RetireWidgetsWorker Build(Func> behaviour) + { + ServiceCollection services = new(); + services.AddLogging(); + services.AddScoped>(_ => new StubHandler(behaviour)); + + ServiceProvider provider = services.BuildServiceProvider(); + IOptions options = Options.Create(new RetireWidgetsOptions + { + RetryBackoff = TimeSpan.Zero, + }); + + RetireWidgetsPass pass = new( + provider.GetRequiredService(), + TimeProvider.System, + options, + provider.GetRequiredService>()); + + return new RetireWidgetsWorker( + pass, + TimeProvider.System, + options, + provider.GetRequiredService>()); + } + + private sealed class StubHandler(Func> behaviour) : IHandler + { + public Task> HandleAsync(Request request, CancellationToken cancellationToken) => + Task.FromResult(behaviour()); + } +} diff --git a/src/Loom.Templates/templates/loom-worker/tests/MyApp.Worker.Tests/RetireOversizedWidgetsTests.cs b/src/Loom.Templates/templates/loom-worker/tests/MyApp.Worker.Tests/RetireOversizedWidgetsTests.cs new file mode 100644 index 0000000..32e8adc --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/tests/MyApp.Worker.Tests/RetireOversizedWidgetsTests.cs @@ -0,0 +1,107 @@ +using Loom.Handlers; +using Loom.Results; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using MyApp.Domain.Widgets; +using MyApp.Worker.Features.Widgets.RetireOversizedWidgets; + +namespace MyApp.Worker.Tests; + +/// +/// The slice, exercised through the same decorator chain the worker resolves at run time. +/// +[NotInParallel] +public sealed class RetireOversizedWidgetsTests +{ + private WorkerHost _host = null!; + + [Before(Test)] + public async Task ResetAsync() + { + _host = WorkerFixture.Host; + await _host.ResetAsync(); + } + + [Test] + public async Task Oversized_Widgets_Are_Retired_And_Others_Are_Left_Alone() + { + await _host.InDatabaseAsync(async database => + { + database.Widgets.Add(Widget.Create("big", 500).Value); + database.Widgets.Add(Widget.Create("small", 5).Value); + await database.SaveChangesAsync(); + }); + + Result result = await DispatchAsync(new Request(LargerThan: 100, BatchSize: 500)); + + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value.Retired).IsEqualTo(1); + + await _host.InDatabaseAsync(async database => + { + await Assert.That(await database.Widgets.CountAsync(widget => widget.IsRetired)).IsEqualTo(1); + await Assert.That(await database.Widgets.CountAsync(widget => !widget.IsRetired)).IsEqualTo(1); + }); + } + + [Test] + public async Task Running_Twice_Retires_Nothing_The_Second_Time() + { + await _host.InDatabaseAsync(async database => + { + database.Widgets.Add(Widget.Create("big", 500).Value); + await database.SaveChangesAsync(); + }); + + Result first = await DispatchAsync(new Request(LargerThan: 100, BatchSize: 500)); + Result second = await DispatchAsync(new Request(LargerThan: 100, BatchSize: 500)); + + await Assert.That(first.Value.Retired).IsEqualTo(1); + + // A scheduled operation runs again and again, so doing nothing the second time is the + // behaviour that matters most, not a detail. + await Assert.That(second.Value.Retired).IsEqualTo(0); + } + + [Test] + public async Task An_Invalid_Request_Is_Refused_By_The_Decorator() + { + // Rejected by the validator before the handler runs. The handler would happily accept it, + // which is what makes this a test of the chain rather than of the query. + Result result = await DispatchAsync(new Request(LargerThan: 0, BatchSize: 500)); + + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error.Category).IsEqualTo(ErrorCategory.Invalid); + } + + [Test] + public async Task A_Pass_Retires_At_Most_One_Batch() + { + await _host.InDatabaseAsync(async database => + { + for (int i = 0; i < 5; i++) + { + database.Widgets.Add(Widget.Create($"big-{i}", 500 + i).Value); + } + + await database.SaveChangesAsync(); + }); + + Result result = await DispatchAsync(new Request(LargerThan: 100, BatchSize: 2)); + + // Bounded, so a backlog is worked through over several ticks rather than loaded at once. + await Assert.That(result.Value.Retired).IsEqualTo(2); + + await _host.InDatabaseAsync(async database => + await Assert.That(await database.Widgets.CountAsync(widget => !widget.IsRetired)).IsEqualTo(3)); + } + + private async Task> DispatchAsync(Request request) + { + using IServiceScope scope = _host.CreateScope(); + + return await scope.ServiceProvider + .GetRequiredService>() + .HandleAsync(request, CancellationToken.None); + } +} diff --git a/src/Loom.Templates/templates/loom-worker/tests/MyApp.Worker.Tests/WorkerHost.cs b/src/Loom.Templates/templates/loom-worker/tests/MyApp.Worker.Tests/WorkerHost.cs new file mode 100644 index 0000000..f9c7ab0 --- /dev/null +++ b/src/Loom.Templates/templates/loom-worker/tests/MyApp.Worker.Tests/WorkerHost.cs @@ -0,0 +1,107 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using MyApp.Worker.Infrastructure; +using Npgsql; +using Respawn; +using Testcontainers.PostgreSql; + +namespace MyApp.Worker.Tests; + +/// +/// One Postgres container and one database for the whole assembly, reset between tests. +/// +/// +/// A worker has no transport, so there is no host to go through and the handler is the entry point. +/// What this provides is the real service graph a scope would resolve at run time — including the +/// decorator chain, which is most of what a test would otherwise skip by calling a handler directly. +/// +public sealed class WorkerHost : IAsyncDisposable +{ + private readonly PostgreSqlContainer _container; + private readonly ServiceProvider _provider; + private readonly Respawner _respawner; + private readonly NpgsqlConnection _resetConnection; + + private WorkerHost( + PostgreSqlContainer container, + ServiceProvider provider, + Respawner respawner, + NpgsqlConnection resetConnection) + { + _container = container; + _provider = provider; + _respawner = respawner; + _resetConnection = resetConnection; + } + + public static async Task StartAsync() + { + PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build(); + await container.StartAsync(); + + // The application's own registrations, not a second set written for the tests. A hand-built + // graph can differ from the real one in exactly the ways that matter — a missing decorator, an + // unregistered validator — and still pass. + ServiceCollection services = new(); + services.AddLogging(); + services.AddWorkerServices(container.GetConnectionString()); + + ServiceProvider provider = services.BuildServiceProvider(); + + using (IServiceScope scope = provider.CreateScope()) + { + // The schema comes from the same migrations a deployment applies, so the tests exercise the + // path that ships rather than one EF derives from the model. + await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync(); + } + + NpgsqlConnection resetConnection = new(container.GetConnectionString()); + await resetConnection.OpenAsync(); + + Respawner respawner = await Respawner.CreateAsync(resetConnection, new RespawnerOptions + { + DbAdapter = DbAdapter.Postgres, + SchemasToInclude = ["public"], + }); + + return new WorkerHost(container, provider, respawner, resetConnection); + } + + public Task ResetAsync() => _respawner.ResetAsync(_resetConnection); + + public IServiceScope CreateScope() => _provider.CreateScope(); + + public async Task InDatabaseAsync(Func work) + { + using IServiceScope scope = CreateScope(); + await work(scope.ServiceProvider.GetRequiredService()); + } + + public async ValueTask DisposeAsync() + { + await _resetConnection.DisposeAsync(); + await _provider.DisposeAsync(); + await _container.DisposeAsync(); + } +} + +/// Starts one container for the whole assembly. +public static class WorkerFixture +{ + private static WorkerHost? _host; + + public static WorkerHost Host => _host ?? throw new InvalidOperationException("The fixture has not started."); + + [Before(Assembly)] + public static async Task StartAsync() => _host = await WorkerHost.StartAsync(); + + [After(Assembly)] + public static async Task StopAsync() + { + if (_host is not null) + { + await _host.DisposeAsync(); + _host = null; + } + } +} diff --git a/tests/Loom.Templates.Tests/Loom.Templates.Tests.csproj b/tests/Loom.Templates.Tests/Loom.Templates.Tests.csproj new file mode 100644 index 0000000..c463a30 --- /dev/null +++ b/tests/Loom.Templates.Tests/Loom.Templates.Tests.csproj @@ -0,0 +1,11 @@ + + + + Exe + + + + + + + diff --git a/tests/Loom.Templates.Tests/TemplateManifestTests.cs b/tests/Loom.Templates.Tests/TemplateManifestTests.cs new file mode 100644 index 0000000..bb4157d --- /dev/null +++ b/tests/Loom.Templates.Tests/TemplateManifestTests.cs @@ -0,0 +1,198 @@ +using System.Text.Json; + +namespace Loom.Templates.Tests; + +/// +/// The template manifests, checked against the content they claim to rewrite. +/// +/// +/// Scaffolding a solution and building it is CI's job, because it needs a pack, an install and a +/// restore from nuget.org. What is checked here is everything that can be wrong before that +/// point and would otherwise only show up as generated output that does not compile. +/// +/// The token check exists because a substitution that silently does nothing is the failure mode this +/// package actually had: a symbol declared with the wrong mechanism left HostProject in the +/// generated file, and nothing failed until a scaffolded solution was compiled. +/// +/// +public sealed class TemplateManifestTests +{ + private static readonly string TemplatesRoot = LocateTemplatesRoot(); + + public static IEnumerable Templates() + { + foreach (string directory in Directory.EnumerateDirectories(TemplatesRoot)) + { + yield return Path.GetFileName(directory); + } + } + + [Test] + [MethodDataSource(nameof(Templates))] + public async Task The_Manifest_Is_Valid_Json_With_The_Members_Dotnet_New_Requires(string template) + { + JsonElement manifest = await ReadManifestAsync(template); + + foreach (string member in (string[])["identity", "name", "shortName"]) + { + await Assert.That(manifest.TryGetProperty(member, out JsonElement value) && + value.ValueKind is JsonValueKind.String && + !string.IsNullOrWhiteSpace(value.GetString())) + .IsTrue(); + } + } + + [Test] + [MethodDataSource(nameof(Templates))] + public async Task The_Short_Name_Matches_The_Directory(string template) + { + JsonElement manifest = await ReadManifestAsync(template); + + await Assert.That(manifest.GetProperty("shortName").GetString()).IsEqualTo(template); + } + + [Test] + [MethodDataSource(nameof(Templates))] + public async Task Exactly_One_Symbol_Renames_Files(string template) + { + JsonElement manifest = await ReadManifestAsync(template); + + string[] renamers = + [ + .. manifest.GetProperty("symbols").EnumerateObject() + .Where(symbol => symbol.Value.TryGetProperty("fileRename", out _)) + .Select(symbol => symbol.Name), + ]; + + // The token is renamed and replaced by one symbol rather than by sourceName, so that paths and + // file contents get the same sanitised value. A name like "My-App" is a valid directory and an + // invalid identifier; two mechanisms would disagree about which to write where. + await Assert.That(renamers.Length).IsEqualTo(1); + await Assert.That(manifest.TryGetProperty("sourceName", out _)).IsFalse(); + } + + [Test] + [MethodDataSource(nameof(Templates))] + public async Task Every_Token_A_Symbol_Rewrites_Actually_Occurs_In_The_Content(string template) + { + JsonElement manifest = await ReadManifestAsync(template); + + if (!manifest.TryGetProperty("symbols", out JsonElement symbols)) + { + return; + } + + foreach (JsonProperty symbol in symbols.EnumerateObject()) + { + if (!symbol.Value.TryGetProperty("replaces", out JsonElement replaces)) + { + continue; + } + + string token = replaces.GetString()!; + + // A symbol whose token appears nowhere rewrites nothing. That is not a harmless typo: the + // generated file keeps whatever placeholder was written instead. + await Assert.That(await OccurrencesAsync(template, token)).IsGreaterThan(0); + } + } + + [Test] + [MethodDataSource(nameof(Templates))] + public async Task The_Assembled_Guidance_Is_Present_And_Carries_No_Internal_Marker(string template) + { + string guidance = Path.Combine(TemplatesRoot, template, "AGENTS.md"); + + await Assert.That(File.Exists(guidance)).IsTrue(); + + string content = await File.ReadAllTextAsync(guidance); + + await Assert.That(content).StartsWith("# AGENTS.md"); + await Assert.That(content).DoesNotContain("LOOM-TEMPLATE"); + } + + [Test] + public async Task Every_Template_Starts_From_The_Same_Root_Configuration() + { + string[] shared = ["Directory.Build.props", "global.json", ".editorconfig", ".gitignore"]; + string[] templates = [.. Templates().Order(StringComparer.Ordinal)]; + + // Two, not one. With a single template the comparison below never runs and the test passes + // while checking nothing. + await Assert.That(templates.Length).IsGreaterThanOrEqualTo(2); + + // Duplicated across archetypes because a dotnet new template has to be a self-contained tree. + // Duplication that nothing checks is duplication that drifts, and an archetype quietly built on + // different conventions is the one failure this package cannot afford. + foreach (string file in shared) + { + // Bytes rather than text, so a byte-order mark or a line-ending change counts as the + // divergence it is. + byte[] expected = await File.ReadAllBytesAsync(Path.Combine(TemplatesRoot, templates[0], file)); + + foreach (string template in templates.Skip(1)) + { + byte[] actual = await File.ReadAllBytesAsync(Path.Combine(TemplatesRoot, template, file)); + + await Assert.That(actual).IsEquivalentTo(expected); + } + } + } + + private static async Task ReadManifestAsync(string template) + { + string path = Path.Combine(TemplatesRoot, template, ".template.config", "template.json"); + await using FileStream stream = File.OpenRead(path); + using JsonDocument document = await JsonDocument.ParseAsync(stream); + + return document.RootElement.Clone(); + } + + private static async Task OccurrencesAsync(string template, string token) + { + int found = 0; + + foreach (string file in Directory.EnumerateFiles( + Path.Combine(TemplatesRoot, template), "*", SearchOption.AllDirectories)) + { + // The manifest is excluded on purpose. Every token appears there as the "replaces" value, + // so counting it would make this test pass for a token that occurs nowhere else — which is + // exactly the case it exists to catch. + if (file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + || file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + || file.Contains($"{Path.DirectorySeparatorChar}.template.config{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + { + continue; + } + + if (file.Contains(token, StringComparison.Ordinal) + || (await File.ReadAllTextAsync(file)).Contains(token, StringComparison.Ordinal)) + { + found++; + } + } + + return found; + } + + // Walks up rather than assuming a build layout: the tests run from bin/, and the content being + // checked is source that is never copied there. + private static string LocateTemplatesRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + + while (directory is not null) + { + string candidate = Path.Combine(directory.FullName, "src", "Loom.Templates", "templates"); + + if (Directory.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not locate src/Loom.Templates/templates."); + } +}