← Back to writing

Identity Modeling and Explicit Domain Types

ddddomain-modelingtype-safetydotnet

This is the eighth and final deep-dive in a series on Domain-Driven Design. It builds on the concepts introduced in Domain-Driven Design: A Practical Overview.

Every DDD project I’ve worked on eventually hits the same class of bug: a Guid that was supposed to be a BoardId got passed where an OrganizationId was expected. Both are Guid, so the compiler doesn’t complain. The code runs. The wrong entity loads. The bug ships.

This isn’t a testing problem. It’s a type system problem. The domain has distinct concepts. A board identity is not an organization identity. But the code represents them with the same type. The fix is to make the type system express what the domain means.


Strongly-typed IDs

A strongly-typed ID is a wrapper around a primitive that gives it a domain-specific identity. Two IDs with the same underlying value are not interchangeable if they represent different concepts.

The base type uses a C# record with the curiously recurring template pattern (CRTP):

public abstract record Id<T>(Guid Value) where T : Id<T>
{
    public static T New() => Create(Guid.NewGuid());

    public static T From(Guid value)
    {
        DomainGuard.AgainstEmpty(value, nameof(value));
        return Create(value);
    }

    public static T Parse(string value) => From(Guid.Parse(value));

    public static implicit operator Guid(Id<T> id) => id.Value;

    public sealed override string ToString() => Value.ToString();

    private static T Create(Guid value)
    {
        return (T)Activator.CreateInstance(typeof(T), value)!;
    }
}

A concrete ID is a single line:

public sealed record BoardId(Guid Value) : Id<BoardId>(Value);
public sealed record StickyId(Guid Value) : Id<StickyId>(Value);
public sealed record OrganizationId(Guid Value) : Id<OrganizationId>(Value);
public sealed record ProductId(Guid Value) : Id<ProductId>(Value);

Now the compiler enforces what the domain means:

// This compiles — correct type
await boardRepository.GetByIdAsync(boardId, cancellationToken);

// This doesn't compile — OrganizationId is not BoardId
await boardRepository.GetByIdAsync(organizationId, cancellationToken);

The CRTP pattern (Id<T> where T : Id<T>) lets factory methods like New() and From() return the concrete type instead of the base type. BoardId.New() returns a BoardId, not an Id<BoardId>.

The implicit conversion to Guid means you can pass a BoardId anywhere a Guid is expected. This is useful at the persistence boundary where EF Core works with primitives. But the reverse is explicit: you must call BoardId.From(guid) to create a typed ID from a raw value, which validates that the GUID isn’t empty.

Because Id<T> is a record, you get value equality for free. Two BoardId instances with the same underlying Guid are equal. Records also make them natural to use as dictionary keys and in pattern matching.


Where typed IDs matter

The value of typed IDs compounds across the codebase. Consider a command handler that validates a parent board:

public async Task<BoardId> HandleAsync(CreateBoard command,
    CancellationToken cancellationToken)
{
    var productId = ProductId.From(command.ProductId);
    BoardId? parentBoardId = null;

    if (command.ParentBoardId.HasValue)
    {
        parentBoardId = BoardId.From(command.ParentBoardId.Value);
        var parentBoard = await _boardRepository
            .GetByIdAsync(parentBoardId, cancellationToken)
            ?? throw EntityNotFoundException
                .For<Board>(command.ParentBoardId.Value);
    }

    var board = Board.Create(productId, command.Name,
        command.CreatedBy, command.Type, parentBoardId);
    // ...
}

The command uses Guid because it’s the API boundary, and APIs deal in primitives. The handler immediately converts to typed IDs. From that point on, productId is a ProductId and parentBoardId is a BoardId. You can’t accidentally pass the product ID to the board repository. The conversion happens once, at the boundary, and the rest of the code is type-safe.


Value objects

A value object is defined by its attributes, not by an identity. Two Position objects with the same X and Y are equal. They’re interchangeable. They’re immutable, validated on construction, and carry no identity.

public sealed class Position : ValueObject
{
    public decimal X { get; }
    public decimal Y { get; }

    public Position(decimal x, decimal y)
    {
        X = x;
        Y = y;
    }

    protected override IEnumerable<object?> GetEqualityComponents()
    {
        yield return X;
        yield return Y;
    }
}

The ValueObject base class implements structural equality through GetEqualityComponents(). Two value objects are equal if all their components are equal. Operator overloads (==, !=) and GetHashCode are derived automatically:

public abstract class ValueObject : IEquatable<ValueObject>
{
    protected abstract IEnumerable<object?> GetEqualityComponents();

    public bool Equals(ValueObject? other)
    {
        if (other is null) return false;
        if (ReferenceEquals(this, other)) return true;
        if (GetType() != other.GetType()) return false;
        return GetEqualityComponents()
            .SequenceEqual(other.GetEqualityComponents());
    }

    public override int GetHashCode()
    {
        var hash = new HashCode();
        foreach (var component in GetEqualityComponents())
            hash.Add(component);
        return hash.ToHashCode();
    }
}

Value objects name domain concepts. Without Position, you’d pass decimal x, decimal y everywhere, two parameters that are conceptually one thing. With Position, the concept is explicit. You can add behavior to it (Distance(), Translate()). You can validate it on construction. You can’t accidentally pass an X value where a Y was expected.


Guard clauses

Guard clauses validate inputs at the boundary of a method, typically in constructors and factory methods. They catch invalid data before it enters the domain model.

public static class DomainGuard
{
    public static T AgainstNull<T>(T? value, string parameterName)
        where T : class
    {
        return value ?? throw new DomainValidationException(
            $"{parameterName} cannot be null.", parameterName);
    }

    public static string AgainstNullOrWhiteSpace(string? value,
        string parameterName)
    {
        if (string.IsNullOrWhiteSpace(value))
            throw new DomainValidationException(
                $"{parameterName} cannot be null or empty.",
                parameterName);
        return value;
    }

    public static Guid AgainstEmpty(Guid value, string parameterName)
    {
        if (value == Guid.Empty)
            throw new DomainValidationException(
                $"{parameterName} cannot be empty.", parameterName);
        return value;
    }

    public static decimal AgainstLessThan(decimal value,
        decimal minimum, string parameterName)
    {
        if (value < minimum)
            throw new DomainValidationException(
                $"{parameterName} cannot be less than {minimum}.",
                parameterName);
        return value;
    }

    public static int AgainstOutOfRange(int value, int min, int max,
        string parameterName)
    {
        if (value < min || value > max)
            throw new DomainValidationException(
                $"{parameterName} must be between {min} and {max}.",
                parameterName);
        return value;
    }
}

The critical design choice: guards return the validated value. This enables fluent assignment in constructors:

public static Board Create(ProductId projectId, string name,
    string createdBy)
{
    DomainGuard.AgainstNull(projectId, nameof(projectId));
    DomainGuard.AgainstNullOrWhiteSpace(name, nameof(name));
    DomainGuard.AgainstNullOrWhiteSpace(createdBy, nameof(createdBy));

    return new Board(BoardId.New(), projectId, name,
        createdBy, DateTime.UtcNow);
}

Guards throw DomainValidationException, not ArgumentException. The DomainValidationException carries a ParameterName property, so the API layer can return structured error responses:

public class DomainValidationException : DomainException
{
    public string ParameterName { get; }

    public DomainValidationException(string message,
        string parameterName) : base(message)
    {
        ParameterName = parameterName;
    }
}

The exception middleware catches DomainValidationException and returns an RFC 7807 ProblemDetails response with the parameter name. The client knows exactly which field failed validation without parsing error messages.


Smart enums

Standard C# enums are integers with labels. They don’t carry behavior, they don’t validate themselves, and they accept any integer value, even invalid ones. A smart enum fixes these problems:

public abstract class Enumeration<TEnum> : IEquatable<Enumeration<TEnum>>
    where TEnum : Enumeration<TEnum>
{
    private static readonly Lazy<IReadOnlyCollection<TEnum>> AllItems
        = new(GetAllItems);

    public int Value { get; }
    public string Name { get; }

    protected Enumeration(int value, string name)
    {
        Value = value;
        Name = name;
    }

    public static IReadOnlyCollection<TEnum> GetAll()
        => AllItems.Value;

    public static TEnum FromValue(int value) =>
        GetAll().FirstOrDefault(e => e.Value == value)
        ?? throw new InvalidOperationException(
            $"'{value}' is not a valid value for " +
            $"{typeof(TEnum).Name}.");

    public static TEnum FromName(string name) =>
        GetAll().FirstOrDefault(e =>
            string.Equals(e.Name, name,
                StringComparison.OrdinalIgnoreCase))
        ?? throw new InvalidOperationException(
            $"'{name}' is not a valid name for " +
            $"{typeof(TEnum).Name}.");

    public static bool TryFromValue(int value, out TEnum? result)
    {
        result = GetAll().FirstOrDefault(e => e.Value == value);
        return result is not null;
    }

    private static IReadOnlyCollection<TEnum> GetAllItems()
    {
        return typeof(TEnum)
            .GetFields(BindingFlags.Public | BindingFlags.Static
                | BindingFlags.DeclaredOnly)
            .Where(f => f.FieldType == typeof(TEnum))
            .Select(f => (TEnum)f.GetValue(null)!)
            .ToList()
            .AsReadOnly();
    }
}

A concrete smart enum adds behavior to its members:

public class Priority : Enumeration<Priority>
{
    public static readonly Priority Low = new(1, "Low");
    public static readonly Priority Medium = new(2, "Medium");
    public static readonly Priority High = new(3, "High");
    public static readonly Priority Critical = new(4, "Critical");

    private Priority(int value, string name) : base(value, name) { }
}

Smart enums are type-safe. Priority.FromValue(99) throws instead of silently accepting an invalid value. They have name-based lookup. Priority.FromName("High") works for deserialization. They support GetAll() for populating dropdowns. And because they’re classes, they can carry behavior. A Priority could have a MaxResponseTime property.

The DddSeedwork EF Core package includes value converters that store Enumeration<T> as int in the database, so smart enums work with persistence out of the box.


Making invalid states unrepresentable

These types work together to push validation to the boundaries of the system. By the time data reaches your domain logic, it’s already valid. Invalid data can’t be represented.

A raw-type method signature:

// Bad — anything can go wrong
public void AddSticky(Guid boardId, string text, decimal x, decimal y,
    string type, decimal width, decimal height)

An explicit domain type signature:

// Good — the types carry meaning and constraints
public Sticky AddSticky(string text, Position position, StickyType type,
    decimal width, decimal height)

In the first version, boardId could be Guid.Empty, type could be "NotAType", width could be -5. Every caller needs to validate. In the second version, Position is always valid (constructed with real coordinates), StickyType is always valid (it’s an enum), and the method is called on a Board aggregate (so the board exists). The remaining validations, text not empty and width not below minimum, are handled by DomainGuard inside the method.

The principle: validate at the boundary, carry safety through the types. Convert from primitives to domain types at the edges of the system: command handlers, API endpoints, repository mapping. Inside the domain, everything is already typed and valid.


The exception hierarchy

Domain errors form a hierarchy that maps to HTTP responses:

// Base domain exception — 400 Bad Request
public class DomainException : Exception { ... }

// Validation failure with parameter name — 400 + structured errors
public class DomainValidationException : DomainException
{
    public string ParameterName { get; }
}

// Entity not found — 404 Not Found
public class EntityNotFoundException : DomainException
{
    public Type EntityType { get; }
    public object EntityId { get; }

    public static EntityNotFoundException For<T>(object id)
        => new($"{typeof(T).Name} with id '{id}' was not found.")
        {
            EntityType = typeof(T),
            EntityId = id
        };
}

Each exception type carries metadata: the parameter name for validation errors, the entity type and ID for not-found errors. The API middleware catches these and returns RFC 7807 ProblemDetails responses. The domain doesn’t know about HTTP status codes. The API doesn’t parse exception messages.


Common mistakes

Using primitives in domain logic. If a handler passes Guid boardId to a domain method instead of BoardId, the type system can’t prevent misuse. Convert to domain types at the boundary and use them throughout.

Validating in the wrong place. If your API endpoint validates that a name isn’t empty and your aggregate validates that a name isn’t empty, you’re duplicating logic. Validate at the domain boundary (factory methods, behavior methods). The API can do format validation (JSON parsing, request shape), but business validation belongs in the domain.

Mutable value objects. A Position with a public X setter is not a value object. It’s a mutable data structure. Value objects are immutable. To change a position, create a new one.

Enums without validation. A standard C# enum accepts (StickyType)99 without complaint. If you need to validate enum values from external input, use Enum.IsDefined or switch to a smart enum that validates on construction.

Exposing typed IDs in API contracts. Your API should return { "id": "3f47..." }, a plain GUID string. Your domain uses BoardId. The conversion happens at the boundary. If your API returns { "id": { "value": "3f47..." } }, you’ve leaked the domain type into the API contract.


Explicit domain types are the foundation that every other DDD pattern builds on. Aggregates use typed IDs for identity. Value objects name domain concepts. Guard clauses enforce invariants. Smart enums prevent invalid states. Together, they create a domain model where the type system works for you, catching errors at compile time that would otherwise surface as bugs in production.


This is the final deep-dive in the series. The full series covers:

  1. Domain-Driven Design: A Practical Overview, what DDD is and when to use it
  2. Event Storming in Practice, discovering the domain
  3. Ubiquitous Language and Naming Strategies, naming as a design activity
  4. Designing and Integrating Bounded Contexts, finding and mapping boundaries
  5. Aggregates, Invariants, and Consistency, protecting business rules
  6. Domain Events and Event-Driven Workflows, decoupling through events
  7. Repositories and Persistence Boundaries, separating domain from storage
  8. CQRS as a Supporting Architectural Pattern, separating reads from writes
  9. Identity Modeling and Explicit Domain Types, making invalid states unrepresentable

The code examples throughout this series are drawn from StormBoard and DddSeedwork, both open-source projects.

Found this useful?

If this post helped you, consider buying me a coffee.

Buy me a coffee

Comments