Skip to content

Entities

An entity is a domain object defined by identity rather than by its attributes. A user who changes name, email, address and password is still the same user. Two customers with identical details are still two customers.

If you find yourself asking "is this the same one, or just an equal one?", you have an entity.

Identity, not equality

class User:
    def __init__(self, user_id: UserId, email: EmailAddress):
        self.id = user_id
        self.email = email

    def __eq__(self, other) -> bool:
        return isinstance(other, User) and self.id == other.id

    def __hash__(self) -> int:
        return hash(self.id)

Equality compares the identifier and nothing else. Comparing all fields would say a user is "different" after an email change, which is exactly backwards.

Contrast with a value object, which has no identifier and is equal by its contents.

Entity Value object
Equality By identifier By all fields
Mutability Changes over time Immutable, replaced not modified
Question it answers Which one is it? What is it?
Example Order, User, Shipment Money, DateRange, Address

Where identifiers come from

Source Trade-off
Database sequence Simple, but the object has no identity until saved, which breaks in-memory logic and tests
UUID generated in the domain Identity exists from construction, works offline and across services. The usual DDD default
Natural key, such as an ISBN Free, but only if the business truly guarantees it is unique and never changes. It rarely does

Prefer generating the identifier in the domain, and wrap it in its own type:

@dataclass(frozen=True)
class OrderId:
    value: UUID

A typed OrderId makes cancel(customer_id) a compile-time or type-check error instead of a production incident. Bare UUID or str identifiers are interchangeable in every signature they appear in, which is the bug.

Entities own their rules

An entity is not a data holder. It enforces the invariants that concern only itself, and it exposes intention-revealing methods rather than setters.

class Shipment:
    def dispatch(self, at: datetime) -> None:
        if not self._lines:
            raise NothingToShip(self.id)
        if self.status is not Status.PACKED:
            raise NotPacked(self.id)
        self.status = Status.IN_TRANSIT
        self.dispatched_at = at

dispatch() says what the business does. setStatus(2) says nothing and enforces nothing, which is how a model becomes anemic.

Entities inside aggregates

Most entities are not independently accessible. They live inside an aggregate and are reached only through its root: an OrderLine is an entity, uniquely identified within its Order, but no outside code should hold a reference to one.

graph TD
    O[Order, aggregate root entity] --> L1[OrderLine, local entity]
    O --> L2[OrderLine, local entity]
    O --> M[Money, value object]

Local identity is enough for those: an OrderLine needs to be distinguishable from its siblings, not globally unique.

Common mistakes

  • Making everything an entity. An identifier on Address invites two addresses with the same street to be treated as different things, and adds a table nobody needed. Most "small" concepts are value objects.
  • Equality on all fields. Breaks the moment an attribute changes.
  • Mutable identifiers. If the identifier can change, it is an attribute, not an identity.
  • Setters for everything. The rules leak out to the callers.

Check Your Understanding

#

What distinguishes an entity from a value object?

#

Why wrap an identifier in a type such as OrderId instead of using a bare UUID?

#

Where should an entity's identifier usually be generated?