Sunlix.NET.DDD.BaseTypes
1.0.1
Prefix Reserved
See the version list below for details.
dotnet add package Sunlix.NET.DDD.BaseTypes --version 1.0.1
NuGet\Install-Package Sunlix.NET.DDD.BaseTypes -Version 1.0.1
<PackageReference Include="Sunlix.NET.DDD.BaseTypes" Version="1.0.1" />
<PackageVersion Include="Sunlix.NET.DDD.BaseTypes" Version="1.0.1" />
<PackageReference Include="Sunlix.NET.DDD.BaseTypes" />
paket add Sunlix.NET.DDD.BaseTypes --version 1.0.1
#r "nuget: Sunlix.NET.DDD.BaseTypes, 1.0.1"
#:package Sunlix.NET.DDD.BaseTypes@1.0.1
#addin nuget:?package=Sunlix.NET.DDD.BaseTypes&version=1.0.1
#tool nuget:?package=Sunlix.NET.DDD.BaseTypes&version=1.0.1
Sunlix.NET.DDD.BaseTypes
Sunlix.NET.DDD.BaseTypes is a lightweight and extensible library designed for building robust domain models in C#. It provides a clean foundation for implementing Domain-Driven Design (DDD) patterns by introducing essential base types: Entity, ValueObject, Enumeration, and Error. These primitives help developers write more expressive, consistent, and maintainable domain logic while reducing boilerplate code. The library is framework-agnostic, making it suitable for use in microservices, monoliths, and modular applications.
Table of contents
- Overview
- Usage
- Implement an entity with DB-generated Id
- Implement an entity with app-assigned Id
- Implement an entity with strongly typed Id
- Transience check
- Comparing entities
- Overriding
Entity<TId>.UnproxiedType(optional) - Implement a value object
- Fail-fast validation & normalization
- Using value objects in collections (hashing)
- Overriding
ValueObject.UnproxiedType(optional) - Implement an enumeration
- Enumeration duplicate detection
- Adding behaviour to an enumeration
- Implement an error
- Installation
- License
- Contributing
Overview
Entity<TId>
An Entity<T> is a domain object defined primarily by its identity, not by its attributes. It represents a distinct, trackable thing in the domain— e.g., an Order, Customer, or Account — whose history and continuity over time matter.
General concepts:
- Domain role: Entities model real domain concepts that must be individually tracked, constrained by invariants, and often participate in aggregates and transactions.
- Identity: An identifier that uniquely distinguishes one instance from all others and enables references between objects.
- Lifespan: The entity persists across multiple operations and can change over time (creation → updates → archival/deletion).
- Mutability: Unlike value objects, entities are mutable — their attributes can change while the identity remains the same.
- Equality: Whether two entity instances are “the same” depends on context. Two objects can refer to the same business entity yet carry different snapshots of state (staleness, out-of-date caches). We don’t override
EqualsonEntity<TId>. Instead we provide a domain serviceEntity<TId>.IdEqualityComparerwhich compares entities byUnproxiedTypeandId. Use it when you explicitly mean “same conceptual entity + same identifier” (e.g., for reconciliation of entities).
Rule of thumb: use an Entity<T> when the business cares about which specific thing it is over time (its continuity and audit trail), not just what values it holds at a moment.
ValueObject
A ValueObject models a descriptive aspect of the domain rather than a distinct, trackable thing. Typical examples include money amounts, dates and ranges, measurements, addresses, and email addresses.
General concepts:
- Domain role: A value object captures a concept defined entirely by its attributes (amount + currency, start + end date, latitude + longitude, etc.). It can include behavior that depends only on those attributes (e.g., normalization, arithmetic, comparison, validation), but not operations that depend on identity or lifecycle.
- Identity: Value objects have no identity. Two instances with the same values are interchangeable. You don’t “look them up by ID” or track them over time.
- Lifespan: Value objects are ephemeral. They’re created where needed, passed around, and discarded. The domain doesn’t care about their history, only about the value they represent at a given moment.
- Mutability: Value objects should be immutable. Any change produces a new instance (e.g., money.Add(tax) returns a new Money). Immutability simplifies reasoning, enables safe sharing, and avoids unintended side effects.
- Equality: Equality is structural — two value objects are equal if and only if they are of the same conceptual type (see
UnproxiedType) and all of their significant components are equal (seeGetEqualityComponents). Hash codes are derived from the same components so equal values behave correctly in sets and dictionaries. - Validation & normalization: Value objects should be valid at creation (fail fast). They commonly normalize internal state (e.g., upper-case currency codes, trimmed strings) and enforce invariants (ranges, formats).
- Composition: Value objects can be composed of other value objects (e.g., Address composed of Street, City, PostalCode). Treat them as atomic inside aggregates.
Rule of thumb: Use a ValueObject when the business cares about what the value is, not which specific instance it is. If you need to reference it across the model, track its lifecycle, or audit its history, that’s a sign you need an entity instead.
Enumeration<T>
An Enumeration<T> models a closed set of named constants with domain meaning and optional behavior—richer than a plain enum. Typical examples: OrderStatus, DocumentState.
General concepts:
- Domain role: A fixed set of named values meaningful to the domain. Each
Enumeration<T>is a first-class object that can expose behavior in addition to data. - Lifespan: Enumerations are declared as
public static readonlyfields on the derived type. They behave like singletons for the lifetime of the process; you don’t create them dynamically in normal code. - Mutability:
Enumeration<T>instances are immutable.ValueandNameare set in the constructor and never change. - Value and Name: Each
Enumeration<T>has an integerValue(the identifier) and a stringName(the human-readable label). - Equality: Two
Enumeration<T>instances are equal when they’re of the same conceptual type (seeUnproxiedType) and have the sameValue.
Rule of thumb: Choose an Enumeration<T> when you need a closed, named set with optional behavior and invariants, more expressive than a basic enum.
Error
An Error is a lightweight ValueObject that carries an error code and a human-readable message for domain/application failures.
General concepts:
- Domain role: Represents failures in a structured form (validation errors, business rule violations). Useful for returning from domain services or mapping to API/problem-details.
- Mutability:
Errorinstances are immutable.CodeandMessageare set in the constructor and never change. - Equality: Two
Errorinstances are equal when they’re of the same conceptual type (seeUnproxiedType) and have the sameValue.
Rule of thumb: Use Error when you need a consistent error payload (store/log by Code, Message, and keep equality stable across layers).
Usage
This section contains small, self-contained examples that demonstrate how to use the types from Sunlix.NET.DDD.BaseTypes. The sample domain classes are deliberately simplified: they do not model a real domain, are not related to each other, and exist solely to illustrate the API surface. For clarity, the snippets omit nonessential infrastructure (e.g., error handling, logging, full EF Core setup) unless explicitly relevant. The examples below use Entity Framework Core as the ORM.
Implement an entity with DB-generated Id
Use this approach when the database assigns the primary key (IDENTITY/SEQUENCE). Create the entity without an Id (parameterless constructor), let EF Core persist it, and the Id will be populated on SaveChanges(). Until then the entity is transient (Id == default). Configure EF with ValueGeneratedOnAdd() to indicate the Id is database-generated.
public sealed class Book : Entity<int>
{
public string Title { get; private set; }
// Parameterless constructor for ORM
private Book() { }
public Book(string title)
{
if (string.IsNullOrEmpty(title))
throw new ArgumentOutOfRangeException(nameof(title));
Title = title;
}
}
// EF Core DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Book>(bookBuilder =>
{
bookBuilder.ToTable("Books").HasKey(b => b.Id);
bookBuilder.Property(b => b.Id)
.ValueGeneratedOnAdd();
/*.UseIdentityColumn(); // use the IDENTITY feature to generate entity Id*/
});
}
// Usage
var book = new Book("Domain-Driven Design"); // Id == default (transient entity)
context.Books.Add(book);
await context.SaveChangesAsync(); // Id populated by EF Core
Implement an entity with app-assigned Id
Use this approach when your application (or an upstream system) provides the identifier (e.g., Guid, ULID, Snowflake, or a typed ID value object). Construct the entity with the Id. Configure EF with ValueGeneratedNever(). The entity is non-transient immediately, so Id-based comparisons via IdEqualityComparer are safe before persistence.
public sealed class User : Entity<Guid>
{
// Parameterless constructor for ORM
private User() { }
public User(Guid id) : base(id) { }
}
// EF Core DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>(userBuilder =>
{
bookBuilder.ToTable("Users").HasKey(b => b.Id);
bookBuilder.Property(b => b.Id)
.ValueGeneratedNever();
});
}
// Usage
var user = new User(Guid.NewGuid());
context.Users.Add(user);
await context.SaveChangesAsync(); // Id is assigned by application
Implement an entity with strongly typed Id
Using strongly typed Ids prevents accidental mix-ups (e.g., passing a CustomerId where an OrderId is expected), makes APIs self-documenting, allows validation in one place, and still maps cleanly in EF Core via a value converter.
public readonly record struct PersonId(Guid Value)
{
public static PersonId Empty { get; } = default;
public static PersonId CreateNew() => new(Guid.NewGuid());
}
public sealed class Person
{
// Strongly typed Id
public PersonId Id { get; private set; } = PersonId.Empty;
public string FirstName { get; private set; } = string.Empty;
public string LastName { get; private set; } = string.Empty;
// Parameterless constructor for ORM
private Person() { }
public static Person CreateNew(string firstName, string lastName) => new()
{
FirstName = firstName,
LastName = lastName,
Id = PersonId.CreateNew()
};
}
// EF Core DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Person>(personBuilder =>
{
personBuilder.ToTable("People").HasKey(p => p.Id);
personBuilder.Property(p => p.Id)
.HasConversion(id => id.Value, value => new PersonId(value));
});
}
// Usage
var person = Person.CreateNew("John", "Smith");
context.People.Add(person);
await context.SaveChangesAsync();
Transience check
IsTransient() tells you whether the entity has a default ID.
if (order.IsTransient()) { /* not persisted yet */ }
Comparing entities
Entity equality is context-dependent, so the base type does not override Equals. When you explicitly mean same entity by Id, use the provided comparer:
var set = new HashSet<Entity<Guid>>(Entity<Guid>.IdEqualityComparer);
var entityFromEfProxy = /* entity loaded via EF (proxy) */;
var entityFromRepository = /* same entity loaded elsewhere */;
set.Add(fromEfProxy);
bool same = set.Contains(fromRepository); // true if UnproxiedType matches and Ids are equal
You can use the comparer in dictionaries, sets, etc.
var dict = new Dictionary<Entity<Guid>, string>(Entity<Guid>.IdEqualityComparer);
Overriding Entity<TId>.UnproxiedType (optional)
Override UnproxiedType to compare a hierarchy as one conceptual type (e.g., treat all Payment subclasses as the same concept). Ensure Ids are unique across the hierarchy before unifying types like this.
public abstract class Payment : Entity<Guid>
{
// Treat all payments as the same conceptual type in Id-based comparisons
protected override Type UnproxiedType => typeof(Payment);
}
public sealed class CardPayment : Payment { }
public sealed class BankTransfer : Payment { }
Proxy note (EF Core & NHibernate).
ORMs create lazy-loading proxies for loaded objects. The entity base class default implementation UnproxiedType => GetType() will return the proxy type, not the domain type in this scenario. Since IdEqualityComparer compares entities by UnproxiedType + Id, a proxy and a non-proxy instance of the same entity may compare as different if you keep the default. You can override UnproxiedType to overcome this issue.
To avoid this, override UnproxiedType in the derived class to return the real domain type:
public sealed class Order : Entity<int>
{
protected override Type UnproxiedType => typeof(Order);
}
Implement a value object
Define the value’s data and return its significant parts from GetEqualityComponents().
Equality is structural: a.Equals(b) returns true only if both conditions hold:
a.UnproxiedType == b.UnproxiedTypea.GetEqualityComponents()andb.GetEqualityComponents()are sequence-equal — same length, same order, and pairwise-equal components.
public sealed class Money : ValueObject
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0)
throw new ArgumentOutOfRangeException(nameof(amount));
if (string.IsNullOrWhiteSpace(currency))
throw new ArgumentException("Required.", nameof(currency));
Amount = amount;
Currency = currency.ToUpperInvariant(); // normalize
}
// Return components used for equality check (order matters)
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Amount;
yield return Currency;
}
}
// Structural equality
var money1 = new Money(10m, "usd");
var money2 = new Money(10m, "USD");
Console.WriteLine(money1 == money2); // true
Fail-fast validation & normalization
Put invariants and normalization in the constructor so every instance is valid by design.
Examples: trimming strings, upper-casing codes, range checks, format checks.
public sealed class Email : ValueObject
{
public string Value { get; }
public Email(string value)
{
if (!IsValidEmail(value))
throw new ArgumentException("Email is invalid.", nameof(value));
Value = value.Trim();
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Value;
}
}
Using value objects in collections (hashing)
GetHashCode() is derived from the same components as equality (plus the unproxied type). The hash is cached for performance.
var set = new HashSet<ValueObject> { new Money(10, "USD") };
set.Contains(new Money(10, "USD")); // true
Overriding ValueObject.UnproxiedType (optional)
ORMs create lazy-loading proxies for loaded objects. The value object base class default implementation UnproxiedType => GetType() will return the proxy type, not the domain type in this scenario. Since ValuObject compares objects by UnproxiedType + equality components, a proxy and a non-proxy instance of the same value object may compare as different if you keep the default. You can override UnproxiedType to overcome this issue.
public class Money : ValueObject
{
// as above…
protected override Type UnproxiedType => typeof(Money);
}
Implement an enumeration
Enumeration<T> is a ValueObject representing a "smart enum". Declare enumerations as static readonly properties. Enumerations are compared by value. Enumeration value should be non-negative integer and enumeration name should not be null or whitespace.
public sealed class OrderStatus : Enumeration<OrderStatus>
{
public static readonly OrderStatus Pending = new(0, "Pending");
public static readonly OrderStatus Paid = new(1, "Paid");
private OrderStatus(int value, string name) : base(value, name) { }
}
// Get all enumerations
var enumerations = OrderStatus.GetAll();
// Throwing lookups
var paid = OrderStatus.FromName("Failed"); // Invalid name → InvalidOperationException
var shipped = OrderStatus.FromValue(4); // Unknown value → InvalidOperationException
// Safe lookups
if (OrderStatus.TryGetFromName("paid", out var st1)) { /* false, st1 == null */ }
if (OrderStatus.TryGetFromValue(3, out var st2)) { /* true, st2 == Cancelled */ }
Notes:
- Duplicates: declaring two fields with the same
ValueorNamethrows exception at first access. - Name lookups: exact match only; normalize at call site if needed.
- Proxies: equality uses
UnproxiedType, so if an ORM introduces lazy-loading proxies, overrideUnproxiedType(this was previously explained for value object).
Enumeration duplicate detection
Enumeration values are declared as public static readonly fields on the derived type. The library discovers them on first use (lazy) via reflection, validates uniqueness, and then caches them:
- On the first call to any API (
GetAll,FromValue,FromName,Exists,TryGet*, etc.), the typeTis scanned for its public static fields of typeT. - The discovered instances are validated: no duplicate
Valueand no duplicateNameare allowed. - If duplicates are found, an
InvalidOperationExceptionis thrown at that first access (not at class load time), with a message pointing to the offendingValue/Name. - After a successful first load, the results are cached (in-memory dictionaries for
Value→TandName→T) so all subsequent lookups are close to O(1).
public sealed class OrderStatusWithDuplicateValue : Enumeration<OrderStatus>
{
public static readonly OrderStatus Pending = new(1, "Pending");
public static readonly OrderStatus Paid = new(1, "Paid");
private OrderStatus(int value, string name) : base(value, name) { }
}
// Lookups throw InvalidOperationException
var paid = OrderStatus.FromName("Paid");
var shipped = OrderStatus.FromValue(1);
// Safe lookups throw InvalidOperationException
OrderStatus.TryGetFromName("paid", out _)
OrderStatus.TryGetFromValue(3, out _)
Adding behaviour to an enumeration
You can keep small, value-specific behavior within the enumeration, avoiding scattered switch statements and making the domain intent explicit.
Example: tax calculation per category
public sealed class TaxCategory : Enumeration<TaxCategory>
{
public decimal Rate { get; } // e.g., 0.20m = 20%
private TaxCategory(int value, string name, decimal rate)
: base(value, name) => Rate = rate;
public decimal ApplyTax(decimal net) => Math.Round(net * (1 + Rate), 2);
public static readonly TaxCategory Standard = new(0, "Standard", 0.20m);
public static readonly TaxCategory Reduced = new(1, "Reduced", 0.10m);
public static readonly TaxCategory Zero = new(2, "Zero", 0.00m);
}
// Usage
var gross = TaxCategory.Reduced.ApplyTax(100m); // 110.00
Implement an error
An Error is a lightweight ValueObject for representing domain/application failures with an error code and a human-readable message. Use it to pass, compare, log, or serialize failures.
Why not exceptions?
Exceptions are for unexpected, exceptional conditions and control flow should not rely on them. Domain errors are expected (e.g., validation failures, rule violations) and should be returned/handled explicitly. Reserve exceptions for programmer mistakes, infrastructure faults, or truly exceptional states.
public readonly record struct Result<T>(T? Value, Error? Error)
{
public bool IsSuccess => Error is null;
public static Result<T> Ok(T value) => new(value, null);
public static Result<T> Fail(Error e) => new(default, e);
}
public static class Errors
{
public static readonly Error InvalidEmail
=> new("user.invalid_email", "Email format is invalid.");
public static Error NotFound(string entity, object id)
=> new("common.not_found", $"{entity} with id '{id}' was not found.");
}
public Result<User> Register(string email)
{
if (!IsValidEmail(email)) return Result<User>.Fail(Errors.InvalidEmail);
// ...
return Result<User>.Ok(new User(/*...*/));
}
Installation
You can install the package via NuGet:
dotnet add package Sunlix.NET.DDD.BaseTypes
License
Sunlix.NET.DDD.BaseTypes is licensed under the MIT License. See the LICENSE file for more details.
Contributing
Contributions are welcome! Feel free to open an issue or submit a pull request.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net9.0 is compatible. 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 was computed. 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. |
-
net9.0
- No dependencies.
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Sunlix.NET.DDD.BaseTypes:
| Package | Downloads |
|---|---|
|
Sunlix.NET.DDD.ROP
Sunlix.NET.DDD.ROP is a lightweight implementation of the Railway-Oriented Programming pattern for C#. It provides a composable Result type and a set of functional operators for building clear and predictable execution pipelines without exceptions. |
GitHub repositories
This package is not used by any popular GitHub repositories.