@cratis/pi 2.0.9 → 2.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -51,6 +51,7 @@ When checking unused code, references, or naming, use semantic navigation if the
51
51
  - [ ] No shared state between commands
52
52
  - [ ] No service locator (`IServiceProvider` not injected); `IInstancesOf<T>` (not `IEnumerable<T>`) for discovering implementations
53
53
  - [ ] No explicit singleton registration when `[Singleton]` attribute suffices
54
+ - [ ] No `[Singleton]` takes a scoped dependency (event store and anything off it, MongoDB collection/database/client, `DbContext`, read model by key) — such a type is transient or scoped instead
54
55
  - [ ] Logging is in a separate `*Logging.cs` partial file with `[LoggerMessage]`
55
56
 
56
57
  ## C# Commands checklist
@@ -133,11 +133,17 @@ The framework discovers and wires dependencies by convention. Explicit registrat
133
133
  - Systems with a convention of `IFoo → Foo` do not need to be registered explicitly.
134
134
  - Command/query `Handle()` method parameters are automatically resolved from DI by type.
135
135
 
136
- ### Service lifetimes — `[Singleton]` is a narrow choice, not the default
136
+ ### Service lifetimes — anything taking a scoped dependency is scoped or transient, never a singleton
137
+
138
+ **The rule, before any of the reasoning:**
139
+
140
+ > **A type that takes a scoped dependency is itself scoped or transient. If you are reaching for `[Singleton]` on something that needs the event store, a database, or a read model, the answer is to not make it a singleton.**
141
+
142
+ `[Singleton]` is the exception, not the default. It is for what is genuinely process-wide *and* holds nothing belonging to a tenant, a user, or a request. Everything else takes the convention (transient) or `[Scoped]`, and inherits the resolving scope — and therefore the right tenant — for free.
137
143
 
138
144
  **Assume every application you build is multi-tenant.** Not "design for it later" — assume it now, even when the deployment ships with a single tenant and no tenant resolution configured. A single-tenant application is a multi-tenant one with one tenant in it, and the code shape that serves both is the same shape. The code shape that serves only one has to be found and rewritten later, from the far side of a data migration, under production. The same reasoning applies to the signed-in user: an application always has one, and a service that remembers *which* one will eventually answer for the wrong person.
139
145
 
140
- That gives one rule with two faces:
146
+ So the rule has a second face:
141
147
 
142
148
  > **A singleton may not depend on anything that belongs to a tenant, a user, or a request.**
143
149
 
@@ -155,9 +161,9 @@ A `[Singleton]` taking one of these is a **captive dependency**: the container h
155
161
 
156
162
  **It does not throw. It returns nothing.** A query against the wrong namespace hits a database that exists and is empty, so the caller gets an empty collection, a `null` read model, or a default-valued options object, and carries on. The application starts, the pages render, the build is green, and the configuration a tenant spent an afternoon entering is simply not there. It is also invisible while there is only one tenant — every symptom appears on the day a second one arrives.
157
163
 
158
- **What to use instead.** Default to the convention (transient), which inherits the resolving scope's tenant for free, or `[Scoped]` when a service must be shared within one request. Reserve `[Singleton]` for things that are genuinely process-wide and hold no tenant-, user-, or request-bound state: `IInstancesOf<T>` aggregators, HTTP client wrappers, `IOptions<T>` readers, pure computation, framework plumbing.
164
+ **What to do instead in this order.**
159
165
 
160
- When something must be a singleton and still needs data a hosted service, a dispatcher, a poller inject `IServiceScopeFactory` and open a scope per unit of work:
166
+ **1. Drop `[Singleton]`.** This is the answer almost every time. Delete the attribute and let the type be transient by convention, or mark it `[Scoped]` when one request should share one instance. Nothing else changes: the constructor keeps the collaborator it wanted, and now gets the caller's tenant instead of the root scope's.
161
167
 
162
168
  ```csharp
163
169
  // ❌ Wrong — IEventStore is scoped; this captures the root scope's default namespace forever.
@@ -168,19 +174,35 @@ public class DigestSources(IEventStore eventStore) : IDigestSources
168
174
  eventStore.ReadModels.GetInstanceById<DigestConfiguration>(DigestId.Default);
169
175
  }
170
176
 
171
- // ✅ Right — a scope per call, so the collaborators bind to the caller's tenant.
172
- [Singleton]
173
- public class DigestSources(IServiceScopeFactory scopeFactory) : IDigestSources
177
+ // ✅ Right — no attribute at all. Transient by convention, so it resolves in the caller's scope
178
+ // and reads that caller's tenant. There was never a reason for this to be process-wide.
179
+ public class DigestSources(IEventStore eventStore) : IDigestSources
180
+ {
181
+ public Task<DigestConfiguration?> GetCurrent() =>
182
+ eventStore.ReadModels.GetInstanceById<DigestConfiguration>(DigestId.Default);
183
+ }
184
+ ```
185
+
186
+ **2. Only when the lifetime is forced on you, open a scope per unit of work.** A hosted service or `BackgroundService` is resolved once by the host, so it *is* a singleton whether or not you asked — and it runs with no request to inherit a scope from. That, and only that, is what `IServiceScopeFactory` is for:
187
+
188
+ ```csharp
189
+ // ✅ Right for a hosted service — a scope per unit of work, so collaborators bind to a real scope.
190
+ public class DigestDispatcher(IServiceScopeFactory scopeFactory) : BackgroundService
174
191
  {
175
- public async Task<DigestConfiguration?> GetCurrent()
192
+ protected override async Task ExecuteAsync(CancellationToken cancellationToken)
176
193
  {
177
- using var scope = scopeFactory.CreateScope();
178
- var eventStore = scope.ServiceProvider.GetRequiredService<IEventStore>();
179
- return await eventStore.ReadModels.GetInstanceById<DigestConfiguration>(DigestId.Default);
194
+ while (!cancellationToken.IsCancellationRequested)
195
+ {
196
+ using var scope = scopeFactory.CreateScope();
197
+ var eventStore = scope.ServiceProvider.GetRequiredService<IEventStore>();
198
+ await Dispatch(eventStore, cancellationToken);
199
+ }
180
200
  }
181
201
  }
182
202
  ```
183
203
 
204
+ ⚠️ **`IServiceScopeFactory` is not a way to keep `[Singleton]` on a service that had no reason to be one.** It is more code, it hides the lifetime question behind a scope nobody asked for, and — because a scope with no request still resolves no tenant — it does not by itself make an off-request flow tenant-correct. Reaching for it first is how a codebase ends up with dozens of these — a sweep of a real application found several dozen singletons holding a scoped service, not one of which needed to be a singleton at all. If the type is not the host's own, the fix is the attribute, not the factory.
205
+
184
206
  `IChronicleClient` **is** singleton-safe, and is the right collaborator when a flow knows which namespace it means and has no scope to resolve one from — it names the event store and namespace explicitly: `await chronicleClient.GetEventStore("MyStore", tenantId.Value)`. Naming the namespace is a deliberate, readable statement that this code crosses a tenant boundary; capturing a scoped service is the same crossing made by accident.
185
207
 
186
208
  **The current user is not process-wide either.** Never keep the signed-in user, their principal, claims, roles, or anything derived from them in a singleton. The distinction that matters: *the accessor is fine, the value is not.* `IHttpContextAccessor` is itself a singleton and safe to inject; reading a value out of it once and keeping it is not. A current-user service may be a singleton only when every method reads through the accessor on each call and stores nothing. Anything that derives something per user and wants to keep it holds a cache **keyed by the user**, never a single field.
@@ -189,9 +211,21 @@ public class DigestSources(IServiceScopeFactory scopeFactory) : IDigestSources
189
211
 
190
212
  **Caching.** A process-wide cache of tenant data is the same bug wearing a performance justification. If a singleton caches, the tenant (and where relevant the user) is part of the key. The same holds for `static` fields: a `static` cache of anything tenant-scoped is shared by every tenant in the process.
191
213
 
192
- **Enforce it, do not remember it.** This failure is silent, so review will not reliably catch it. Add an architecture spec that reflects over the assembly, finds every `[Singleton]` whose constructor takes a scope-bound service, and asserts the set is empty. It is a few dozen lines, it runs on every build, and it is the only thing that keeps the rule true a year from now.
214
+ **Enforce it, do not remember it.** This failure is silent, so review will not reliably catch it. Two gates, and an application wants both:
215
+
216
+ **Turn .NET's own scope validation on in every environment, not just Development.** `ServiceProviderOptions.ValidateScopes` rejects resolving a scoped service from the root provider, and `ValidateOnBuild` walks every registration at startup so a captive dependency fails the host immediately rather than at whichever request first happens to need it. The host enables both in Development by default and **neither outside it** — which is exactly backwards for a failure whose whole character is that it stays quiet:
217
+
218
+ ```csharp
219
+ builder.Host.UseDefaultServiceProvider(options =>
220
+ {
221
+ options.ValidateScopes = true;
222
+ options.ValidateOnBuild = true;
223
+ });
224
+ ```
225
+
226
+ Enable it on an existing codebase in this order, or it will simply refuse to start: turn it on locally first, fix everything it names, and only then let it reach the deployed environments. Turning it on *before* the sweep converts a silent multi-tenant bug into a production outage, which is a worse trade, not a braver one.
193
227
 
194
- > .NET's own captive-dependency detection (`ServiceProviderOptions.ValidateScopes`, which Arc deliberately leaves on in Development) exists to catch exactly this. If a singleton in your codebase holds a scoped service and Development startup is not complaining, that path is not being exercised in Development worth knowing on its own.
228
+ **And add an architecture spec**, because validation only catches what a run actually resolves. Reflect over the assembly, find every `[Singleton]` whose constructor takes a scope-bound service, and assert the set is empty. It is a few dozen lines, it names every offender in one pass rather than one per restart, and it holds for the types no startup path touches.
195
229
 
196
230
  ### Discovering multiple implementations — use `IInstancesOf<T>`, never `IEnumerable<T>`
197
231
 
@@ -258,7 +258,7 @@ Documentation-only changes use repository-supported non-release intent, ordinari
258
258
  | **Contributing to a Cratis framework repo** (framework profile) | `framework.md` |
259
259
  | Slice anatomy (commands, `Provide()`, validators, events, projections, read models, reactors, constraints, compliance, cross-slice) | `vertical-slices.md` |
260
260
  | C# / TypeScript style | `csharp.md`, `typescript.md` |
261
- | Service lifetimes — what a singleton may never hold (tenant, user, request state) | `csharp.md` |
261
+ | Service lifetimes — why anything taking a scoped dependency is scoped or transient, never a singleton | `csharp.md` |
262
262
  | React + Arc + Cratis Components + MVVM + dialogs | `react.md`, `components.md`, `dialogs.md` |
263
263
  | Frontend engineering quality & testing | `frontend-quality.md`, `frontend-testing.md`, `storybook.md` |
264
264
  | Spec patterns — universal `Specification` base (both profiles) | `specs.md`, `specs.csharp.md`, `specs.typescript.md` |
@@ -6,6 +6,8 @@ applyTo: "**/*"
6
6
 
7
7
  PR descriptions serve two purposes: they help reviewers understand the change *now*, and they become the release notes that users read *later*. Write them with both audiences in mind.
8
8
 
9
+ **The description is the release note — it is published verbatim.** Write it as the note you want the person upgrading to read, in the repository template's sections. A generic development write-up (`## Summary`, `## Verification`, `## Testing`, a list of the files you touched, a description of how you arrived at the change) is not a release note, and shipping one makes the release history unreadable. The same applies wherever a release is produced by hand: release-notes text typed into a manual workflow run, or written straight into a published release, carries exactly the same shape and the same audience as a PR description. There is no path to a release whose notes are allowed to describe the work instead of the change.
10
+
9
11
  ## Description
10
12
 
11
13
  - Follow the repository's pull request template (`.github/pull_request_template.md`).
@@ -66,6 +66,10 @@ there is nothing to review under a red build.
66
66
  - No service locator: `IServiceProvider` is not injected. Implementation sets
67
67
  come from `IInstancesOf<T>`, never `IEnumerable<T>`.
68
68
  - No explicit singleton registration where `[Singleton]` suffices.
69
+ - No `[Singleton]` takes a scoped dependency — the event store and anything off
70
+ it, a MongoDB collection/database/client, a `DbContext`, a read model by key.
71
+ Such a type is transient or scoped instead; `IServiceScopeFactory` is only for
72
+ a service the host itself resolves once.
69
73
  - Logging lives in a `*Logging.cs` partial with `[LoggerMessage]`, not inline in
70
74
  domain code.
71
75
  - No shared mutable state between commands.
@@ -100,10 +100,12 @@ Read the reference that covers the decision at hand rather than all three.
100
100
 
101
101
  ## The two rules most often got wrong
102
102
 
103
- **`[Singleton]` is a narrow choice, not the default.** A singleton may not
104
- depend on anything that belongs to a tenant, a user, or a request. Capturing a
105
- scoped collaborator does not throw — it silently binds to the root scope's
106
- default namespace forever and returns empty results. See
103
+ **Anything taking a scoped dependency is scoped or transient, never a
104
+ singleton.** A singleton may not depend on anything that belongs to a tenant, a
105
+ user, or a request. Capturing a scoped collaborator does not throw — it silently
106
+ binds to the root scope's default namespace forever and returns empty results.
107
+ The fix is almost always to drop `[Singleton]`, not to reach for
108
+ `IServiceScopeFactory`. See
107
109
  [exceptions-logging-and-di.md](references/exceptions-logging-and-di.md).
108
110
 
109
111
  **Use `IInstancesOf<T>`, never `IEnumerable<T>`, to enumerate implementations of
@@ -123,7 +125,9 @@ hand-registered, which defeats convention-based discovery.
123
125
  non-nullable annotation.
124
126
  - Every public type, method, property, and operator carries multiline XML
125
127
  documentation with `<param>` and `<returns>` where applicable.
126
- - No `[Singleton]` holds tenant-, user-, or request-bound state.
128
+ - No `[Singleton]` takes a scoped dependency or holds tenant-, user-, or
129
+ request-bound state, and the host sets `ValidateScopes` and `ValidateOnBuild`
130
+ in every environment.
127
131
  - No `services.Add*<TInterface, TImplementation>()` registers a type that exists
128
132
  to be discovered by convention.
129
133
  - Text is American English.
@@ -88,7 +88,18 @@ public class <ClassName>(IServiceProvider provider)
88
88
  }
89
89
  ```
90
90
 
91
- ## Service lifetimes — `[Singleton]` is a narrow choice
91
+ ## Service lifetimes — anything taking a scoped dependency is scoped or transient
92
+
93
+ **The rule, before any of the reasoning:**
94
+
95
+ > **A type that takes a scoped dependency is itself scoped or transient. If you
96
+ > are reaching for `[Singleton]` on something that needs the event store, a
97
+ > database, or a read model, the answer is to not make it a singleton.**
98
+
99
+ `[Singleton]` is the exception, not the default. It is for what is genuinely
100
+ process-wide *and* holds nothing belonging to a tenant, a user, or a request.
101
+ Everything else takes the convention (transient) or a scoped lifetime, and
102
+ inherits the resolving scope — and therefore the right tenant — for free.
92
103
 
93
104
  **Assume every application is multi-tenant**, even when it ships with a single
94
105
  tenant and no tenant resolution configured. A single-tenant application is a
@@ -96,7 +107,7 @@ multi-tenant one with one tenant in it, and the code shape that serves both is
96
107
  the same. The shape that serves only one has to be found and rewritten later,
97
108
  from the far side of a data migration, in production.
98
109
 
99
- That gives one rule with two faces:
110
+ So the rule has a second face:
100
111
 
101
112
  > **A singleton may not depend on anything that belongs to a tenant, a user, or
102
113
  > a request.**
@@ -124,16 +135,15 @@ on. The application starts, pages render, the build is green, and configuration
124
135
  a tenant spent an afternoon entering is simply absent. It is invisible while
125
136
  there is one tenant; every symptom appears the day a second arrives.
126
137
 
127
- **What to use instead.** Default to the convention (transient), which inherits
128
- the resolving scope's tenant for free, or a scoped lifetime when a service must
129
- be shared within one request. Reserve `[Singleton]` for what is genuinely
130
- process-wide and holds no tenant-, user-, or request-bound state: implementation
131
- aggregators, HTTP client wrappers, options readers, pure computation, framework
132
- plumbing.
138
+ **What to do instead in this order.**
133
139
 
134
- When something must be a singleton and still needs data a hosted service, a
135
- dispatcher, a poller inject `IServiceScopeFactory` and open a scope per unit
136
- of work:
140
+ **1. Drop `[Singleton]`.** This is the answer almost every time. Delete the
141
+ attribute and let the type be transient by convention, or mark it scoped when
142
+ one request should share one instance. Nothing else changes: the constructor
143
+ keeps the collaborator it wanted, and now gets the caller's tenant instead of
144
+ the root scope's. Reserve `[Singleton]` for what is genuinely process-wide and
145
+ holds no tenant-, user-, or request-bound state: implementation aggregators,
146
+ HTTP client wrappers, options readers, pure computation, framework plumbing.
137
147
 
138
148
  ```csharp
139
149
  // Wrong — the scoped collaborator captures the root scope's default namespace forever
@@ -143,21 +153,41 @@ public class <ClassName>(<IScopedCollaboratorType> <collaborator>) : <IInterface
143
153
  public Task<<ResultType>?> <MethodName>() => <collaborator>.<Method>(<argument>);
144
154
  }
145
155
 
146
- // Right — a scope per call, so collaborators bind to the caller's tenant
147
- [Singleton]
148
- public class <ClassName>(IServiceScopeFactory scopeFactory) : <IInterfaceName>
156
+ // Right — no attribute at all. Transient by convention, so it resolves in the
157
+ // caller's scope and reads that caller's tenant.
158
+ public class <ClassName>(<IScopedCollaboratorType> <collaborator>) : <IInterfaceName>
149
159
  {
150
- public async Task<<ResultType>?> <MethodName>()
160
+ public Task<<ResultType>?> <MethodName>() => <collaborator>.<Method>(<argument>);
161
+ }
162
+ ```
163
+
164
+ **2. Only when the lifetime is forced on you, open a scope per unit of work.** A
165
+ hosted or background service is resolved once by the host, so it *is* a
166
+ singleton whether or not you asked — and it runs with no request to inherit a
167
+ scope from. That, and only that, is what `IServiceScopeFactory` is for:
168
+
169
+ ```csharp
170
+ // Right for a hosted service — a scope per unit of work
171
+ public class <ClassName>(IServiceScopeFactory scopeFactory) : BackgroundService
172
+ {
173
+ protected override async Task ExecuteAsync(CancellationToken cancellationToken)
151
174
  {
152
175
  using var scope = scopeFactory.CreateScope();
153
176
  var <collaborator> = scope.ServiceProvider
154
177
  .GetRequiredService<<IScopedCollaboratorType>>();
155
178
 
156
- return await <collaborator>.<Method>(<argument>);
179
+ await <collaborator>.<Method>(<argument>);
157
180
  }
158
181
  }
159
182
  ```
160
183
 
184
+ `IServiceScopeFactory` is **not** a way to keep `[Singleton]` on a service that
185
+ had no reason to be one. It is more code, it hides the lifetime question behind
186
+ a scope nobody asked for, and — because a scope with no request still resolves
187
+ no tenant — it does not by itself make an off-request flow tenant-correct.
188
+ Reaching for it first is how a codebase ends up with dozens of these. If the
189
+ type is not the host's own, the fix is the attribute, not the factory.
190
+
161
191
  A client that names its store and namespace explicitly **is** singleton-safe,
162
192
  and is the right collaborator when a flow knows which namespace it means and has
163
193
  no scope to resolve one from. Naming the namespace is a deliberate, readable
@@ -180,6 +210,36 @@ collaborators they call are not — a flow that reaches a tenant-blind singleton
180
210
  has left its namespace behind without saying so. Such a flow states its tenant
181
211
  explicitly rather than inheriting whatever the root scope happens to be.
182
212
 
213
+ **Enforce it, do not remember it.** This failure is silent, so review will not
214
+ reliably catch it. Two gates, and an application wants both.
215
+
216
+ Turn .NET's own scope validation on in **every** environment, not just
217
+ Development. `ValidateScopes` rejects resolving a scoped service from the root
218
+ provider, and `ValidateOnBuild` walks every registration at startup so a captive
219
+ dependency fails the host immediately rather than at whichever request first
220
+ needs it. The host enables both in Development by default and neither outside
221
+ it — which is backwards for a failure whose whole character is that it stays
222
+ quiet:
223
+
224
+ ```csharp
225
+ builder.Host.UseDefaultServiceProvider(options =>
226
+ {
227
+ options.ValidateScopes = true;
228
+ options.ValidateOnBuild = true;
229
+ });
230
+ ```
231
+
232
+ Enable it on an existing codebase in this order, or it will simply refuse to
233
+ start: turn it on locally first, fix everything it names, and only then let it
234
+ reach the deployed environments. Turning it on before the sweep converts a
235
+ silent multi-tenant bug into a production outage.
236
+
237
+ And add an architecture specification, because validation only catches what a
238
+ run actually resolves. Reflect over the assembly, find every `[Singleton]` whose
239
+ constructor takes a scope-bound service, and assert the set is empty. It names
240
+ every offender in one pass rather than one per restart, and it covers the types
241
+ no startup path touches.
242
+
183
243
  ## Discovering implementations — `IInstancesOf<T>`, never `IEnumerable<T>`
184
244
 
185
245
  When a type needs every implementation of an abstraction — handlers, strategies,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/pi",
3
- "version": "2.0.9",
3
+ "version": "2.0.11",
4
4
  "description": "Configuration-aware Cratis AI integration for Pi",
5
5
  "type": "module",
6
6
  "files": [