From d2b223ad777fd73e7acf241fc2fafe46255b9870 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 14:46:05 +0000 Subject: [PATCH 1/3] docs: teach purview-architecture to choose a contract strategy Add a decision procedure for how an HTTP API and its clients share a contract (Contracts, OpenAPI or Hybrid), with version-skew rules, BFF guidance and worked examples. Add Bff to the architecture policy and the Project Responsibilities catalogue so the skill does not describe a responsibility the policy does not define. --- .config/architecture-policy.json | 69 ++++++-- docs/wiki/Project-Responsibilities.md | 7 +- skills/purview-architecture/SKILL.md | 37 ++++- .../references/contract-strategy.md | 151 ++++++++++++++++++ .../references/project-responsibilities.md | 8 +- 5 files changed, 248 insertions(+), 24 deletions(-) create mode 100644 skills/purview-architecture/references/contract-strategy.md diff --git a/.config/architecture-policy.json b/.config/architecture-policy.json index 062a543..a25f284 100644 --- a/.config/architecture-policy.json +++ b/.config/architecture-policy.json @@ -27,7 +27,8 @@ "Migrator", "AppHost", "ServiceDefaults", - "WebApp" + "WebApp", + "Bff" ], "forbiddenPackagePrefixes": [ "Microsoft.EntityFrameworkCore", @@ -56,7 +57,8 @@ "Migrator", "AppHost", "ServiceDefaults", - "WebApp" + "WebApp", + "Bff" ], "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Microsoft.AspNetCore", "Aspire.Hosting"], "separateProjectWhen": "A contract has more than one implementation, or an implementation must not be referenced by its consumer.", @@ -78,7 +80,8 @@ "Migrator", "AppHost", "ServiceDefaults", - "WebApp" + "WebApp", + "Bff" ], "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Npgsql", "Microsoft.Data.SqlClient", "Aspire.Hosting"], "separateProjectWhen": "Workflows are reused by more than one host, or coordinate several capabilities.", @@ -101,7 +104,8 @@ "Migrator", "AppHost", "ServiceDefaults", - "WebApp" + "WebApp", + "Bff" ], "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Npgsql", "Microsoft.Data.SqlClient"], "separateProjectWhen": "A client - a WebApp, an SDK or another service - must compile against the contract without taking the server.", @@ -113,7 +117,7 @@ "category": "infrastructure", "purpose": "Durable data access: DbContext, mappings, migrations and persistence-specific configuration.", "mayDependOn": ["Core", "Abstractions", "Application", "Shared"], - "mustNotDependOn": ["Composition", "API", "Worker", "Migrator", "AppHost", "WebApp", "Providers", "Adapters"], + "mustNotDependOn": ["Composition", "API", "Worker", "Migrator", "AppHost", "WebApp", "Bff", "Providers", "Adapters"], "forbiddenPackagePrefixes": ["Microsoft.AspNetCore.Mvc", "Aspire.Hosting"], "separateProjectWhen": "Always, when persistence exists; it is what Core and Application must not reference.", "mayRemainCombinedWhen": "A single-project utility where the storage is a file and there is no provider to isolate." @@ -124,7 +128,7 @@ "category": "infrastructure", "purpose": "Integrations with named external platforms or services.", "mayDependOn": ["Core", "Abstractions", "Application", "Shared"], - "mustNotDependOn": ["Composition", "API", "Worker", "Migrator", "AppHost", "WebApp", "Persistence", "Adapters"], + "mustNotDependOn": ["Composition", "API", "Worker", "Migrator", "AppHost", "WebApp", "Bff", "Persistence", "Adapters"], "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Aspire.Hosting"], "siblingIndependence": true, "separateProjectWhen": "Two or more platforms are integrated and their SDKs or lifecycles differ.", @@ -136,7 +140,7 @@ "category": "infrastructure", "purpose": "Concrete implementations of application-defined technical capabilities.", "mayDependOn": ["Core", "Abstractions", "Application", "Shared"], - "mustNotDependOn": ["Composition", "API", "Worker", "Migrator", "AppHost", "WebApp", "Persistence", "Providers"], + "mustNotDependOn": ["Composition", "API", "Worker", "Migrator", "AppHost", "WebApp", "Bff", "Persistence", "Providers"], "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Aspire.Hosting"], "siblingIndependence": true, "separateProjectWhen": "A capability has more than one technology behind it, or the technology drags in heavy dependencies.", @@ -157,7 +161,7 @@ "Adapters", "Shared" ], - "mustNotDependOn": ["API", "Worker", "Migrator", "AppHost", "WebApp"], + "mustNotDependOn": ["API", "Worker", "Migrator", "AppHost", "WebApp", "Bff"], "forbiddenPackagePrefixes": [], "separateProjectWhen": "More than one host composes the same implementations.", "mayRemainCombinedWhen": "There is one host; compose in the host." @@ -179,7 +183,7 @@ "Providers", "Adapters" ], - "mustNotDependOn": ["Worker", "Migrator", "AppHost", "WebApp"], + "mustNotDependOn": ["Worker", "Migrator", "AppHost", "WebApp", "Bff"], "forbiddenPackagePrefixes": [], "separateProjectWhen": "An HTTP surface exists.", "mayRemainCombinedWhen": "Never combined with domain or persistence; it is the host." @@ -201,7 +205,7 @@ "Providers", "Adapters" ], - "mustNotDependOn": ["API", "Migrator", "AppHost", "WebApp"], + "mustNotDependOn": ["API", "Migrator", "AppHost", "WebApp", "Bff"], "forbiddenPackagePrefixes": [], "separateProjectWhen": "Background processing is deployed or scaled separately from the API.", "mayRemainCombinedWhen": "A hosted service inside the API is sufficient and they scale together." @@ -212,7 +216,7 @@ "category": "host", "purpose": "Executable migration and controlled seeding host. Persistence owns migration definitions; Migrator owns their execution.", "mayDependOn": ["Core", "Abstractions", "Persistence", "Composition", "ServiceDefaults", "Shared"], - "mustNotDependOn": ["API", "Worker", "AppHost", "WebApp"], + "mustNotDependOn": ["API", "Worker", "AppHost", "WebApp", "Bff"], "forbiddenPackagePrefixes": [], "separateProjectWhen": "Schema changes are applied by a deliberate deployment step rather than at API startup.", "mayRemainCombinedWhen": "Development-only, where the host applies migrations on start." @@ -222,7 +226,7 @@ "aliases": [], "category": "host", "purpose": "Aspire orchestration host. Orchestration is not a compile-time dependency between the services it orchestrates.", - "mayDependOn": ["API", "Worker", "Migrator", "WebApp", "Shared"], + "mayDependOn": ["API", "Worker", "Migrator", "WebApp", "Bff", "Shared"], "mustNotDependOn": ["Core", "Application", "Persistence", "Providers", "Adapters"], "forbiddenPackagePrefixes": [], "separateProjectWhen": "Aspire is used.", @@ -245,7 +249,8 @@ "Worker", "Migrator", "AppHost", - "WebApp" + "WebApp", + "Bff" ], "forbiddenPackagePrefixes": [], "separateProjectWhen": "More than one host shares operational defaults.", @@ -257,11 +262,44 @@ "category": "host", "purpose": "Frontend application.", "mayDependOn": ["Contracts", "ServiceDefaults", "Shared"], - "mustNotDependOn": ["Core", "Application", "Persistence", "Providers", "Adapters", "Composition", "API", "Worker", "Migrator"], + "mustNotDependOn": [ + "Core", + "Application", + "Persistence", + "Providers", + "Adapters", + "Composition", + "API", + "Worker", + "Migrator", + "Bff" + ], "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Npgsql", "Microsoft.Data.SqlClient"], "separateProjectWhen": "A UI exists.", "mayRemainCombinedWhen": "Server-rendered pages inside the API host." }, + { + "name": "Bff", + "aliases": [], + "category": "host", + "purpose": "Backend-for-Frontend: signs the user in, holds the tokens, gives the client a cookie, and forwards calls with the user's access token rather than its own identity.", + "mayDependOn": ["Contracts", "ServiceDefaults", "Shared"], + "mustNotDependOn": [ + "Core", + "Application", + "Persistence", + "Providers", + "Adapters", + "Composition", + "API", + "Worker", + "Migrator", + "WebApp" + ], + "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Npgsql", "Microsoft.Data.SqlClient"], + "separateProjectWhen": "Tokens must be kept out of the browser, or several API calls must be combined into one response. One per client kind, not one per screen.", + "mayRemainCombinedWhen": "Never combined with the API; a BFF that only forwards calls the client could safely make itself is not earned." + }, { "name": "Shared", "aliases": [], @@ -282,7 +320,8 @@ "Migrator", "AppHost", "ServiceDefaults", - "WebApp" + "WebApp", + "Bff" ], "forbiddenPackagePrefixes": [], "separateProjectWhen": "A genuinely domain-independent primitive is used by two or more responsibilities.", diff --git a/docs/wiki/Project-Responsibilities.md b/docs/wiki/Project-Responsibilities.md index ea83dd4..9c94d06 100644 --- a/docs/wiki/Project-Responsibilities.md +++ b/docs/wiki/Project-Responsibilities.md @@ -55,9 +55,10 @@ HTTP request or an Aspire orchestration edge. | `API` | host | — | Core, Abstractions, Application, Contracts, Composition, ServiceDefaults, Shared, Persistence, Providers, Adapters | | `Worker` | host | — | Core, Abstractions, Application, Contracts, Composition, ServiceDefaults, Shared, Persistence, Providers, Adapters | | `Migrator` | host | — | Core, Abstractions, Persistence, Composition, ServiceDefaults, Shared | -| `AppHost` | host | — | API, Worker, Migrator, WebApp, Shared | +| `AppHost` | host | — | API, Worker, Migrator, WebApp, Bff, Shared | | `ServiceDefaults` | host | — | Shared | | `WebApp` | host | — | Contracts, ServiceDefaults, Shared | +| `Bff` | host | — | Contracts, ServiceDefaults, Shared | | `Shared` | shared | — | *(nothing)* | ### When each one earns its own project @@ -74,6 +75,7 @@ HTTP request or an Aspire orchestration edge. | `Composition` | More than one host composes the same implementations | One host — compose in the host | | `Migrator` | Schema changes are applied by a deliberate deployment step | Development-only, migrate on startup | | `ServiceDefaults` | More than one host shares operational defaults | One host | +| `Bff` | Tokens must be kept out of the browser, or several API calls must become one response — one per client kind | It would only forward calls the client could safely make itself | | `Shared` | A domain-independent primitive is used by two or more responsibilities | Almost always — it is the easiest project to create and the hardest to keep coherent | ### Diagram A — the responsibility catalogue @@ -105,6 +107,7 @@ flowchart TB AppHost["AppHost"] ServiceDefaults["ServiceDefaults"] WebApp["WebApp"] + Bff["Bff"] end subgraph shared["Shared"] Shared["Shared"] @@ -140,6 +143,8 @@ flowchart RL Worker --> Composition Migrator --> Persistence WebApp --> Contracts + Bff -->|"only when it combines responses"| Contracts + WebApp -.->|"HTTP via the cookie, not a reference"| Bff AppHost -.->|"orchestrates, not a reference"| API AppHost -.->|"orchestrates, not a reference"| Worker diff --git a/skills/purview-architecture/SKILL.md b/skills/purview-architecture/SKILL.md index 8d4b5a8..4b4b1a4 100644 --- a/skills/purview-architecture/SKILL.md +++ b/skills/purview-architecture/SKILL.md @@ -2,9 +2,10 @@ name: purview-architecture description: >- Use when deciding the shape of a .NET solution that uses Purview libraries — how many projects, - whether to add a repository or CQRS layer, where validation lives — or before generating substantial - implementation code. Establishes project context first, then applies DRY, KISS and architecture - proportional to the actual requirements rather than to an enterprise reference diagram. + whether to add a repository or CQRS layer, where validation lives, how an API and its clients share + a contract (Contracts, OpenAPI or Hybrid) and when a BFF is earned — or before generating + substantial implementation code. Establishes project context first, then applies DRY, KISS and + architecture proportional to the actual requirements rather than to an enterprise reference diagram. license: MIT metadata: author: Purview-Dev @@ -110,7 +111,31 @@ The rules that matter most: When a boundary is worth having, make it fail the build rather than the review — see `purview-architecture-testing`. -## 5. DRY, applied where it pays +## 5. Choose how an API and its clients share a contract + +When there is an HTTP API and at least one client, choose **Contracts**, **OpenAPI** or **Hybrid** +from the clients that **exist today**. A planned client is noted; it does not count. + +| Clients today | Strategy | Failure it prevents | +| -- | -- | -- | +| All .NET, same solution, shipped in lockstep | **Contracts** — a library of request/response types on the domain's value objects, referenced by the server and every .NET client | Rules drifting, and a weaker second copy of them in the UI | +| At least one non-.NET client, or one that ships independently | **Hybrid** — Contracts is the source; the OpenAPI document and its schemas are generated from it | Non-.NET clients hand-writing shapes, and .NET clients losing their value objects to a generated client | +| No .NET client — partners or the public only | **OpenAPI**, generated from the server's types; no Contracts project | A project with no consumer but the server | + +The rules that do not bend: + +- **OpenAPI is an output, never an input**, while Contracts exists. Never regenerate a C# client from + your own API's document when that client is in the same solution. +- **Never hand-maintain a parallel set of models**, and **the server always validates** — a shared + validator is for user experience, not trust. +- **Contracts is browser-safe** when a WebAssembly client references it, and an architecture test, + not a reviewer, says so. +- **A BFF is earned by keeping tokens out of the browser.** It forwards with the user's token, and a + BFF that only forwards does not reference Contracts. + +Full procedure, version-skew rules, BFF guidance and worked examples: `references/contract-strategy.md`. + +## 6. DRY, applied where it pays Reuse the Purview libraries when they remove a real source of truth, not for coverage: @@ -126,7 +151,7 @@ Duplication that matters: validation repeated per entry point, a domain rule res configuration bound in two places, two types that both claim to be the truth. Duplication that does not: two similar-looking methods that answer to different owners. -## 6. Telemetry is not an advanced topic +## 7. Telemetry is not an advanced topic Recommend instrumentation from the first commit, at every project size. A small application gets small telemetry — it does not get none. See `purview-telemetry`. @@ -136,6 +161,8 @@ small telemetry — it does not get none. See `purview-telemetry`. - Did you read the four context dimensions rather than ask for them? - Can you name, for each project and each abstraction you are proposing, the failure it prevents here? - Is anything in your proposal there only because a reference architecture has it? +- If there is an API with clients, did you choose its contract strategy from the clients that exist + today? - Did you include telemetry? ## See also diff --git a/skills/purview-architecture/references/contract-strategy.md b/skills/purview-architecture/references/contract-strategy.md new file mode 100644 index 0000000..9cc2785 --- /dev/null +++ b/skills/purview-architecture/references/contract-strategy.md @@ -0,0 +1,151 @@ +# Contract strategy: Contracts, OpenAPI or Hybrid + +When a solution has an HTTP API and one or more clients, something has to define the shapes that cross +between them. There are three ways to do it, and the wrong one fails in one of two directions: + +- **Rules drift.** The client holds a second, weaker copy of the domain rules, and a request the UI + accepted is rejected by the server — or a break only surfaces at runtime. +- **Decoupling bought in advance.** A .NET client in the same solution regenerates its models from + the API's own OpenAPI document, and throws away every value object and rule to buy an independence + the solution does not have. + +The strategy is chosen from the clients that **exist today**. A planned client is noted; it does not +count. "Later" and "in case" do not justify a strategy any more than they justify a project — see +`proportional-architecture.md`. + +## The three strategies + +### Contracts + +A .NET class library holding the request and response types, built on the domain's value objects and +carrying `[ZodSchema]`. The server references it, and so does every .NET client — including a Blazor +WebAssembly client. + +There is one copy of every rule, error code and normalisation, and both sides run the same generated +validators. + +### OpenAPI + +The server publishes an OpenAPI document, and clients generate their models and calls from it. It is +language-neutral and versions independently. + +Generated models are primitives. Value objects, custom ZodSharp rules, error codes, and trimming and +casing are lost; only what JSON Schema can express survives. + +### Hybrid + +Contracts is the **single source of truth**. The OpenAPI document is **generated from it**, with the +component schemas exported by ZodSharp (`{T}Schema.BuildJsonSchema()`), so the document and the +validators cannot drift. + +.NET clients reference Contracts. Non-.NET clients generate from the document and take their +validation rules from the exported JSON Schema — for TypeScript, as Zod schemas; the +`purview-zodsharp-json` skill covers the export. + +## Decision procedure + +1. **List the clients that exist today.** For each, record: + - its language and runtime; + - who owns it; + - whether it lives in the same repository and solution; + - whether it ships **in lockstep** with the server or on its own cadence. + + A planned client is noted, but it does not count yet. +2. **Every client is .NET, in the same solution, and ships in lockstep → Contracts.** This prevents: + - rules drifting between client and server; + - a second, weaker copy of the domain rules in the UI; + - contract breaks that only surface at runtime. + + Publish OpenAPI anyway if documentation or tooling wants it, but generate it from Contracts. It is + an output, never an input. +3. **At least one client is not .NET, or ships independently → Hybrid.** This prevents: + - non-.NET clients hand-writing shapes and rules; + - the .NET clients losing their value objects and rules to a generated client. + + It requires the independent-versioning rules under [Version skew](#version-skew). +4. **No .NET client exists, or the API is a surface whose clients the team does not own (public or + partner) → OpenAPI**, still generated from the server's types. A separate Contracts project is not + earned when the server is its only consumer. +5. **A .NET client in another repository with its own release cadence** prefers Contracts shipped as + a package, if that team accepts the dependency. Otherwise treat it as step 3. + +```text +flowchart TD + A["List the clients that exist today"] --> C{"Every client .NET,
same solution, lockstep?"} + C -- yes --> K["Contracts
OpenAPI only as an output"] + C -- no --> B{"Any .NET client
at all?"} + B -- no --> O["OpenAPI
generated from the server's types"] + B -- yes --> D{"The only other clients are .NET
in another repository,
and they accept a package?"} + D -- yes --> P["Contracts, shipped as a package"] + D -- no --> H["Hybrid
Contracts is the source, OpenAPI generated from it"] +``` + +Steps 3 and 4 do not overlap: a .NET client in the solution alongside a partner or non-.NET client is +step 3, because the .NET client still has value objects and rules to lose. + +## Rules that follow + +- **Never regenerate a C# client from your own API's OpenAPI document when that client is in the same + solution.** It throws away value objects and rules to buy decoupling the solution does not have. +- **Never hand-maintain a parallel set of models** on either side. Two definitions of one wire shape + disagree eventually, and nothing fails when they start to. +- **The server always validates.** Sharing validators with a client is for user experience and + consistency. It is never a substitute for validating on the server, because the client is untrusted. +- **Moving from Contracts to Hybrid must be additive.** Because Contracts is already the source, adding + a non-.NET client means turning on generation of the OpenAPI document and a contract test. Nothing in + Contracts changes. Design for that, but do not build it until the client exists. + +## Contracts project constraints + +- **References only the domain library (value objects) and the Purview packages.** Never the server, + persistence, HTTP clients or hosting. A Contracts project that references the server is a server + that every client now ships. +- **Browser-safe when a WebAssembly client references it.** No file, network or process I/O, safe to + trim, and no reflection-dependent behaviour. The same applies transitively to the domain library, + because the client downloads it too. Enforce it with an architecture test — forbidden packages and + forbidden framework references on `Contracts` and `Core` — not by review; see + `purview-architecture-testing`. +- **Deserialisation mode is a deliberate choice per direction.** The server reads requests through + `Create` (strict), because the client is untrusted. Record what the client uses for responses, and + why — strict catches a server that has drifted, and `Hydrate` trusts a server the client cannot + verify. + +## Version skew + +| Shipping | Strategy | Failure it prevents | What to do | +| -- | -- | -- | -- | +| Lockstep | Contracts, internal apps | A silent failure that looks like a bug, when an old cached client talks to a new server | The server stamps its build on responses; the client prompts the user to reload when the stamps differ | +| Independent | Hybrid, OpenAPI | A deployed client breaking without warning | Additive changes only; a breaking change gets a new route version; a contract test compares the generated document against the last published one and fails on a breaking difference | + +## Backend-for-Frontend + +A BFF is a host, not a layer, and it is held to the same justification test as anything else. + +- **It is earned by keeping tokens out of the browser.** It signs the user in, holds the tokens, and + gives the browser a cookie. It forwards calls with the **user's** access token, never its own + identity, so the API's authorisation and audit trail stay per-user. This prevents tokens being stolen + from browser storage, and every action being recorded as the BFF's. +- **A BFF that only forwards calls does not reference Contracts** and never reads payloads. Reading a + payload it does not own makes it a second consumer that has to change in lockstep for no benefit. +- **A BFF that combines several API calls into one response owns its own contract** for those + responses, chosen with this same procedure. That is a separate justification. The forwarding-only + test — "a service layer that forwards" is not earned — still applies to everything else it does. +- **One BFF per client kind, not one per screen.** Web and mobile may each get one when their sign-in + or response needs differ. + +The policy names it `Bff`; its dependency row is in `project-responsibilities.md`. + +## Worked examples + +| Clients today | Strategy | +| -- | -- | +| Blazor WebAssembly app, same solution, lockstep, behind a BFF | Contracts. The BFF forwards only. OpenAPI generated for documentation. | +| Blazor WebAssembly app plus a React Native mobile app on its own release cadence | Hybrid. Mobile generates from the document and takes Zod rules from the exported schema. Additive-only versioning. A mobile BFF if sign-in differs. | +| Partner integrations only | OpenAPI, generated from the server's types. No Contracts project. | + +## See also + +- [Contracts and Data Exchange](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Contracts-And-Data-Exchange.md) + — `Create` and `Hydrate`, and the schema as the source of truth for a wire shape +- [zodsharp](https://github.com/purview-dev/zodsharp) — authoritative on `[ZodSchema]` and JSON Schema + export diff --git a/skills/purview-architecture/references/project-responsibilities.md b/skills/purview-architecture/references/project-responsibilities.md index 1bf0a1a..095e944 100644 --- a/skills/purview-architecture/references/project-responsibilities.md +++ b/skills/purview-architecture/references/project-responsibilities.md @@ -15,7 +15,7 @@ Do not restate either here or in another skill; link to them. | `Core` | `Domain` | Entities, value objects, invariants, domain events and errors | Shared | | `Abstractions` | — | Cross-boundary capability contracts | Core, Shared | | `Application` | `UseCases` | Use cases, workflows, orchestration | Core, Abstractions, Contracts, Shared | -| `Contracts` | — | Externally exposed request/response types | Core, Shared | +| `Contracts` | — | Externally exposed request/response types; browser-safe when a WebAssembly client references it | Core, Shared | | `Persistence` | — | DbContext, mappings, migration *definitions* | Core, Abstractions, Application, Shared | | `Providers` | `Integrations` | Integration with a *named external platform* | Core, Abstractions, Application, Shared | | `Adapters` | — | An *application-defined capability* via one technology | Core, Abstractions, Application, Shared | @@ -25,7 +25,8 @@ Do not restate either here or in another skill; link to them. | `Migrator` | — | *Executes* migrations and controlled seeding | Persistence, Composition, ServiceDefaults | | `AppHost` | — | Aspire orchestration | The hosts it orchestrates | | `ServiceDefaults` | — | Telemetry, health, resilience, discovery defaults | Shared | -| `WebApp` | — | Frontend | Contracts, ServiceDefaults, Shared | +| `WebApp` | — | Frontend, including a Blazor WebAssembly client that references Contracts | Contracts, ServiceDefaults, Shared | +| `Bff` | — | Sign-in and token custody for a browser or mobile client; forwards with the user's token | Contracts (only when it combines responses), ServiceDefaults, Shared | | `Shared` | — | Small domain-independent primitives | *nothing* | **None of these is mandatory.** A small application with `Core` and one host has a complete and @@ -39,7 +40,8 @@ buys something — it is not a list of projects to create. | `Core` | A domain model exists | The app is a thin utility | | `Abstractions` | Two implementations, or the consumer must not see the implementation | One implementation — keep the interface beside it | | `Application` | Workflows are reused by more than one host | It would only forward CRUD | -| `Contracts` | A client compiles against it without taking the server | The API host is the only consumer | +| `Contracts` | The contract strategy is Contracts or Hybrid — see `contract-strategy.md` | The API host is the only consumer, which is the OpenAPI strategy | +| `Bff` | Tokens must be kept out of the browser, or several API calls must become one response | It would only forward calls that the client could make with a token it already holds safely | | `Persistence` | Persistence exists at all | A single-project utility writing a file | | `Providers` | Two platforms with different SDKs or lifecycles | One platform | | `Adapters` | One capability, several technologies | One capability, one technology | From e6f2335604dbf3d120cd6baf1b67b35acde2976c Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 14:52:32 +0000 Subject: [PATCH 2/3] docs: correct the BFF and ZodSharp guidance in the contract strategy ZodSharp exports JSON Schema but ships no OpenAPI integration, so the Hybrid strategy now says the API host wires the export into the document. It also covers x-zodsharp-unsupported, and the way additionalProperties: false breaks additive versioning for clients that validate responses strictly. Browser-safety now names mechanisms that actually check it: policy bans on ASP.NET Core packages and the framework reference, IsAotCompatible, and SupportedPlatform browser. It also says plainly that the analyzers only warn unless TreatWarningsAsErrors is set. The BFF section now covers the Blazor case, where the BFF is the host that serves the WebAssembly client (Bff may reference WebApp). It adds anti-forgery, cookie flags, Data Protection keys, server-side refresh, a server-side session store, and prerendering as the reason a BFF references Contracts. --- .config/architecture-policy.json | 30 ++--- docs/wiki/Project-Responsibilities.md | 6 +- skills/purview-architecture/SKILL.md | 10 +- .../references/contract-strategy.md | 121 +++++++++++++++--- .../references/project-responsibilities.md | 2 +- 5 files changed, 125 insertions(+), 44 deletions(-) diff --git a/.config/architecture-policy.json b/.config/architecture-policy.json index a25f284..6a8938c 100644 --- a/.config/architecture-policy.json +++ b/.config/architecture-policy.json @@ -37,6 +37,7 @@ "Microsoft.Data.SqlClient", "Aspire.Hosting" ], + "forbiddenFrameworkReferences": ["Microsoft.AspNetCore.App"], "separateProjectWhen": "Always, when a domain model exists at all; it is the one responsibility that earns its own project early.", "mayRemainCombinedWhen": "The application is a thin utility with no domain rules worth isolating." }, @@ -107,8 +108,16 @@ "WebApp", "Bff" ], - "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Npgsql", "Microsoft.Data.SqlClient"], - "separateProjectWhen": "A client - a WebApp, an SDK or another service - must compile against the contract without taking the server.", + "forbiddenPackagePrefixes": [ + "Microsoft.EntityFrameworkCore", + "Npgsql", + "Microsoft.Data.SqlClient", + "Microsoft.AspNetCore", + "Purview.ZodSharp.AspNetCore", + "Purview.Results.ZodSharp.AspNetCore" + ], + "forbiddenFrameworkReferences": ["Microsoft.AspNetCore.App"], + "separateProjectWhen": "A client - a WebApp, an SDK or another service - must compile against the contract without taking the server. A Blazor WebAssembly client downloads it, so it must stay browser-safe.", "mayRemainCombinedWhen": "The only consumer is the API host itself." }, { @@ -283,21 +292,10 @@ "aliases": [], "category": "host", "purpose": "Backend-for-Frontend: signs the user in, holds the tokens, gives the client a cookie, and forwards calls with the user's access token rather than its own identity.", - "mayDependOn": ["Contracts", "ServiceDefaults", "Shared"], - "mustNotDependOn": [ - "Core", - "Application", - "Persistence", - "Providers", - "Adapters", - "Composition", - "API", - "Worker", - "Migrator", - "WebApp" - ], + "mayDependOn": ["Contracts", "ServiceDefaults", "Shared", "WebApp"], + "mustNotDependOn": ["Core", "Application", "Persistence", "Providers", "Adapters", "Composition", "API", "Worker", "Migrator"], "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Npgsql", "Microsoft.Data.SqlClient"], - "separateProjectWhen": "Tokens must be kept out of the browser, or several API calls must be combined into one response. One per client kind, not one per screen.", + "separateProjectWhen": "Tokens must be kept out of the browser, or several API calls must be combined into one response. In a Blazor solution it is the host that serves the WebAssembly client, so it references WebApp. One per client kind, not one per screen.", "mayRemainCombinedWhen": "Never combined with the API; a BFF that only forwards calls the client could safely make itself is not earned." }, { diff --git a/docs/wiki/Project-Responsibilities.md b/docs/wiki/Project-Responsibilities.md index 9c94d06..4c3fff1 100644 --- a/docs/wiki/Project-Responsibilities.md +++ b/docs/wiki/Project-Responsibilities.md @@ -58,7 +58,7 @@ HTTP request or an Aspire orchestration edge. | `AppHost` | host | — | API, Worker, Migrator, WebApp, Bff, Shared | | `ServiceDefaults` | host | — | Shared | | `WebApp` | host | — | Contracts, ServiceDefaults, Shared | -| `Bff` | host | — | Contracts, ServiceDefaults, Shared | +| `Bff` | host | — | Contracts, ServiceDefaults, Shared, WebApp | | `Shared` | shared | — | *(nothing)* | ### When each one earns its own project @@ -143,8 +143,8 @@ flowchart RL Worker --> Composition Migrator --> Persistence WebApp --> Contracts - Bff -->|"only when it combines responses"| Contracts - WebApp -.->|"HTTP via the cookie, not a reference"| Bff + Bff -->|"serves the WebAssembly client"| WebApp + Bff -->|"only when it prerenders or combines"| Contracts AppHost -.->|"orchestrates, not a reference"| API AppHost -.->|"orchestrates, not a reference"| Worker diff --git a/skills/purview-architecture/SKILL.md b/skills/purview-architecture/SKILL.md index 4b4b1a4..29a7cfb 100644 --- a/skills/purview-architecture/SKILL.md +++ b/skills/purview-architecture/SKILL.md @@ -128,10 +128,12 @@ The rules that do not bend: your own API's document when that client is in the same solution. - **Never hand-maintain a parallel set of models**, and **the server always validates** — a shared validator is for user experience, not trust. -- **Contracts is browser-safe** when a WebAssembly client references it, and an architecture test, - not a reviewer, says so. -- **A BFF is earned by keeping tokens out of the browser.** It forwards with the user's token, and a - BFF that only forwards does not reference Contracts. +- **Contracts is browser-safe** when a WebAssembly client references it: the architecture policy, + the trim and AOT analyzers and the browser platform analyzer check it, not a reviewer. +- **A BFF is earned by keeping tokens out of the browser.** It holds the tokens, gives the browser an + `HttpOnly` cookie, forwards with the user's token, and requires an anti-forgery header on every + forwarded call. In Blazor it is the host that serves the WebAssembly client, and a BFF that only + forwards does not reference Contracts. Full procedure, version-skew rules, BFF guidance and worked examples: `references/contract-strategy.md`. diff --git a/skills/purview-architecture/references/contract-strategy.md b/skills/purview-architecture/references/contract-strategy.md index 9cc2785..a1771af 100644 --- a/skills/purview-architecture/references/contract-strategy.md +++ b/skills/purview-architecture/references/contract-strategy.md @@ -34,13 +34,29 @@ casing are lost; only what JSON Schema can express survives. ### Hybrid -Contracts is the **single source of truth**. The OpenAPI document is **generated from it**, with the -component schemas exported by ZodSharp (`{T}Schema.BuildJsonSchema()`), so the document and the -validators cannot drift. +Contracts is the **single source of truth**. The OpenAPI document is **generated from it**, with its +component schemas taken from ZodSharp's export — the generated `{T}Schema.BuildJsonSchema()` is built +from the same metadata as the validator, so the document and the validators cannot drift. .NET clients reference Contracts. Non-.NET clients generate from the document and take their -validation rules from the exported JSON Schema — for TypeScript, as Zod schemas; the -`purview-zodsharp-json` skill covers the export. +validation rules from the exported JSON Schema — for TypeScript, `z.fromJSONSchema` (Zod 4) reads it +back as Zod schemas. The `purview-zodsharp-json` and `purview-zodsharp-source-generator` skills are +authoritative on the export. + +Three facts about the export that a Hybrid design has to plan for: + +- **ZodSharp ships no OpenAPI integration.** It produces JSON Schema; putting that into the OpenAPI + document — for example with a schema transformer in `Microsoft.AspNetCore.OpenApi` — is the API + host's job. Without it, ASP.NET Core describes a value object from its CLR shape, not from its rules, + and the document and the validators drift after all. +- **A non-.NET client still gets only what JSON Schema can express.** A value object exports as its + underlying value, and a rule the exporter cannot represent is listed under `x-zodsharp-unsupported` + rather than dropped. The contract test should fail when that list grows. +- **Exported objects set `additionalProperties: false`.** A client that validates a *response* against + that schema rejects a field the server has added — so "additive changes only" is not safe until + clients tolerate unknown response fields. Validate requests strictly and responses leniently, or the + versioning rule below protects nobody. (The reverse also holds: System.Text.Json ignores unknown + members, so the server accepts a request with an extra key that the schema rejects.) ## Decision procedure @@ -97,14 +113,29 @@ step 3, because the .NET client still has value objects and rules to lose. ## Contracts project constraints -- **References only the domain library (value objects) and the Purview packages.** Never the server, - persistence, HTTP clients or hosting. A Contracts project that references the server is a server - that every client now ships. +- **References only the domain library (value objects) and the runtime Purview packages** — + `Purview.ValueObjects`, `Purview.ZodSharp`, `Purview.Results`. Never the server, persistence, HTTP + clients or hosting, and never an ASP.NET Core bridge such as `Purview.ZodSharp.AspNetCore`; those + belong to the API host. A Contracts project that references the server is a server that every client + now ships. - **Browser-safe when a WebAssembly client references it.** No file, network or process I/O, safe to trim, and no reflection-dependent behaviour. The same applies transitively to the domain library, - because the client downloads it too. Enforce it with an architecture test — forbidden packages and - forbidden framework references on `Contracts` and `Core` — not by review; see - `purview-architecture-testing`. + because the client downloads it too. Make the build enforce it, not a reviewer — three mechanisms, + each catching what the others cannot: + - **The architecture policy** forbids `Microsoft.AspNetCore` packages and the + `Microsoft.AspNetCore.App` framework reference in `Contracts` and `Core`. See + `purview-architecture-testing`. + - **`true`** on both projects turns on the trim and AOT analyzers, + so reflection a trimmed client would break on is a warning at build time. `Purview.ValueObjects` + and `Purview.ZodSharp` already set it. + - **``** makes the platform-compatibility analyzer (`CA1416`) + flag an API that is unsupported in the browser, such as starting a process. + + The policy fails a test run. The two analyzers produce warnings, and the SDK does not turn on + `TreatWarningsAsErrors` for an ordinary project — set it on `Contracts` and `Core`, or the "clean + build has no warnings" rule is the only thing standing behind them. No analyzer proves the absence of + every kind of I/O; what they miss is a review obligation, and the client's own tests running in the + browser are the last check. - **Deserialisation mode is a deliberate choice per direction.** The server reads requests through `Create` (strict), because the client is untrusted. Record what the client uses for responses, and why — strict catches a server that has drifted, and `Hydrate` trusts a server the client cannot @@ -120,26 +151,76 @@ step 3, because the .NET client still has value objects and rules to lose. ## Backend-for-Frontend A BFF is a host, not a layer, and it is held to the same justification test as anything else. - -- **It is earned by keeping tokens out of the browser.** It signs the user in, holds the tokens, and - gives the browser a cookie. It forwards calls with the **user's** access token, never its own - identity, so the API's authorisation and audit trail stay per-user. This prevents tokens being stolen - from browser storage, and every action being recorded as the BFF's. -- **A BFF that only forwards calls does not reference Contracts** and never reads payloads. Reading a - payload it does not own makes it a second consumer that has to change in lockstep for no benefit. +Microsoft's [Blazor Web App with OIDC and the BFF pattern](https://learn.microsoft.com/aspnet/core/blazor/security/blazor-web-app-with-oidc?pivots=with-bff-pattern) +is the authority on the mechanics; what follows is the rules and the failures behind them. + +### What earns it + +- **Keeping tokens out of the browser.** The BFF signs the user in (OpenID Connect), holds the access + and refresh tokens, and gives the browser only an authentication cookie. Script in the page — a + compromised dependency, an XSS hole — cannot read a token that never reached it. This prevents tokens + being stolen from browser storage. +- **It forwards with the user's access token, never its own identity.** The API validates that token + exactly as it would any other, so authorisation and the audit trail stay per-user. A BFF calling with + a client-credentials token turns every action into the BFF's and moves authorisation into the BFF. + +### In a Blazor solution, the BFF is the host + +The project that serves the WebAssembly client is the natural BFF: it already runs on the server, at +the client's origin. Do not add a separate BFF in front of it. + +- **It references the client project** (`Bff → WebApp`) because it serves the client's assets. The + client never references it; they talk over HTTP with the cookie. +- **Same origin is the point.** The client calls the BFF's own routes, the BFF forwards them to the API + (with YARP's `MapForwarder`, for example), and there is no CORS configuration and no third-party + cookie. +- **The client shares Contracts with the API directly.** The WebAssembly client references Contracts, + sends Contracts types, and runs the generated validators. The BFF in the middle passes the bytes + through untouched. + +### Rules that come with a cookie + +Moving the credential into a cookie trades token theft for a different attack, and the BFF has to +close it: + +- **Every forwarded call requires an anti-forgery signal.** The browser attaches the cookie to any + request to the BFF's origin, including one a hostile page triggers. Require a custom header the + client always sends, which a cross-site form cannot set, and reject calls without it — Duende.BFF's + `X-CSRF: 1` is the established form. `SameSite` narrows the attack; it does not replace the check. + This prevents cross-site request forgery. +- **The cookie is `HttpOnly`, `Secure` and `SameSite=Strict` or `Lax`.** `HttpOnly` is what keeps + script from reading it; without it the BFF protects nothing. +- **Data Protection keys are persisted and shared** when the BFF runs on more than one instance or + survives a restart. The cookie is encrypted with them, so per-instance keys sign users out at random. +- **Refresh happens on the server.** The BFF refreshes the access token before it expires and signs + the user out when refresh fails. The client never sees either token. +- **A large session moves to a server-side store.** Tokens kept inside the cookie make it large enough + to be split across several cookies, and the session cannot be revoked server-side. Use a ticket store + when either matters. + +### What it may reference + +- **A BFF that only forwards does not reference Contracts** and never reads payloads. Reading a payload + it does not own makes it a second consumer that has to change in lockstep for no benefit. +- **Prerendering is the common exception.** If the BFF prerenders client components that load data, + it must run those data calls on the server, and it references Contracts to do so. That is a real + reason; turning prerendering off for those components is the alternative, and the choice should be + written down. - **A BFF that combines several API calls into one response owns its own contract** for those responses, chosen with this same procedure. That is a separate justification. The forwarding-only test — "a service layer that forwards" is not earned — still applies to everything else it does. - **One BFF per client kind, not one per screen.** Web and mobile may each get one when their sign-in or response needs differ. -The policy names it `Bff`; its dependency row is in `project-responsibilities.md`. +The policy names it `Bff`; its dependency row is in `project-responsibilities.md`. The policy allows +`Bff → Contracts` because it cannot express "only when it prerenders or combines"; that condition is a +review obligation. ## Worked examples | Clients today | Strategy | | -- | -- | -| Blazor WebAssembly app, same solution, lockstep, behind a BFF | Contracts. The BFF forwards only. OpenAPI generated for documentation. | +| Blazor WebAssembly app, same solution, lockstep, signed in through a BFF | Contracts, referenced by the API and the WebAssembly client. The BFF is the host that serves the client, holds the tokens and forwards with the user's token; it references Contracts only if it prerenders data. OpenAPI generated for documentation. | | Blazor WebAssembly app plus a React Native mobile app on its own release cadence | Hybrid. Mobile generates from the document and takes Zod rules from the exported schema. Additive-only versioning. A mobile BFF if sign-in differs. | | Partner integrations only | OpenAPI, generated from the server's types. No Contracts project. | diff --git a/skills/purview-architecture/references/project-responsibilities.md b/skills/purview-architecture/references/project-responsibilities.md index 095e944..160d5fd 100644 --- a/skills/purview-architecture/references/project-responsibilities.md +++ b/skills/purview-architecture/references/project-responsibilities.md @@ -26,7 +26,7 @@ Do not restate either here or in another skill; link to them. | `AppHost` | — | Aspire orchestration | The hosts it orchestrates | | `ServiceDefaults` | — | Telemetry, health, resilience, discovery defaults | Shared | | `WebApp` | — | Frontend, including a Blazor WebAssembly client that references Contracts | Contracts, ServiceDefaults, Shared | -| `Bff` | — | Sign-in and token custody for a browser or mobile client; forwards with the user's token | Contracts (only when it combines responses), ServiceDefaults, Shared | +| `Bff` | — | Sign-in and token custody for a browser or mobile client; forwards with the user's token. In Blazor, the host that serves the WebAssembly client | WebApp, Contracts (only when it prerenders or combines responses), ServiceDefaults, Shared | | `Shared` | — | Small domain-independent primitives | *nothing* | **None of these is mandatory.** A small application with `Core` and one host has a complete and From 1d390bf2a7596b3626f020568b5258b95e5f3804 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 14:57:41 +0000 Subject: [PATCH 3/3] docs: keep BFF and Blazor guidance in the skill until the Build SDK adds it Revert the architecture policy and Project Responsibilities wiki changes. BFF and Blazor support will arrive with a future Build SDK release, so for now the skill states the BFF and browser-safety rules as guidance and review obligations rather than as policy the playbook enforces. --- .config/architecture-policy.json | 71 +++++-------------- docs/wiki/Project-Responsibilities.md | 7 +- skills/purview-architecture/SKILL.md | 5 +- .../references/contract-strategy.md | 18 +++-- .../references/project-responsibilities.md | 18 +++-- 5 files changed, 46 insertions(+), 73 deletions(-) diff --git a/.config/architecture-policy.json b/.config/architecture-policy.json index 6a8938c..062a543 100644 --- a/.config/architecture-policy.json +++ b/.config/architecture-policy.json @@ -27,8 +27,7 @@ "Migrator", "AppHost", "ServiceDefaults", - "WebApp", - "Bff" + "WebApp" ], "forbiddenPackagePrefixes": [ "Microsoft.EntityFrameworkCore", @@ -37,7 +36,6 @@ "Microsoft.Data.SqlClient", "Aspire.Hosting" ], - "forbiddenFrameworkReferences": ["Microsoft.AspNetCore.App"], "separateProjectWhen": "Always, when a domain model exists at all; it is the one responsibility that earns its own project early.", "mayRemainCombinedWhen": "The application is a thin utility with no domain rules worth isolating." }, @@ -58,8 +56,7 @@ "Migrator", "AppHost", "ServiceDefaults", - "WebApp", - "Bff" + "WebApp" ], "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Microsoft.AspNetCore", "Aspire.Hosting"], "separateProjectWhen": "A contract has more than one implementation, or an implementation must not be referenced by its consumer.", @@ -81,8 +78,7 @@ "Migrator", "AppHost", "ServiceDefaults", - "WebApp", - "Bff" + "WebApp" ], "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Npgsql", "Microsoft.Data.SqlClient", "Aspire.Hosting"], "separateProjectWhen": "Workflows are reused by more than one host, or coordinate several capabilities.", @@ -105,19 +101,10 @@ "Migrator", "AppHost", "ServiceDefaults", - "WebApp", - "Bff" + "WebApp" ], - "forbiddenPackagePrefixes": [ - "Microsoft.EntityFrameworkCore", - "Npgsql", - "Microsoft.Data.SqlClient", - "Microsoft.AspNetCore", - "Purview.ZodSharp.AspNetCore", - "Purview.Results.ZodSharp.AspNetCore" - ], - "forbiddenFrameworkReferences": ["Microsoft.AspNetCore.App"], - "separateProjectWhen": "A client - a WebApp, an SDK or another service - must compile against the contract without taking the server. A Blazor WebAssembly client downloads it, so it must stay browser-safe.", + "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Npgsql", "Microsoft.Data.SqlClient"], + "separateProjectWhen": "A client - a WebApp, an SDK or another service - must compile against the contract without taking the server.", "mayRemainCombinedWhen": "The only consumer is the API host itself." }, { @@ -126,7 +113,7 @@ "category": "infrastructure", "purpose": "Durable data access: DbContext, mappings, migrations and persistence-specific configuration.", "mayDependOn": ["Core", "Abstractions", "Application", "Shared"], - "mustNotDependOn": ["Composition", "API", "Worker", "Migrator", "AppHost", "WebApp", "Bff", "Providers", "Adapters"], + "mustNotDependOn": ["Composition", "API", "Worker", "Migrator", "AppHost", "WebApp", "Providers", "Adapters"], "forbiddenPackagePrefixes": ["Microsoft.AspNetCore.Mvc", "Aspire.Hosting"], "separateProjectWhen": "Always, when persistence exists; it is what Core and Application must not reference.", "mayRemainCombinedWhen": "A single-project utility where the storage is a file and there is no provider to isolate." @@ -137,7 +124,7 @@ "category": "infrastructure", "purpose": "Integrations with named external platforms or services.", "mayDependOn": ["Core", "Abstractions", "Application", "Shared"], - "mustNotDependOn": ["Composition", "API", "Worker", "Migrator", "AppHost", "WebApp", "Bff", "Persistence", "Adapters"], + "mustNotDependOn": ["Composition", "API", "Worker", "Migrator", "AppHost", "WebApp", "Persistence", "Adapters"], "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Aspire.Hosting"], "siblingIndependence": true, "separateProjectWhen": "Two or more platforms are integrated and their SDKs or lifecycles differ.", @@ -149,7 +136,7 @@ "category": "infrastructure", "purpose": "Concrete implementations of application-defined technical capabilities.", "mayDependOn": ["Core", "Abstractions", "Application", "Shared"], - "mustNotDependOn": ["Composition", "API", "Worker", "Migrator", "AppHost", "WebApp", "Bff", "Persistence", "Providers"], + "mustNotDependOn": ["Composition", "API", "Worker", "Migrator", "AppHost", "WebApp", "Persistence", "Providers"], "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Aspire.Hosting"], "siblingIndependence": true, "separateProjectWhen": "A capability has more than one technology behind it, or the technology drags in heavy dependencies.", @@ -170,7 +157,7 @@ "Adapters", "Shared" ], - "mustNotDependOn": ["API", "Worker", "Migrator", "AppHost", "WebApp", "Bff"], + "mustNotDependOn": ["API", "Worker", "Migrator", "AppHost", "WebApp"], "forbiddenPackagePrefixes": [], "separateProjectWhen": "More than one host composes the same implementations.", "mayRemainCombinedWhen": "There is one host; compose in the host." @@ -192,7 +179,7 @@ "Providers", "Adapters" ], - "mustNotDependOn": ["Worker", "Migrator", "AppHost", "WebApp", "Bff"], + "mustNotDependOn": ["Worker", "Migrator", "AppHost", "WebApp"], "forbiddenPackagePrefixes": [], "separateProjectWhen": "An HTTP surface exists.", "mayRemainCombinedWhen": "Never combined with domain or persistence; it is the host." @@ -214,7 +201,7 @@ "Providers", "Adapters" ], - "mustNotDependOn": ["API", "Migrator", "AppHost", "WebApp", "Bff"], + "mustNotDependOn": ["API", "Migrator", "AppHost", "WebApp"], "forbiddenPackagePrefixes": [], "separateProjectWhen": "Background processing is deployed or scaled separately from the API.", "mayRemainCombinedWhen": "A hosted service inside the API is sufficient and they scale together." @@ -225,7 +212,7 @@ "category": "host", "purpose": "Executable migration and controlled seeding host. Persistence owns migration definitions; Migrator owns their execution.", "mayDependOn": ["Core", "Abstractions", "Persistence", "Composition", "ServiceDefaults", "Shared"], - "mustNotDependOn": ["API", "Worker", "AppHost", "WebApp", "Bff"], + "mustNotDependOn": ["API", "Worker", "AppHost", "WebApp"], "forbiddenPackagePrefixes": [], "separateProjectWhen": "Schema changes are applied by a deliberate deployment step rather than at API startup.", "mayRemainCombinedWhen": "Development-only, where the host applies migrations on start." @@ -235,7 +222,7 @@ "aliases": [], "category": "host", "purpose": "Aspire orchestration host. Orchestration is not a compile-time dependency between the services it orchestrates.", - "mayDependOn": ["API", "Worker", "Migrator", "WebApp", "Bff", "Shared"], + "mayDependOn": ["API", "Worker", "Migrator", "WebApp", "Shared"], "mustNotDependOn": ["Core", "Application", "Persistence", "Providers", "Adapters"], "forbiddenPackagePrefixes": [], "separateProjectWhen": "Aspire is used.", @@ -258,8 +245,7 @@ "Worker", "Migrator", "AppHost", - "WebApp", - "Bff" + "WebApp" ], "forbiddenPackagePrefixes": [], "separateProjectWhen": "More than one host shares operational defaults.", @@ -271,33 +257,11 @@ "category": "host", "purpose": "Frontend application.", "mayDependOn": ["Contracts", "ServiceDefaults", "Shared"], - "mustNotDependOn": [ - "Core", - "Application", - "Persistence", - "Providers", - "Adapters", - "Composition", - "API", - "Worker", - "Migrator", - "Bff" - ], + "mustNotDependOn": ["Core", "Application", "Persistence", "Providers", "Adapters", "Composition", "API", "Worker", "Migrator"], "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Npgsql", "Microsoft.Data.SqlClient"], "separateProjectWhen": "A UI exists.", "mayRemainCombinedWhen": "Server-rendered pages inside the API host." }, - { - "name": "Bff", - "aliases": [], - "category": "host", - "purpose": "Backend-for-Frontend: signs the user in, holds the tokens, gives the client a cookie, and forwards calls with the user's access token rather than its own identity.", - "mayDependOn": ["Contracts", "ServiceDefaults", "Shared", "WebApp"], - "mustNotDependOn": ["Core", "Application", "Persistence", "Providers", "Adapters", "Composition", "API", "Worker", "Migrator"], - "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Npgsql", "Microsoft.Data.SqlClient"], - "separateProjectWhen": "Tokens must be kept out of the browser, or several API calls must be combined into one response. In a Blazor solution it is the host that serves the WebAssembly client, so it references WebApp. One per client kind, not one per screen.", - "mayRemainCombinedWhen": "Never combined with the API; a BFF that only forwards calls the client could safely make itself is not earned." - }, { "name": "Shared", "aliases": [], @@ -318,8 +282,7 @@ "Migrator", "AppHost", "ServiceDefaults", - "WebApp", - "Bff" + "WebApp" ], "forbiddenPackagePrefixes": [], "separateProjectWhen": "A genuinely domain-independent primitive is used by two or more responsibilities.", diff --git a/docs/wiki/Project-Responsibilities.md b/docs/wiki/Project-Responsibilities.md index 4c3fff1..ea83dd4 100644 --- a/docs/wiki/Project-Responsibilities.md +++ b/docs/wiki/Project-Responsibilities.md @@ -55,10 +55,9 @@ HTTP request or an Aspire orchestration edge. | `API` | host | — | Core, Abstractions, Application, Contracts, Composition, ServiceDefaults, Shared, Persistence, Providers, Adapters | | `Worker` | host | — | Core, Abstractions, Application, Contracts, Composition, ServiceDefaults, Shared, Persistence, Providers, Adapters | | `Migrator` | host | — | Core, Abstractions, Persistence, Composition, ServiceDefaults, Shared | -| `AppHost` | host | — | API, Worker, Migrator, WebApp, Bff, Shared | +| `AppHost` | host | — | API, Worker, Migrator, WebApp, Shared | | `ServiceDefaults` | host | — | Shared | | `WebApp` | host | — | Contracts, ServiceDefaults, Shared | -| `Bff` | host | — | Contracts, ServiceDefaults, Shared, WebApp | | `Shared` | shared | — | *(nothing)* | ### When each one earns its own project @@ -75,7 +74,6 @@ HTTP request or an Aspire orchestration edge. | `Composition` | More than one host composes the same implementations | One host — compose in the host | | `Migrator` | Schema changes are applied by a deliberate deployment step | Development-only, migrate on startup | | `ServiceDefaults` | More than one host shares operational defaults | One host | -| `Bff` | Tokens must be kept out of the browser, or several API calls must become one response — one per client kind | It would only forward calls the client could safely make itself | | `Shared` | A domain-independent primitive is used by two or more responsibilities | Almost always — it is the easiest project to create and the hardest to keep coherent | ### Diagram A — the responsibility catalogue @@ -107,7 +105,6 @@ flowchart TB AppHost["AppHost"] ServiceDefaults["ServiceDefaults"] WebApp["WebApp"] - Bff["Bff"] end subgraph shared["Shared"] Shared["Shared"] @@ -143,8 +140,6 @@ flowchart RL Worker --> Composition Migrator --> Persistence WebApp --> Contracts - Bff -->|"serves the WebAssembly client"| WebApp - Bff -->|"only when it prerenders or combines"| Contracts AppHost -.->|"orchestrates, not a reference"| API AppHost -.->|"orchestrates, not a reference"| Worker diff --git a/skills/purview-architecture/SKILL.md b/skills/purview-architecture/SKILL.md index 29a7cfb..c04f3d6 100644 --- a/skills/purview-architecture/SKILL.md +++ b/skills/purview-architecture/SKILL.md @@ -128,12 +128,13 @@ The rules that do not bend: your own API's document when that client is in the same solution. - **Never hand-maintain a parallel set of models**, and **the server always validates** — a shared validator is for user experience, not trust. -- **Contracts is browser-safe** when a WebAssembly client references it: the architecture policy, +- **Contracts is browser-safe** when a WebAssembly client references it: architecture-policy bans, the trim and AOT analyzers and the browser platform analyzer check it, not a reviewer. - **A BFF is earned by keeping tokens out of the browser.** It holds the tokens, gives the browser an `HttpOnly` cookie, forwards with the user's token, and requires an anti-forgery header on every forwarded call. In Blazor it is the host that serves the WebAssembly client, and a BFF that only - forwards does not reference Contracts. + forwards does not reference Contracts. The policy does not define a BFF yet, so these are review + obligations until a future Build SDK release adds Blazor and BFF support. Full procedure, version-skew rules, BFF guidance and worked examples: `references/contract-strategy.md`. diff --git a/skills/purview-architecture/references/contract-strategy.md b/skills/purview-architecture/references/contract-strategy.md index a1771af..e281a41 100644 --- a/skills/purview-architecture/references/contract-strategy.md +++ b/skills/purview-architecture/references/contract-strategy.md @@ -122,16 +122,18 @@ step 3, because the .NET client still has value objects and rules to lose. trim, and no reflection-dependent behaviour. The same applies transitively to the domain library, because the client downloads it too. Make the build enforce it, not a reviewer — three mechanisms, each catching what the others cannot: - - **The architecture policy** forbids `Microsoft.AspNetCore` packages and the - `Microsoft.AspNetCore.App` framework reference in `Contracts` and `Core`. See - `purview-architecture-testing`. + - **Your repository's architecture policy** forbids `Microsoft.AspNetCore` packages + (`forbiddenPackagePrefixes`) and the `Microsoft.AspNetCore.App` framework reference + (`forbiddenFrameworkReferences`) in `Contracts` and `Core`. The playbook's own policy forbids the + packages in `Core` only; the rest arrives with the Build SDK's planned Blazor support, so add it + yourself until then. See `purview-architecture-testing`. - **`true`** on both projects turns on the trim and AOT analyzers, so reflection a trimmed client would break on is a warning at build time. `Purview.ValueObjects` and `Purview.ZodSharp` already set it. - **``** makes the platform-compatibility analyzer (`CA1416`) flag an API that is unsupported in the browser, such as starting a process. - The policy fails a test run. The two analyzers produce warnings, and the SDK does not turn on + The policy rules fail a test run. The two analyzers produce warnings, and the SDK does not turn on `TreatWarningsAsErrors` for an ordinary project — set it on `Contracts` and `Core`, or the "clean build has no warnings" rule is the only thing standing behind them. No analyzer proves the absence of every kind of I/O; what they miss is a review obligation, and the client's own tests running in the @@ -212,9 +214,11 @@ close it: - **One BFF per client kind, not one per screen.** Web and mobile may each get one when their sign-in or response needs differ. -The policy names it `Bff`; its dependency row is in `project-responsibilities.md`. The policy allows -`Bff → Contracts` because it cannot express "only when it prerenders or combines"; that condition is a -review obligation. +**None of this is enforced yet.** The architecture policy has no BFF responsibility; Blazor and BFF +support is planned for a future Build SDK release. Until then every rule in this section is a review +obligation, and a repository that adds a BFF project records it in its own policy — see +`project-responsibilities.md`. Even once it is in the policy, "Contracts only when it prerenders or +combines" stays a review obligation, because a dependency matrix cannot express a condition. ## Worked examples diff --git a/skills/purview-architecture/references/project-responsibilities.md b/skills/purview-architecture/references/project-responsibilities.md index 160d5fd..d74d2cf 100644 --- a/skills/purview-architecture/references/project-responsibilities.md +++ b/skills/purview-architecture/references/project-responsibilities.md @@ -26,13 +26,25 @@ Do not restate either here or in another skill; link to them. | `AppHost` | — | Aspire orchestration | The hosts it orchestrates | | `ServiceDefaults` | — | Telemetry, health, resilience, discovery defaults | Shared | | `WebApp` | — | Frontend, including a Blazor WebAssembly client that references Contracts | Contracts, ServiceDefaults, Shared | -| `Bff` | — | Sign-in and token custody for a browser or mobile client; forwards with the user's token. In Blazor, the host that serves the WebAssembly client | WebApp, Contracts (only when it prerenders or combines responses), ServiceDefaults, Shared | | `Shared` | — | Small domain-independent primitives | *nothing* | **None of these is mandatory.** A small application with `Core` and one host has a complete and correct architecture. The catalogue names responsibilities so you can tell when splitting one out buys something — it is not a list of projects to create. +### Backend-for-Frontend: guidance, not yet policy + +A BFF is a host responsibility that the policy does **not** define yet; Blazor and BFF support is +planned for a future Build SDK release. Until then it is guidance, and every rule below is a review +obligation: + +| Owns | May depend on | Split it out when | Leave it combined when | +| -- | -- | -- | -- | +| Sign-in and token custody for a browser or mobile client; forwards with the user's token. In Blazor, the host that serves the WebAssembly client | The WebAssembly client project, ServiceDefaults, Shared — and Contracts only when it prerenders data or combines responses | Tokens must be kept out of the browser, or several API calls must become one response | It would only forward calls that the client could make with a token it already holds safely | + +A repository that adds a BFF project today needs its own responsibility entry or a waiver in its +policy, or the architecture tests reject the project as unclassified. See `contract-strategy.md`. + ## When to split | Responsibility | Split it out when | Leave it combined when | @@ -40,9 +52,7 @@ buys something — it is not a list of projects to create. | `Core` | A domain model exists | The app is a thin utility | | `Abstractions` | Two implementations, or the consumer must not see the implementation | One implementation — keep the interface beside it | | `Application` | Workflows are reused by more than one host | It would only forward CRUD | -| `Contracts` | The contract strategy is Contracts or Hybrid — see `contract-strategy.md` | The API host is the only consumer, which is the OpenAPI strategy | -| `Bff` | Tokens must be kept out of the browser, or several API calls must become one response | It would only forward calls that the client could make with a token it already holds safely | -| `Persistence` | Persistence exists at all | A single-project utility writing a file | +| `Contracts` | The contract strategy is Contracts or Hybrid — see `contract-strategy.md` | The API host is the only consumer, which is the OpenAPI strategy || `Persistence` | Persistence exists at all | A single-project utility writing a file | | `Providers` | Two platforms with different SDKs or lifecycles | One platform | | `Adapters` | One capability, several technologies | One capability, one technology | | `Composition` | Two hosts compose the same implementations | One host — compose in the host |