Skip to content

Commit 97d076f

Browse files
rafalmaciagclaude
andcommitted
README: document the real Uniqueness API — the old section was actively misleading
The section was titled 'EXPERIMENTAL Uniqueness support' and said 'Let's see the API proposal', documenting [Unique<FooCategory>] as though it worked. It never did: nothing reads that attribute and no source generator wires it. A reader would have decorated an event and got SILENT ZERO uniqueness — the exact failure this library exists to prevent. Now documents what ships: the reservation pattern, the 3 provider packages, the Reserve -> SaveNew -> Confirm sequence, the R9 compensation obligation (the lease buys liveness, not safety), provider choice incl. SQLite's same-host shared-volume boundary and SQL Server's case-insensitive default collation, binary-exact comparison, why this is NOT for document numbering, and an explicit statement that [Unique<T>] and IUniqueFrom are inert. Adds NuGet badges for the 3 new provider packages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012eBz8Xt5TA1LbF94tdbMZV
1 parent 48988e3 commit 97d076f

1 file changed

Lines changed: 100 additions & 28 deletions

File tree

README.md

Lines changed: 100 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ Just eXtreamly simple.
2323
[![MicroPlumberd.Encryption](https://img.shields.io/nuget/v/MicroPlumberd.Encryption.svg)](https://www.nuget.org/packages/MicroPlumberd.Encryption/)
2424
[![MicroPlumberd.Protobuf](https://img.shields.io/nuget/v/MicroPlumberd.Protobuf.svg)](https://www.nuget.org/packages/MicroPlumberd.Protobuf/)
2525
[![MicroPlumberd.Services.Uniqueness](https://img.shields.io/nuget/v/MicroPlumberd.Services.Uniqueness.svg)](https://www.nuget.org/packages/MicroPlumberd.Services.Uniqueness/)
26+
[![MicroPlumberd.Services.Uniqueness.Postgres](https://img.shields.io/nuget/v/MicroPlumberd.Services.Uniqueness.Postgres.svg)](https://www.nuget.org/packages/MicroPlumberd.Services.Uniqueness.Postgres/)
27+
[![MicroPlumberd.Services.Uniqueness.Sqlite](https://img.shields.io/nuget/v/MicroPlumberd.Services.Uniqueness.Sqlite.svg)](https://www.nuget.org/packages/MicroPlumberd.Services.Uniqueness.Sqlite/)
28+
[![MicroPlumberd.Services.Uniqueness.SqlServer](https://img.shields.io/nuget/v/MicroPlumberd.Services.Uniqueness.SqlServer.svg)](https://www.nuget.org/packages/MicroPlumberd.Services.Uniqueness.SqlServer/)
2629
[![MicroPlumberd.Services.Grpc.DirectConnect](https://img.shields.io/nuget/v/MicroPlumberd.Services.Grpc.DirectConnect.svg)](https://www.nuget.org/packages/MicroPlumberd.Services.Grpc.DirectConnect/)
2730
[![MicroPlumberd.Services.Identity](https://img.shields.io/nuget/v/MicroPlumberd.Services.Identity.svg)](https://www.nuget.org/packages/MicroPlumberd.Services.Identity/)
2831

@@ -480,49 +483,118 @@ public class OrderProcessManager(IPlumberd plumberd)
480483

481484
```
482485

483-
### EXPERIMENTAL Uniqueness support
486+
### Uniqueness (set-wide constraints)
484487

485-
Uniqueness support in EventSourcing is not out-of-the-box, especially in regards to EventStoreDB. You can use some "hacks" but at the end of the day, you want uniqueness to be enforced by some kind of database. EventStoreDB is not designed for that purpose.
488+
An event store enforces invariants **within one stream**. Uniqueness — "no two companies share a tax id" —
489+
spans streams, and there is no stream whose optimistic-concurrency check can express it. KurrentDB is not
490+
designed for it, and the usual workaround (one stream per value, append-at-NoStream) does not survive
491+
contact with reality: a *released* value's stream still exists, so it can never be re-reserved, and you get
492+
one stream per possible value forever.
486493

487-
However, you can leverage typical reservation patterns. At the moment the library supports only the first option:
494+
So put the constraint where constraints belong: a **relational unique index**, as a sidecar to the
495+
aggregate write. `MicroPlumberd.Services.Uniqueness` implements the **reservation pattern**
496+
(try / confirm / cancel).
488497

489-
- At domain-layer, a domain-service usually would enforce uniqueness. This commonly requires a round-trip to a database. So just before actual event(s) are saved in a stream, a check against uniqueness constraints should be evaluated - thus reservation is made. When the event is appended to the stream, a confirmation is done automatically (on db).
490-
491-
- At a app-layer, command-handler would typically reserve a name. And when aggregate, which is being executed by the handler, saves its events successfully, then the reservation is confirmed. If the handler fails, then the reservation is deleted. Seems simple? Under the hood, it is not that simple, because what if the process is terminated while the command-handler is executing? We need to make sure, that we can recover successfully from this situation.
498+
```
499+
1. Reserve(name, source, lease) INSERT {name, source, validUntil, confirmed=false}
500+
the UNIQUE index arbitrates; losers learn who won
501+
2. persist the aggregate to the event store
502+
3. Confirm(source) confirmed=true (permanent)
503+
on failure at 2: RollbackReservation(source)
504+
```
492505

493-
Let's see the API proposal:
506+
**The lease is the point.** If the process dies between 1 and 3 the row is left unconfirmed and expires,
507+
so the name frees itself — no distributed transaction, and a crash costs the lease duration, not the name.
494508

495-
```csharp
496-
// Let's define unique-category name
497-
record FooCategory;
509+
#### Install
498510

511+
The core package is provider-neutral; add exactly one provider package. A dialect is **required** — it
512+
supplies both the DDL and the database clock.
499513

500-
public class FooCreated
501-
// and apply it to one fo the columns.
502-
[Unique<FooCategory>]
503-
public string? Name { get; set; }
504-
505-
// other stuff
506-
}
514+
```
515+
dotnet add package MicroPlumberd.Services.Uniqueness.Postgres # recommended for a real server
516+
dotnet add package MicroPlumberd.Services.Uniqueness.Sqlite # light + serverless, shared volume
517+
dotnet add package MicroPlumberd.Services.Uniqueness.SqlServer
507518
```
508519

509-
For complex types, we need more flexibility.
520+
#### Use
510521

511522
```csharp
512-
// Let's define unique-category name, this will be mapped to columns in db
513-
// If you'd opt for domain-layer enforcment, you need to change commands to events.
514-
record BooCategory(string Name, string OtherName) : IUniqueFrom<BooCategory, BooCreated>, IUniqueFrom<BooCategory, BooRefined>
523+
// A category is just a marker type. It names its own table, and several categories may share one database.
524+
record CompanyNip;
525+
526+
services.AddUniquenessPostgres<CompanyNip>(connectionString); // table "CompanyNip"
527+
services.AddUniquenessPostgres<InvoiceNumber>(connectionString); // table "InvoiceNumber", same database
528+
```
529+
530+
```csharp
531+
public partial class CompanyCommandHandler(IPlumberInstance plumber, IUniqueNameReservation<CompanyNip> unique)
515532
{
516-
public static BooCategory From(BooCreated x) => new(x.InitialName, x.OtherName);
517-
public static BooCategory From(BooRefined x) => new(x.NewName, x.OtherName);
533+
public async Task Handle(Guid id, RegisterCompany cmd)
534+
{
535+
var agg = CompanyAggregate.Empty(id);
536+
agg.Register(cmd); // validate BEFORE reserving
537+
538+
await unique.Reserve(cmd.Nip, id); // throws UniqueNameConflictException (its Code maps to 409)
539+
try
540+
{
541+
await plumber.SaveNew(agg);
542+
}
543+
catch
544+
{
545+
await unique.RollbackReservation(id);
546+
throw;
547+
}
548+
await unique.Confirm(id); // see the warning below
549+
}
518550
}
551+
```
519552

520-
[Unique<BooCategory>]
521-
public record BooCreated(string InitialName, string OtherName);
553+
`TryReserve` is the non-throwing overload. `DeleteConfirmedNameReservation(source)` releases a confirmed
554+
name; `Confirm` releases the source's previous name automatically when it renames.
522555

523-
[Unique<BooCategory>]
524-
public record BooRefined(string NewName, string OtherName);
525-
```
556+
#### ⚠️ If `Confirm` throws, you MUST compensate
557+
558+
**The lease buys liveness, not safety.** If a caller stalls past its lease, another source may take the
559+
name — and the stalled caller's aggregate write can still land. Its `Confirm` then fails, but the aggregate
560+
is *already persisted*. That failure is your signal to compensate (reject / soft-delete). It cannot be
561+
fixed by reordering: `Reserve → Confirm → SaveNew` is strictly worse, because a crash between Confirm and
562+
SaveNew burns the name **permanently** (confirmed rows never expire).
563+
564+
Size the lease to swamp your worst-case persist time. The default is 10 minutes against a sub-second
565+
append: a stall that long means a dead process.
566+
567+
#### Choosing a provider
568+
569+
| Provider | When |
570+
|---|---|
571+
| **PostgreSQL** | Default for a real server. |
572+
| **SQLite** | Light and serverless. **Requires a shared volume, and all instances MUST be on the SAME HOST** (docker named volume / bind mount — one kernel). Verified: 16 separate processes racing one file yield exactly one winner. **Never put the file on NFS/SMB or any cross-host share** — SQLite's locking is unreliable there and the failure mode is *database corruption*, which is worse than the bug this library prevents. WAL is enabled, which also makes that boundary structural: WAL needs shared memory, so it cannot work cross-host. |
573+
| **SQL Server** | Supported. The dialect pins `COLLATE Latin1_General_100_BIN2` — SQL Server's *default* collation is case-INsensitive, which would otherwise make `"abc"` and `"ABC"` collide there but not on the other providers. |
574+
575+
#### Names are compared exactly
576+
577+
Comparison is **ordinal/binary on every provider**. There is no per-category case sensitivity: that would
578+
be per-provider collation configuration, and normalisation is domain knowledge the library must not guess
579+
(`ToLowerInvariant` and culture-aware casing disagree on the Turkish dotless i; Unicode folding has choices
580+
only you can make). **Normalise before calling** if you want case-insensitive uniqueness.
581+
582+
#### This is NOT for document numbering
583+
584+
Numbering (`FV-12222/07/2024`) is a different problem: you **mint** the value rather than validating
585+
someone else's, there is one counter per series rather than an unbounded candidate set, and the series key
586+
is a real domain entity. Model it as an **aggregate** — the id is the template *without* the counter, the
587+
sequence is state, and the versioned append gives you atomicity and gaplessness for free. No index, no
588+
lease, no second store.
589+
590+
> Rule of thumb: if you would be creating **one stream per possible value**, you are not modelling an
591+
> entity — you are building a lock, so use the index. A bounded domain entity is an aggregate.
592+
593+
#### `[Unique<T>]` / `IUniqueFrom<,>` — declarative only, NOT implemented
594+
595+
These types exist and are **inert**: nothing reads them. There is no source-generated wiring; reservation
596+
is performed by calling `IUniqueNameReservation<T>` explicitly, as above. Do not decorate an event with
597+
`[Unique<T>]` and assume anything is enforced.
526598

527599
# How-to
528600

0 commit comments

Comments
 (0)