@cratis/pi 2.0.10 → 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.
- package/package/corpus/agents/code-reviewer.md +1 -0
- package/package/corpus/rules/csharp.md +47 -13
- package/package/corpus/rules/general.md +1 -1
- package/package/corpus/skills/cratis-code-review/SKILL.md +4 -0
- package/package/corpus/skills/cratis-engineering-csharp-conventions/SKILL.md +9 -5
- package/package/corpus/skills/cratis-engineering-csharp-conventions/references/exceptions-logging-and-di.md +76 -16
- package/package.json +1 -1
|
@@ -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 —
|
|
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
|
-
|
|
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
|
|
164
|
+
**What to do instead — in this order.**
|
|
159
165
|
|
|
160
|
-
|
|
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 —
|
|
172
|
-
|
|
173
|
-
public class DigestSources(
|
|
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
|
-
|
|
192
|
+
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
|
176
193
|
{
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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.
|
|
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
|
-
|
|
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 —
|
|
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` |
|
|
@@ -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
|
-
|
|
104
|
-
depend on anything that belongs to a tenant, a
|
|
105
|
-
scoped collaborator does not throw — it silently
|
|
106
|
-
default namespace forever and returns empty results.
|
|
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
|
|
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 —
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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 —
|
|
147
|
-
|
|
148
|
-
public class <ClassName>(
|
|
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
|
|
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
|
-
|
|
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,
|