Skip to content

Entity and Identity

There are two products with the same name and the same price. Are they the same product? If they need to be managed separately even though all their attributes are identical, how do you distinguish them?

In DDD, an Entity is a domain object distinguished by a unique identifier (Identity). Even if two entities have the same attribute values, they are different entities if their IDs differ, and even if attributes change, they are the same entity if their IDs match. This chapter solves this problem using Functorium’s Entity<TId> and the Ulid-based IEntityId<TId>.


After completing this chapter, you will be able to:

  1. Explain the principle by which Entity<TId> automatically provides ID-based equality comparison
  2. Create, restore, and compare Ulid-based identifiers using the IEntityId<TId> interface
  3. Distinguish the equality differences between Entity and Value Object

What You Will Verify Through Hands-on Practice

Section titled “What You Will Verify Through Hands-on Practice”
  • ProductId: Ulid-based Entity identifier implementation (manual implementation of code generated by [GenerateEntityId])
  • Product: A domain Entity that inherits Entity<ProductId> and automatically receives ID-based equality

What happens if you compare Entities only by their attributes?

// Same name, same price, but different IDs mean different Entities
var product1 = Product.Create("Laptop", 1_500_000m);
var product2 = Product.Create("Laptop", 1_500_000m);
product1 == product2 // false - different IDs
// Same ID means same Entity
var id = ProductId.New();
var productA = Product.CreateFromValidated(id, "Mouse", 25_000m);
var productB = Product.CreateFromValidated(id, "Mouse", 25_000m);
productA == productB // true - same ID

Even if two products have identical names and prices, they are different products if they have different IDs. Conversely, even if attributes change, they are the same product if the ID matches. This is the concept of Entity Identity.

So what type should you use for the ID? Functorium provides strongly-typed IDs based on Ulid (Universally Unique Lexicographically Sortable Identifier).

public interface IEntityId<T> : IEquatable<T>, IComparable<T>
where T : struct, IEntityId<T>
{
Ulid Value { get; }
static abstract T New(); // Create new ID
static abstract T Create(Ulid id); // Restore from Ulid
static abstract T Create(string id); // Restore from string
}

Using Ulid provides the following benefits:

  • Chronological sorting possible (first 48 bits are timestamp)
  • String representation is short and URL-safe (26 characters)
  • UUID compatible

In practice, you don’t implement Entity IDs manually but use the [GenerateEntityId] source generator. In this chapter, we manually implement the code that the source generator creates for learning purposes.


EntityAndIdentity/
├── Program.cs # Demo execution
├── ProductId.cs # Ulid-based Entity ID
├── Product.cs # Product Entity
└── EntityAndIdentity.csproj
EntityAndIdentity.Tests.Unit/
├── ProductTests.cs # ID equality, Entity equality tests
├── Using.cs
├── xunit.runner.json
└── EntityAndIdentity.Tests.Unit.csproj

Implementing IEntityId<ProductId> completes a Ulid-based strongly-typed identifier. Use New() to create a new ID and Create() to restore from an existing value.

public readonly record struct ProductId : IEntityId<ProductId>
{
public Ulid Value { get; }
private ProductId(Ulid value) => Value = value;
public static ProductId New() => new(Ulid.NewUlid());
public static ProductId Create(Ulid id) => new(id);
public static ProductId Create(string id) => new(Ulid.Parse(id));
public bool Equals(ProductId other) => Value == other.Value;
public int CompareTo(ProductId other) => Value.CompareTo(other.Value);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString();
}

Using readonly record struct ensures immutability, and all comparison operations are delegated to the internal Ulid value.

Now let’s create an Entity using this ID. Inheriting from Entity<ProductId> automatically provides ID-based equality comparison.

public sealed class Product : Entity<ProductId>
{
public string Name { get; private set; }
public decimal Price { get; private set; }
private Product(ProductId id, string name, decimal price)
{
Id = id;
Name = name;
Price = price;
}
public static Product Create(string name, decimal price)
{
return new Product(ProductId.New(), name, price);
}
public static Product CreateFromValidated(ProductId id, string name, decimal price)
{
return new Product(id, name, price);
}
}

Create() automatically generates a new ID, and CreateFromValidated() restores an Entity with an existing ID. Note that the constructor is private, forcing creation exclusively through factory methods.


Entity and Value Object differ in how equality is determined. Check the key differences in the table below.

AspectEntityValue Object
EqualityID-basedValue-based
MutabilityState can changeImmutable
LifecycleUnique lifecycleDependent on Entity
ExamplesProduct, OrderMoney, Address

A summary of the methods provided by the ID type and their purposes.

MethodDescription
New()Create a new Ulid-based ID
Create(Ulid)Restore ID from Ulid value
Create(string)Restore ID from string (DB/API deserialization)
ValueAccess internal Ulid value

A: Unlike Guid, Ulid supports chronological sorting. The first 48 bits are millisecond timestamps, providing good DB index performance and natural ordering by creation time. Additionally, its 26-character string representation is shorter than UUID’s 36 characters.

A: Entity IDs must be immutable, so readonly struct is used. While record struct auto-generates Equals, GetHashCode, and ToString, we implement them explicitly to meet IEntityId<T> requirements.

Q3: What’s the difference between Create and CreateFromValidated?

Section titled “Q3: What’s the difference between Create and CreateFromValidated?”

A: Create is a general factory method that auto-generates a new ID. CreateFromValidated restores an Entity with an already existing ID, used when converting DB data to Entities in the Repository.

A: The Id property should only be set at creation time, hence init. protected restricts setting to derived classes only, preventing external ID modification.


You’ve learned how to handle Entity identity with IDs. But what happens if external code directly modifies an Entity’s internal state? In the next chapter, we’ll look at how to protect consistency boundaries through Aggregate Root.

-> Chapter 2: Aggregate Root