Skip to content

Commit 52f3f4c

Browse files
committed
Lifecycle and invariant protection
1 parent 5953bef commit 52f3f4c

12 files changed

Lines changed: 544 additions & 302 deletions

File tree

README.md

Lines changed: 100 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Domain-Driven Design (DDD) base library for .NET Standard 2.1 and modern .NET pr
44

55
[![NuGet CI/CD](https://github.com/goodtocode/aspect-domain/actions/workflows/gtc-domain-nuget.yml/badge.svg)](https://github.com/goodtocode/aspect-domain/actions/workflows/gtc-domain-nuget.yml)
66

7-
Goodtocode.Domain provides foundational types for building DDD, clean architecture, and event-driven systems. It includes base classes for domain entities, audit fields, domain events, and secured/multi-tenant entities. The library is lightweight, dependency-free, and designed to work with EF Core, Cosmos DB, Table Storage, or custom repositories.
7+
Goodtocode.Domain provides foundational types for building DDD, clean architecture, and event-driven systems. It includes base classes for domain entities, audit fields, domain events, secured/multi-tenant entities, and immutable versioned entities with full lifecycle management. The library is lightweight, dependency-free, and designed to work with EF Core, Cosmos DB, Table Storage, or custom repositories.
88

99
## Target Frameworks
1010
- Library: `netstandard2.1`
@@ -14,10 +14,14 @@ Goodtocode.Domain provides foundational types for building DDD, clean architectu
1414
- Domain entity base with audit fields (`CreatedOn`, `ModifiedOn`, `DeletedOn`, `Timestamp`)
1515
- Domain event pattern and dispatcher (`IDomainEvent`, `IDomainHandler`, `DomainDispatcher`)
1616
- Equality and identity management for aggregate roots
17-
- Partition key and row key support for document/table stores (`PartitionKey` and `RowKey` both default to `Id.ToString()` unless specified)
17+
- UUIDv7-based `RowKey` for time-ordered, chronologically sortable storage keys
18+
- Partition key and row key support for document/table stores (`PartitionKey` defaults to `Id.ToString()`; `RowKey` is always a new UUIDv7 — distinct from `Id`)
1819
- Secured entity base for multi-tenancy and ownership (`OwnerId`, `TenantId`, `CreatedBy`, `ModifiedBy`, `DeletedBy`)
1920
- Extension methods for authorization and ownership queries
2021
- **Invariant state protection** for audit and security fields (fields are only set if not already set, ensuring consistency and preventing accidental overwrites)
22+
- **Immutable versioned entities** via `SecuredVersionedEntity<TModel>`: all state changes produce new rows; no in-place mutation after persistence
23+
- Full versioning lifecycle: `CreateNextVersion()`, `CreateSuccessor()`, `Freeze()`, `MarkNotLatest()`
24+
- Abstract factory pattern (`CreateNextVersionCore` / `CreateSuccessorCore`) keeps derived classes in control of construction
2125

2226
## Install
2327
```bash
@@ -42,11 +46,27 @@ dotnet add package Goodtocode.Domain
4246

4347
## Core Concepts
4448
- `DomainEntity<TModel>`: Base entity with audit fields (`CreatedOn`, `ModifiedOn`, `DeletedOn`, `Timestamp`), identity (`Id`), partition key, row key, and domain event tracking.
45-
- `PartitionKey` and `RowKey` both default to `Id.ToString()` unless explicitly set. This supports portability across Cosmos DB, Table Storage, and other stores. If you do not specify a value, both will be the same by default.
49+
- `RowKey` is always a new **UUIDv7** (time-ordered, RFC 4122). It is intentionally distinct from `Id`. `PartitionKey` defaults to `Id.ToString()` unless explicitly set. This supports portability across Cosmos DB, Table Storage, and other stores.
4650
- `SecuredEntity<TModel>`: Extends `DomainEntity<TModel>` with `OwnerId`, `TenantId`, and audit fields for user actions (`CreatedBy`, `ModifiedBy`, `DeletedBy`). `PartitionKey` defaults to `TenantId.ToString()` for multi-tenant isolation.
4751
- **Invariant state protection**: Methods like `MarkCreated`, `MarkDeleted`, etc. only set fields if not already set, ensuring entity state is consistent and protected from accidental changes.
52+
- `SecuredVersionedEntity<TModel>`: Extends `SecuredEntity<TModel>` and implements `IVersionable`. All persisted rows are **immutable** — state changes always produce new rows. `PartitionKey` is `TenantId:CanonicalKey`, grouping all versions of a logical entity in the same partition.
53+
- `IVersionable`: Read-only state contract — `CanonicalKey`, `Version`, `PreviousVersionId`, `IsLatest`, `IsPinned`, `IsFrozen`. No mutation methods on the interface.
4854
- Domain events: Implement `IDomainEvent<TModel>` and dispatch with `DomainDispatcher`.
4955

56+
## Versioning Lifecycle & Invariants
57+
58+
`SecuredVersionedEntity<TModel>` enforces the following invariants:
59+
60+
| Rule | Detail |
61+
|------|--------|
62+
| Rows are immutable | Once persisted, a row's fields never change (except `IsLatest` and `IsFrozen` via `MarkNotLatest`/`Freeze`) |
63+
| New version = new row | `CreateNextVersion()` returns a new row with a new `Id`, new UUIDv7 `RowKey`, incremented `Version`, and `PreviousVersionId` pointing to the current row |
64+
| `IsLatest` is caller-managed | The caller must call `MarkNotLatest()` on the previous row and persist both rows **transactionally** |
65+
| Frozen series cannot version | `CreateNextVersion()` throws `InvalidOperationException` when `IsFrozen = true` |
66+
| Successors start fresh | `CreateSuccessor(newCanonicalKey)` starts a new series: `Version = 1`, `PreviousVersionId = null`, same `TenantId`/`OwnerId` |
67+
| Successors are always allowed | `CreateSuccessor()` is permitted even on a frozen series |
68+
| Derived classes own construction | `CreateNextVersionCore()` and `CreateSuccessorCore()` are `abstract` — the concrete class supplies the new instance |
69+
5070
## Key Examples
5171

5272
### 1. Basic Domain Entity with Audit Fields
@@ -146,6 +166,76 @@ await dispatcher.DispatchAsync(person.DomainEvents);
146166
person.ClearDomainEvents();
147167
```
148168

169+
### 4. Versioned Entity Lifecycle (Immutable Pattern)
170+
171+
`SecuredVersionedEntity<TModel>` uses the **Template Method** pattern. Your derived class provides `CreateNextVersionCore()` and `CreateSuccessorCore()`; the base class enforces all invariants.
172+
173+
```csharp
174+
using Goodtocode.Domain.Entities;
175+
176+
public sealed class Invoice : SecuredVersionedEntity<Invoice>
177+
{
178+
public decimal Amount { get; private set; }
179+
public string Status { get; private set; } = string.Empty;
180+
181+
// Required by ORM / serialization
182+
private Invoice() { }
183+
184+
public Invoice(
185+
Guid id, string canonicalKey, Guid ownerId, Guid tenantId, Guid createdBy,
186+
DateTime createdOn, DateTimeOffset timestamp,
187+
int version, Guid? previousVersionId, bool isLatest, bool isPinned, bool isFrozen,
188+
decimal amount, string status)
189+
: base(id, canonicalKey, null, ownerId, tenantId, createdBy,
190+
createdOn, timestamp, version, previousVersionId, isLatest, isPinned, isFrozen)
191+
{
192+
Amount = amount;
193+
Status = status;
194+
}
195+
196+
protected override Invoice CreateNextVersionCore() =>
197+
new(Guid.NewGuid(), CanonicalKey, OwnerId, TenantId, CreatedBy,
198+
DateTime.UtcNow, DateTimeOffset.UtcNow,
199+
Version + 1, Id, isLatest: true, isPinned: false, isFrozen: false,
200+
Amount, Status);
201+
202+
protected override Invoice CreateSuccessorCore(string newCanonicalKey) =>
203+
new(Guid.NewGuid(), newCanonicalKey, OwnerId, TenantId, CreatedBy,
204+
DateTime.UtcNow, DateTimeOffset.UtcNow,
205+
1, null, isLatest: true, isPinned: false, isFrozen: false,
206+
Amount, Status);
207+
}
208+
```
209+
210+
```csharp
211+
// --- Create version 1 ---
212+
var v1 = new Invoice(Guid.NewGuid(), "INV-2026-001", ownerId, tenantId, createdBy,
213+
DateTime.UtcNow, DateTimeOffset.UtcNow,
214+
1, null, isLatest: true, isPinned: false, isFrozen: false,
215+
amount: 100m, status: "Draft");
216+
217+
// --- Produce version 2 (new row, v1 stays unchanged) ---
218+
var v2 = v1.CreateNextVersion();
219+
// Transactionally: persist v2, then mark v1 as no longer latest
220+
v1.MarkNotLatest();
221+
// persist both: v1 (IsLatest=false) and v2 (IsLatest=true)
222+
223+
// --- Freeze the series (no more versions allowed) ---
224+
v2.Freeze();
225+
226+
// --- Successor starts a new canonical key series ---
227+
var successor = v2.CreateSuccessor("INV-2027-001");
228+
// successor: Version=1, PreviousVersionId=null, CanonicalKey="INV-2027-001"
229+
v2.MarkNotLatest();
230+
// persist both: v2 (IsLatest=false) and successor (IsLatest=true)
231+
```
232+
233+
**Key invariants to remember:**
234+
- `RowKey` on every row is a new UUIDv7 — never equal to `Id`
235+
- `PartitionKey` = `TenantId:CanonicalKey` — all versions of the same entity land in the same partition
236+
- `IsLatest` flip is **your responsibility** — call `MarkNotLatest()` on the outgoing row inside your transaction
237+
- `CreateNextVersion()` throws if `IsFrozen = true`
238+
- `CreateSuccessor()` always succeeds, even on a frozen series
149239

150240
---
151241

@@ -230,11 +320,13 @@ See the fully working examples in the test project:
230320

231321
## Version History
232322

233-
| Version | Date | Release Notes |
234-
|---------|-------------|-------------------------------|
235-
| 1.0.0 | 2026-Jan-19 | Initial release |
236-
| 1.2.0 | 2026-Mar-14 | Added rowKey support |
237-
| 1.3.0 | 2026-Mar-18 | Versioning, pinning, freezing |
323+
| Version | Date | Release Notes |
324+
|---------|-------------|------------------------------------------------------|
325+
| 1.0.0 | 2026-Jan-19 | Initial release |
326+
| 1.2.0 | 2026-Mar-14 | Added rowKey support |
327+
| 1.3.0 | 2026-Mar-18 | Versioning, pinning, freezing |
328+
| 1.4.0 | 2026-Apr-05 | Versioning lifecycle and invariants |
329+
| 1.5.0 | 2026-Apr-19 | Immutable versioning: UUID7 RowKey, CanonicalKey, SecuredVersionedEntity lifecycle (CreateNextVersion, CreateSuccessor, Freeze, MarkNotLatest), abstract factory pattern, IVersionable read-only contract |
238330

239331
## License
240332

src/Goodtocode.Domain.Tests/Entities/DomainEntityConstructorTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ public void ConstructorWithIdSetsId()
2929
Assert.IsNotNull(entity);
3030
Assert.AreEqual(id, entity.Id);
3131
Assert.AreEqual(id.ToString(), entity.PartitionKey);
32-
Assert.AreEqual(id.ToString(), entity.RowKey);
32+
Assert.IsTrue(Guid.TryParse(entity.RowKey, out _), "RowKey should be a valid UUID");
33+
Assert.AreNotEqual(id.ToString(), entity.RowKey, "RowKey should be UUID7, not the entity Id");
3334
Assert.AreEqual(createdOn, entity.CreatedOn);
3435
Assert.AreEqual(timestamp, entity.Timestamp);
3536
}

0 commit comments

Comments
 (0)