Goodtocode.Domain
1.3.34
dotnet add package Goodtocode.Domain --version 1.3.34
NuGet\Install-Package Goodtocode.Domain -Version 1.3.34
<PackageReference Include="Goodtocode.Domain" Version="1.3.34" />
<PackageVersion Include="Goodtocode.Domain" Version="1.3.34" />
<PackageReference Include="Goodtocode.Domain" />
paket add Goodtocode.Domain --version 1.3.34
#r "nuget: Goodtocode.Domain, 1.3.34"
#:package Goodtocode.Domain@1.3.34
#addin nuget:?package=Goodtocode.Domain&version=1.3.34
#tool nuget:?package=Goodtocode.Domain&version=1.3.34
Goodtocode.Domain
Domain-Driven Design (DDD) base library for .NET Standard 2.1 and modern .NET projects.
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.
Target Frameworks
- Library:
netstandard2.1 - Tests/examples:
net10.0
Features
- Domain entity base with audit fields (
CreatedOn,ModifiedOn,DeletedOn,Timestamp) - Domain event pattern and dispatcher (
IDomainEvent,IDomainHandler,DomainDispatcher) - Equality and identity management for aggregate roots
- UUIDv7-based
RowKeyfor time-ordered, chronologically sortable storage keys - Partition key and row key support for document/table stores (
PartitionKeydefaults toId.ToString();RowKeyis always a new UUIDv7 — distinct fromId) - Secured entity base for multi-tenancy and ownership (
OwnerId,TenantId,CreatedBy,ModifiedBy,DeletedBy) - Extension methods for authorization and ownership queries
- Invariant state protection for audit and security fields (fields are only set if not already set, ensuring consistency and preventing accidental overwrites)
- Immutable versioned entities via
SecuredVersionedEntity<TModel>: all state changes produce new rows; no in-place mutation after persistence - Full versioning lifecycle:
CreateNextVersion(),CreateSuccessor(),Freeze(),MarkNotLatest() - Abstract factory pattern (
CreateNextVersionCore/CreateSuccessorCore) keeps derived classes in control of construction
Install
dotnet add package Goodtocode.Domain
Quick-Start (Repo)
- Clone this repository
git clone https://github.com/goodtocode/aspect-domain.git - Build the solution
cd src dotnet build Goodtocode.Domain.sln - Run tests
cd Goodtocode.Domain.Tests dotnet test
Core Concepts
DomainEntity<TModel>: Base entity with audit fields (CreatedOn,ModifiedOn,DeletedOn,Timestamp), identity (Id), partition key, row key, and domain event tracking.RowKeyis always a new UUIDv7 (time-ordered, RFC 4122). It is intentionally distinct fromId.PartitionKeydefaults toId.ToString()unless explicitly set. This supports portability across Cosmos DB, Table Storage, and other stores.SecuredEntity<TModel>: ExtendsDomainEntity<TModel>withOwnerId,TenantId, and audit fields for user actions (CreatedBy,ModifiedBy,DeletedBy).PartitionKeydefaults toTenantId.ToString()for multi-tenant isolation.- 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. SecuredVersionedEntity<TModel>: ExtendsSecuredEntity<TModel>and implementsIVersionable. All persisted rows are immutable — state changes always produce new rows.PartitionKeyisTenantId:CanonicalKey, grouping all versions of a logical entity in the same partition.IVersionable: Read-only state contract —CanonicalKey,Version,PreviousVersionId,IsLatest,IsPinned,IsFrozen. No mutation methods on the interface.- Domain events: Implement
IDomainEvent<TModel>and dispatch withDomainDispatcher.
Versioning Lifecycle & Invariants
SecuredVersionedEntity<TModel> enforces the following invariants:
| Rule | Detail |
|---|---|
| Rows are immutable | Once persisted, a row's fields never change (except IsLatest and IsFrozen via MarkNotLatest/Freeze) |
| 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 |
IsLatest is caller-managed |
The caller must call MarkNotLatest() on the previous row and persist both rows transactionally |
| Frozen series cannot version | CreateNextVersion() throws InvalidOperationException when IsFrozen = true |
| Successors start fresh | CreateSuccessor(newCanonicalKey) starts a new series: Version = 1, PreviousVersionId = null, same TenantId/OwnerId |
| Successors are always allowed | CreateSuccessor() is permitted even on a frozen series |
| Derived classes own construction | CreateNextVersionCore() and CreateSuccessorCore() are abstract — the concrete class supplies the new instance |
Key Examples
1. Basic Domain Entity with Audit Fields
using Goodtocode.Domain.Entities;
public sealed class MyEntity : DomainEntity<MyEntity>
{
public string Name { get; private set; } = string.Empty;
public int Value { get; private set; }
private MyEntity() { }
public MyEntity(Guid id, string name, int value) : base(id)
{
Name = name;
Value = value;
}
}
// Example: Customizing PartitionKey and RowKey
public sealed class TableEntity : DomainEntity<TableEntity>
{
public TableEntity(Guid id, string partitionKey, string rowKey) : base(id, partitionKey, rowKey) { }
}
2. Secured Entity with Multi-Tenant Ownership
using Goodtocode.Domain.Entities;
public sealed class Document : SecuredEntity<Document>
{
public string Title { get; private set; } = string.Empty;
private Document() { }
public Document(Guid id, Guid ownerId, Guid tenantId, string title) : base(id, ownerId, tenantId)
{
Title = title;
}
}
// Query helpers
var ownedDocuments = queryableDocuments.WhereOwner(ownerId);
var tenantDocuments = queryableDocuments.WhereTenant(tenantId);
var authorized = queryableDocuments.WhereAuthorized(tenantId, ownerId);
3. Domain Events + Dispatcher
using Goodtocode.Domain.Entities;
using Goodtocode.Domain.Events;
public sealed class Person : SecuredEntity<Person>
{
public string Email { get; private set; } = string.Empty;
public Person(Guid id, Guid ownerId, Guid tenantId, string email)
: base(id, ownerId, tenantId)
{
Email = email;
AddDomainEvent(new PersonCreatedEvent(this));
}
}
public sealed class PersonCreatedEvent : IDomainEvent<Person>
{
public Person Item { get; }
public DateTime OccurredOn { get; }
public PersonCreatedEvent(Person person)
{
Item = person;
OccurredOn = DateTime.UtcNow;
}
}
public sealed class PersonCreatedHandler : IDomainHandler<PersonCreatedEvent>
{
public Task HandleAsync(PersonCreatedEvent domainEvent)
{
Console.WriteLine($"Created: {domainEvent.Item.Email}");
return Task.CompletedTask;
}
}
// Dispatcher usage (with your DI container)
var serviceProvider = new ServiceCollection();
serviceProvider.AddTransient<IDomainHandler<PersonCreatedEvent>, PersonCreatedHandler>();
serviceProvider.BuildServiceProvider();
var dispatcher = new DomainDispatcher(serviceProvider);
await dispatcher.DispatchAsync(person.DomainEvents);
person.ClearDomainEvents();
4. Versioned Entity Lifecycle (Immutable Pattern)
SecuredVersionedEntity<TModel> uses the Template Method pattern. Your derived class provides CreateNextVersionCore() and CreateSuccessorCore(); the base class enforces all invariants.
using Goodtocode.Domain.Entities;
public sealed class Invoice : SecuredVersionedEntity<Invoice>
{
public decimal Amount { get; private set; }
public string Status { get; private set; } = string.Empty;
// Required by ORM / serialization
private Invoice() { }
public Invoice(
Guid id, string canonicalKey, Guid ownerId, Guid tenantId, Guid createdBy,
DateTime createdOn, DateTimeOffset timestamp,
int version, Guid? previousVersionId, bool isLatest, bool isPinned, bool isFrozen,
decimal amount, string status)
: base(id, canonicalKey, null, ownerId, tenantId, createdBy,
createdOn, timestamp, version, previousVersionId, isLatest, isPinned, isFrozen)
{
Amount = amount;
Status = status;
}
protected override Invoice CreateNextVersionCore() =>
new(Guid.NewGuid(), CanonicalKey, OwnerId, TenantId, CreatedBy,
DateTime.UtcNow, DateTimeOffset.UtcNow,
Version + 1, Id, isLatest: true, isPinned: false, isFrozen: false,
Amount, Status);
protected override Invoice CreateSuccessorCore(string newCanonicalKey) =>
new(Guid.NewGuid(), newCanonicalKey, OwnerId, TenantId, CreatedBy,
DateTime.UtcNow, DateTimeOffset.UtcNow,
1, null, isLatest: true, isPinned: false, isFrozen: false,
Amount, Status);
}
// --- Create version 1 ---
var v1 = new Invoice(Guid.NewGuid(), "INV-2026-001", ownerId, tenantId, createdBy,
DateTime.UtcNow, DateTimeOffset.UtcNow,
1, null, isLatest: true, isPinned: false, isFrozen: false,
amount: 100m, status: "Draft");
// --- Produce version 2 (new row, v1 stays unchanged) ---
var v2 = v1.CreateNextVersion();
// Transactionally: persist v2, then mark v1 as no longer latest
v1.MarkNotLatest();
// persist both: v1 (IsLatest=false) and v2 (IsLatest=true)
// --- Freeze the series (no more versions allowed) ---
v2.Freeze();
// --- Successor starts a new canonical key series ---
var successor = v2.CreateSuccessor("INV-2027-001");
// successor: Version=1, PreviousVersionId=null, CanonicalKey="INV-2027-001"
v2.MarkNotLatest();
// persist both: v2 (IsLatest=false) and successor (IsLatest=true)
Key invariants to remember:
RowKeyon every row is a new UUIDv7 — never equal toIdPartitionKey=TenantId:CanonicalKey— all versions of the same entity land in the same partitionIsLatestflip is your responsibility — callMarkNotLatest()on the outgoing row inside your transactionCreateNextVersion()throws ifIsFrozen = trueCreateSuccessor()always succeeds, even on a frozen series
Integrating with EF Core: Audit & Security Field Automation
To ensure audit and security fields are set correctly and invariant state is protected, you must wire up your DbContext to set these fields during the entity lifecycle.
Example:
public class ExampleDbContext : DbContext
{
private readonly ICurrentUserContext _currentUserContext;
public ExampleDbContext(DbContextOptions options, ICurrentUserContext currentUserContext)
: base(options)
{
_currentUserContext = currentUserContext;
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
SetAuditFields();
SetSecurityFields();
return base.SaveChangesAsync(cancellationToken);
}
private void SetAuditFields()
{
foreach (var entry in ChangeTracker.Entries())
{
if (entry.Entity is IAuditable auditable)
{
if (entry.State == EntityState.Modified)
{
auditable.MarkModified();
}
else if (entry.State == EntityState.Deleted)
{
auditable.MarkDeleted();
entry.State = EntityState.Modified;
}
}
}
}
private void SetSecurityFields()
{
if (_currentUserContext is null) return;
foreach (var entry in ChangeTracker.Entries())
{
if (entry.Entity is ISecurable securable)
{
if (entry.State == EntityState.Added)
{
if (securable.OwnerId == Guid.Empty)
securable.ChangeOwner(_currentUserContext.OwnerId);
if (securable.TenantId == Guid.Empty)
securable.ChangeTenant(_currentUserContext.TenantId);
}
}
}
}
}
Note:
- This pattern should be implemented in your infrastructure layer (not in the domain library).
- See
Goodtocode.Domain.Tests/Examples/ExampleDbContext.csfor a working reference
Complete Examples
See the fully working examples in the test project:
Goodtocode.Domain.Tests/Examples/RowLevelSecurityExample.cs(row-level security, audit fields, and partition/row key usage)Goodtocode.Domain.Tests/Examples/CommandHandlerWithEventsExample.cs(command handlers, domain events, dispatcher, and service bus integration)Goodtocode.Domain.Tests/Examples/ExampleDbContext.cs(EF Core integration for audit and security fields)
Technologies
Version History
| Version | Date | Release Notes |
|---|---|---|
| 1.0.0 | 2026-Jan-19 | Initial release |
| 1.2.0 | 2026-Mar-14 | Added rowKey support |
| 1.3.0 | 2026-Mar-18 | Versioning, pinning, freezing |
| 1.4.0 | 2026-Apr-05 | Versioning lifecycle and invariants |
| 1.5.0 | 2026-Apr-19 | Immutable versioning: UUID7 RowKey, CanonicalKey, SecuredVersionedEntity lifecycle (CreateNextVersion, CreateSuccessor, Freeze, MarkNotLatest), abstract factory pattern, IVersionable read-only contract |
License
This project is licensed with the MIT license.
Contact
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.1 is compatible. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.1
- No dependencies.
-
net10.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.3.34 | 77 | 4/6/2026 |
| 1.3.32 | 91 | 3/18/2026 |
| 1.2.30 | 110 | 3/14/2026 |
| 1.2.28 | 112 | 3/14/2026 |
| 1.2.26 | 110 | 3/14/2026 |
| 1.1.24 | 111 | 2/14/2026 |
| 1.1.22 | 97 | 2/14/2026 |
| 1.1.20 | 95 | 2/14/2026 |
| 1.1.13 | 94 | 2/10/2026 |
| 1.1.11 | 94 | 2/10/2026 |
| 1.1.8 | 107 | 2/8/2026 |
| 1.1.6 | 262 | 1/21/2026 |
| 1.1.4 | 90 | 1/23/2026 |
| 1.1.3 | 98 | 1/20/2026 |