Skip to content

Commit a36bec1

Browse files
authored
fix: register the unit of work with its store context (#41)
* fix: register the unit of work with its store context * fix: pin read-side lifetimes and name the unit of work's other dependencies --------- Co-authored-by: Norbert Rosenwinkel <199918463+yesbert@users.noreply.github.com>
1 parent d8b7acb commit a36bec1

12 files changed

Lines changed: 466 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ applies to the entire NuGet family.
1818

1919
### Fixed
2020

21+
- **Registering a store context now registers its unit of work.**
22+
`AddNpgsqlWriteDbContextFactory<T>()` registered the context factory, the context and the default
23+
connection resolver but not the `IWriteUnitOfWork` that the event source, the outbox dispatcher and
24+
the command worker take from the container — and nothing else in the published packages did, so a
25+
host composed exactly as documented failed at its first command with a dependency-injection error.
26+
The write registration now try-adds a scoped `IWriteUnitOfWork` over its context, and
27+
`AddNpgsqlReadDbContextFactory<T>()` try-adds `IProjectionsUnitOfWork` and `IReadUnitOfWork`
28+
likewise. A unit of work the host registers itself, before or after, is still the one used; a
29+
hand-written registration can simply be deleted.
2130
- **`AddMediator()` no longer requires the host to register an OpenTelemetry `Tracer`.** The
2231
mediator traces every dispatch and obtained its tracer from the host, but nothing registered one,
2332
so a host that called `AddMediator()` and nothing else failed at the first resolve of `IMediator`.

‎docs/reference/di-extensions-cheatsheet.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,8 @@ transport, but registering both in one host is still a smell — pick one.
120120

121121
| Extension | What it does |
122122
|---|---|
123-
| `services.AddNpgsqlWriteDbContextFactory<TContext>()` | Npgsql-backed `IDbContextFactory<TContext>` for the write-store context, plus the default `IWriteUnitOfWork` if none is registered |
124-
| `services.AddNpgsqlReadDbContextFactory<TContext>()` | The same for a read-store context |
123+
| `services.AddNpgsqlWriteDbContextFactory<TContext>()` | Npgsql-backed `IDbContextFactory<TContext>` for the write-store context, plus a scoped `IWriteUnitOfWork` over it unless the host registered its own. The unit of work also needs `ISessionContextProvider` and `ISecureJsonSerializer` from `AddSessionContext()` / `AddSecurity()`, which every worker composite applies |
124+
| `services.AddNpgsqlReadDbContextFactory<TContext>()` | The same for a read-store context, plus a scoped `IProjectionsUnitOfWork` / `IReadUnitOfWork` over it unless the host registered its own |
125125
| `services.AddNpgsqlIdentityDbContextFactory<TContext>()` | The same for an identity-store context, **plus** a scoped resolution of the context itself so ASP.NET Identity can inject it directly |
126126
| `services.AddWriteStore(configuration)` | Binds `EventSourcingOptions` from the `EventSourcing` section — snapshot cadence, batch sizes and the other write-side knobs |
127127
| `services.AddCommandAuditing()` | `CommandAuditBehavior` for both command shapes — persists an audit row per dispatched command; queries pass through (`Stratara.EventSourcing.Pipeline.CommandAudit`) |

‎llms-full.txt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,8 @@ Public extension methods a host calls to wire Stratara, with what each one does
131131
| `AddMembershipTenantClaimsTransformation()` | Stratara.Identity.AspNetCore | Register MembershipClaimsTransformation as an IClaimsTransformation, resolving the tenant claim from the membership store on every request |
132132
| `AddMessaging()` | Stratara.Outbox.RabbitMQ | Registers the messaging infrastructure: IMessageBus backed by RabbitMqBus, the IMessagingIdentifier service, and binds MessagingOptions from the Messaging configuration section |
133133
| `AddNpgsqlIdentityDbContextFactory<TDbContext>()` | Stratara.EventSourcing.EntityFrameworkCore | Registers an Npgsql-backed IDbContextFactory`1 for an identity-store DbContext together with a scoped resolution of the context itself (so ASP.NET Identity can inject it directly) and the default IDbResolver |
134-
| `AddNpgsqlReadDbContextFactory<TDbContext>()` | Stratara.EventSourcing.EntityFrameworkCore | Registers an Npgsql-backed IDbContextFactory`1 for a read-store DbContext along with the default IDbResolver if none has been registered yet |
135-
| `AddNpgsqlWriteDbContextFactory<TDbContext>()` | Stratara.EventSourcing.EntityFrameworkCore | Registers an Npgsql-backed IDbContextFactory`1 for a write-store DbContext along with the default IDbResolver if none has been registered yet |
134+
| `AddNpgsqlReadDbContextFactory<TDbContext>()` | Stratara.EventSourcing.EntityFrameworkCore | Registers an Npgsql-backed IDbContextFactory`1 for a read-store DbContext together with the read-side unit of work over it, and the default IDbResolver if none has been registered yet |
135+
| `AddNpgsqlWriteDbContextFactory<TDbContext>()` | Stratara.EventSourcing.EntityFrameworkCore | Registers an Npgsql-backed IDbContextFactory`1 for a write-store DbContext together with the write-side unit of work over it, and the default IDbResolver if none has been registered yet |
136136
| `AddOutboxDispatcher()` | Stratara.Outbox.RabbitMQ | Registers ICommandOutboxDispatcher and IEventBundleOutboxDispatcher (scoped) and, transitively, IProjectionReplayState (singleton via AddProjectionReplayState) |
137137
| `AddOutboxHealthCheck(Int32? degradedThreshold, Int32? unhealthyThreshold, String name, HealthStatus? failureStatus, IEnumerable<String> tags)` | Stratara.EventSourcing.EntityFrameworkCore | Registers the OutboxBacklogHealthCheck, reporting the depth and age of the Stratara outbox backlog |
138138
| `AddOutboxWorker(IConfiguration configuration)` | Stratara.Outbox.RabbitMQ | Registers the OutboxWorker hosted service and binds OutboxOptions from configuration |
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-09-03
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Design — Let a store context bring its unit of work
2+
3+
## Context
4+
5+
See `proposal.md` → *Why*. What matters here is what the two registrations do today and what the
6+
one place that does register the unit of work looks like.
7+
8+
`NpgsqlDbContextServiceCollectionExtensions` (`src/Stratara.EventSourcing.EntityFrameworkCore/EntityFrameworkCore/DependencyInjection/`):
9+
10+
- `AddNpgsqlWriteDbContextFactory<TDbContext>()` (line 34) — `AddDbContextFactory<TDbContext>` scoped,
11+
`TryAddScoped<IWriteDbContext>` from the factory, `TryAddScoped<IDbResolver, DefaultDbResolver>`.
12+
- `AddNpgsqlReadDbContextFactory<TDbContext>()` (line 54) — the factory and the resolver only.
13+
14+
`WriteUnitOfWork<TDbContext>` (`WriteStore/WriteUnitOfWork.cs:24`) is constructed from
15+
`(IDbContextFactory<TDbContext>, ISessionContextProvider, ISecureJsonSerializer)`; the last two come
16+
from `AddSessionContext()` and `AddSecurity()`, which every composite applies.
17+
`ProjectionsUnitOfWork<TDbContext>` (`ReadStore/ProjectionsUnitOfWork.cs:16`) takes only the
18+
factory and implements `IProjectionsUnitOfWork : IReadUnitOfWork`.
19+
20+
The only registration of `IWriteUnitOfWork` for a concrete context in the family is in test support,
21+
`AddStrataraTestingEventStore` (`src/Stratara.Testing.EntityFrameworkCore/TestEventStoreServiceCollectionExtensions.cs:72`):
22+
23+
```csharp
24+
services.AddScoped<IWriteUnitOfWork>(sp => new WriteUnitOfWork<TWriteDbContext>(
25+
sp.GetRequiredService<IDbContextFactory<TWriteDbContext>>(),
26+
sp.GetRequiredService<ISessionContextProvider>(),
27+
sp.GetRequiredService<ISecureJsonSerializer>()));
28+
```
29+
30+
`docs/reference/di-extensions-cheatsheet.md:123` describes the write factory as registering "the
31+
default `IWriteUnitOfWork` if none is registered". `llms-full.txt` is generated from the XML docs
32+
and the documentation tests fail when it drifts from them.
33+
34+
## Goals / Non-Goals
35+
36+
**Goals:**
37+
38+
- Registering a context is sufficient for the store on that side to work.
39+
- A consumer's own unit of work, registered before or after the context, wins.
40+
- The unit of work is scoped, as the test host registers it and as the repositories it mints assume.
41+
42+
**Non-Goals:**
43+
44+
- Registering the unit of work from the composites (`AddBackendServices`, `AddCommandWorkerServices`).
45+
They do not know the consumer's context type, and the point is that the one call that does know it
46+
is the one that registers it.
47+
- Changing the identity-context registration. ASP.NET Identity resolves the context itself; there
48+
is no unit of work on that side.
49+
- Touching the SQLite test host. It keeps its explicit registration, which is now redundant with
50+
the production one but harmless, and its `AddScoped` (not try-add) is deliberate there.
51+
52+
## Decisions
53+
54+
### The factory registration try-adds the unit of work for its own context type
55+
56+
`AddNpgsqlWriteDbContextFactory<TDbContext>()` adds
57+
`TryAddScoped<IWriteUnitOfWork>(sp => new WriteUnitOfWork<TDbContext>(...))` with the three
58+
constructor dependencies resolved from the provider, exactly the shape the test host uses.
59+
`AddNpgsqlReadDbContextFactory<TDbContext>()` adds
60+
`TryAddScoped<IProjectionsUnitOfWork>(sp => new ProjectionsUnitOfWork<TDbContext>(factory))` and
61+
`TryAddScoped<IReadUnitOfWork>(sp => sp.GetRequiredService<IProjectionsUnitOfWork>())`, so the two
62+
contracts resolve to one instance per scope.
63+
64+
Try-add is what makes precedence hold in both orders: a consumer registration before the factory
65+
call means the framework's try-add is a no-op; a consumer registration after it is a later descriptor
66+
for the same service type, which the container prefers. Registering the same context twice is a
67+
second no-op.
68+
69+
Evidence: the constructor shapes above; the test host's registration; the new tests in
70+
`tests/Stratara.EntityFrameworkCore.Tests/` that resolve each contract against a registered context
71+
and assert the type and the precedence.
72+
73+
*Rejected: `AddScoped` rather than try-add.* It would silently replace a consumer's own unit of work
74+
registered before the factory call — the opposite of what an additive patch may do.
75+
76+
*Rejected: a separate `AddNpgsqlWriteUnitOfWork<TDbContext>()` the consumer calls in addition.* A
77+
second call that is mandatory for the first to be useful is the defect restated with one more name.
78+
79+
### The factory dependencies are resolved lazily, not captured at registration
80+
81+
The write unit of work needs the session provider and the serializer. Both are registered by
82+
composites the consumer may call after the factory extension. Resolving them inside the factory
83+
delegate, per scope, means the order of `Add*` calls does not matter, which is the standing rule of
84+
the composition surface.
85+
86+
## Risks / Trade-offs
87+
88+
- [A consumer registered `IWriteUnitOfWork` as a singleton by hand, and now also gets a scoped
89+
try-add] → Try-add sees the existing descriptor and does nothing; the consumer's singleton stands.
90+
No change.
91+
- [A host registers the write context but not `AddSecurity()` / `AddSessionContext()`] → The unit of
92+
work fails to resolve its serializer or session provider at first use, with the container's message
93+
naming that type. That host could not have run the store before either; the error now names a
94+
documented composite dependency rather than the unit of work itself.
95+
- [`llms-full.txt` drifts] → It is regenerated as part of the change; the documentation tests are the
96+
gate.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
> **Status:** approved
2+
3+
# Let a store context bring its unit of work
4+
5+
## Why
6+
7+
A consumer who registers the write store the way the documentation shows — one context class
8+
deriving from the framework's write context, one call to the Npgsql factory extension — cannot
9+
dispatch a command. The registration brings the context factory, the context and the default
10+
connection resolver, but not the write-side unit of work that every repository, the event source,
11+
the outbox dispatcher and the command worker take from the container. The first dispatch fails with
12+
a dependency-injection error naming a type the consumer never saw in a guide. The reference page
13+
even says the factory registers "the default `IWriteUnitOfWork` if none is registered"; it does not,
14+
and nothing else in the published packages does — only the test-support host, which is why the
15+
framework's own tests never notice.
16+
17+
The read side has the same gap: the read-context factory registers the factory and the resolver, and
18+
the read-side unit of work the projections capability requires is left to the consumer to construct
19+
by hand from a type they have to discover in the source.
20+
21+
Found on 2026-09-03 while briefing the first example that consumes the published packages rather
22+
than the source. It is the second step of the entry-point work approved that day: the event-sourcing
23+
door has to be "register the context, write an aggregate", not "register the context, read the
24+
framework's test host to learn what else is missing".
25+
26+
## What Changes
27+
28+
- **Registering the write context registers the write-side unit of work.** The Npgsql write-context
29+
registration also makes the write-side unit of work resolvable for that context, with try-add
30+
semantics: a consumer that registers its own keeps it.
31+
- **Registering the read context registers the read-side unit of work.** The Npgsql read-context
32+
registration likewise makes the read-side unit of work — the one projections use, and the plain
33+
read unit of work it derives from — resolvable, with the same try-add semantics.
34+
- The reference page stops describing behaviour the framework did not have and describes the
35+
behaviour it now has, for both sides.
36+
37+
Nothing about what the unit of work does changes. A consumer that already registers it by hand sees
38+
no difference; the line becomes deletable.
39+
40+
## Capabilities
41+
42+
### New Capabilities
43+
44+
_none_
45+
46+
### Modified Capabilities
47+
48+
- `event-sourcing-store`: gains the requirement that registering the store's database context makes
49+
the store usable without further registration — the write-side unit of work is available and a
50+
consumer-supplied one takes precedence.
51+
- `projections`: the requirement *Read models are queried through a scoped unit of work* gains the
52+
guarantee that registering the read-side database context makes that unit of work available, and
53+
that a consumer-supplied one takes precedence.
54+
55+
## Impact
56+
57+
- `src/Stratara.EventSourcing.EntityFrameworkCore/EntityFrameworkCore/DependencyInjection/NpgsqlDbContextServiceCollectionExtensions.cs`
58+
— the write and read factory registrations gain the try-add of their unit of work; XML docs say so.
59+
- `docs/reference/di-extensions-cheatsheet.md` — the write row becomes true, the read row says the
60+
same for its side.
61+
- `llms-full.txt` — regenerated, because it is derived from the XML docs that change.
62+
- `tests/Stratara.EntityFrameworkCore.Tests/` — the registration is pinned on both sides, including
63+
the precedence of a consumer-supplied unit of work.
64+
- `CHANGELOG.md` — `[Unreleased]`.
65+
- Additive on the published surface: a patch release. Source: consumer briefing for
66+
`Stratara.Examples`, 2026-09-03 (`.claude/docs/examples-consumer-briefing.md`, item 1).
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Registering the store's context makes the store usable
4+
5+
Registering the write store's database context through the framework's registration SHALL make the
6+
write-side unit of work available to everything that depends on it — appending events, publishing
7+
through the outbox, handling commands in a worker — without a further registration by the consumer.
8+
A write-side unit of work the consumer registers itself SHALL take precedence over the framework's.
9+
10+
A store whose context is registered but whose unit of work is not is a store that fails at the first
11+
command with an error naming a type no guide mentions. The registration that declares the context
12+
is the one place that knows which context the unit of work should be built over.
13+
14+
#### Scenario: A consumer registers only the write context
15+
16+
- **WHEN** a consumer registers its write-store context through the framework's registration and
17+
nothing else
18+
- **THEN** the write-side unit of work resolves and is bound to that context, and a command that
19+
appends an event can be handled
20+
21+
#### Scenario: A consumer supplies its own write-side unit of work
22+
23+
- **WHEN** a consumer registers its own write-side unit of work, before or after registering the
24+
context
25+
- **THEN** the consumer's unit of work is the one resolved
26+
27+
#### Scenario: The registration is applied more than once
28+
29+
- **WHEN** the write-store context is registered more than once for the same context type
30+
- **THEN** one unit of work is resolved, bound to that context
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
## MODIFIED Requirements
2+
3+
### Requirement: Read models are queried through a scoped unit of work
4+
5+
Read-side access SHALL be through a unit of work distinct from the write side's, so that a query
6+
never participates in a write transaction and a read model can live in its own store.
7+
8+
Registering the read store's database context through the framework's registration SHALL make that
9+
read-side unit of work available without a further registration by the consumer. A read-side unit
10+
of work the consumer registers itself SHALL take precedence over the framework's.
11+
12+
#### Scenario: A projection writes to a read model
13+
14+
- **WHEN** a projection processes a bundle
15+
- **THEN** it does so through the read-side unit of work, in its own transaction
16+
17+
#### Scenario: A consumer registers only the read context
18+
19+
- **WHEN** a consumer registers its read-store context through the framework's registration and
20+
nothing else
21+
- **THEN** the read-side unit of work resolves, bound to that context
22+
23+
#### Scenario: A consumer supplies its own read-side unit of work
24+
25+
- **WHEN** a consumer registers its own read-side unit of work, before or after registering the
26+
context
27+
- **THEN** the consumer's unit of work is the one resolved
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
## 1. The registrations
2+
3+
- [x] 1.1 `src/Stratara.EventSourcing.EntityFrameworkCore/EntityFrameworkCore/DependencyInjection/NpgsqlDbContextServiceCollectionExtensions.cs`:
4+
`AddNpgsqlWriteDbContextFactory<TDbContext>()` try-adds a scoped `IWriteUnitOfWork` built as
5+
`WriteUnitOfWork<TDbContext>` from the context factory, `ISessionContextProvider` and
6+
`ISecureJsonSerializer`, all resolved inside the factory delegate. Update the XML summary and
7+
remarks: what is registered, that a consumer-supplied unit of work wins, that the session and
8+
security composites supply the other two dependencies.
9+
- [x] 1.2 Same file: `AddNpgsqlReadDbContextFactory<TDbContext>()` try-adds a scoped
10+
`IProjectionsUnitOfWork` as `ProjectionsUnitOfWork<TDbContext>` and a scoped `IReadUnitOfWork`
11+
that resolves to it. XML docs likewise.
12+
- [x] 1.3 Tests in `tests/Stratara.EntityFrameworkCore.Tests/DependencyInjection/NpgsqlDbContextServiceCollectionExtensionsTests.cs`
13+
(new): with a write context registered and the session/security doubles present,
14+
`IWriteUnitOfWork` resolves as `WriteUnitOfWork<TContext>` and is scoped; a consumer
15+
`IWriteUnitOfWork` registered before, and one registered after, is the instance resolved;
16+
registering the context twice yields one `IWriteUnitOfWork` descriptor; the read side resolves
17+
`IProjectionsUnitOfWork` and `IReadUnitOfWork` to the same `ProjectionsUnitOfWork<TContext>`
18+
instance within a scope, with the same precedence cases. Resolution only — no database is
19+
opened.
20+
21+
## 2. Documentation and generated inventory
22+
23+
- [x] 2.1 `docs/reference/di-extensions-cheatsheet.md` rows 123–124: the write row describes the
24+
try-added `IWriteUnitOfWork`; the read row says `IProjectionsUnitOfWork` / `IReadUnitOfWork`.
25+
- [x] 2.2 `src/Stratara.EventSourcing.EntityFrameworkCore/README.md`, where the consumer contexts are
26+
shown: the factory registration is all the store needs. (`di-composition.md` shows no consumer
27+
context, so it has nothing to say here.)
28+
- [x] 2.3 Regenerate `llms-full.txt` with the generator the documentation tests use
29+
(`tests/Stratara.Documentation.Tests`), so the registration inventory carries the new summaries.
30+
- [x] 2.4 `CHANGELOG.md` `[Unreleased]` → *Fixed*: registering the write or read context now
31+
registers its unit of work; a consumer-supplied one still wins; the hand-written line can go.
32+
33+
## 3. Gate
34+
35+
- [x] 3.1 `./scripts/local-gauntlet.sh` green; `openspec validate --strict` clean.

0 commit comments

Comments
 (0)