Entities — Domain Objects with an Identifier and a Lifecycle
What is an entity?
In the previous chapter you learned about value objects. This chapter covers the other kind of domain object, the entity.
An entity is a domain object with the following characteristics.
- It has an identifier (ID): it can be uniquely identified
- It holds data and business rules: it is not a mere container of data
- Identity is judged by the identifier: even if attributes change, it is the same if the identifier is the same
- It has a lifecycle: it is created, updated, and deleted
What is an identifier (ID)?
An identifier (ID) is a value for uniquely identifying an object. Let's think about it with real-world examples.
Real-world examples of identifiers
| Subject | Identifier | Description |
|---|---|---|
| A person | National ID number, passport number | Even if the name or address changes, the same person can be identified |
| A book | ISBN | Even for books with the same title, a different edition has a different ISBN |
| An order | Order number | Even if the order contents change, the order number does not |
| A bank account | Account number | Even if the balance changes, the account number stays the same |
Because there is an identifier, even when the attributes (data) change, you can track it as "the same thing."
The importance of the identifier
// Without an identifier (a value object)
$address1 = new Address('Tokyo', 'Shibuya', '1-1-1');
$address2 = new Address('Tokyo', 'Shibuya', '1-1-1');
$address1->equals($address2); // true (the same if all attributes are the same)
// With an identifier (an entity)
$user1 = new User(id: 1, name: 'Yuki', email: 'yuki@example.com');
$user2 = new User(id: 1, name: 'Yuki Igarashi', email: 'new@example.com');
$user3 = new User(id: 2, name: 'Yuki', email: 'yuki@example.com');
$user1->equals($user2); // true ← the same if the ID is the same (even if the name or email changes)
$user1->equals($user3); // false ← a different person if the ID differs (even if the name and email are the same)
- "What it is" matters → a value object (e.g. 1000 yen, Shibuya in Tokyo)
- "Which one" matters → an entity (e.g. Mr. Tanaka, order #123)
The difference between an entity and a value object
Let's compare with concrete examples.
Example 1: an address
// An address is a value object
$address1 = new Address('Tokyo', 'Shibuya', '1-1-1');
$address2 = new Address('Tokyo', 'Shibuya', '1-1-1');
// The same address if all attributes are the same
$address1->equals($address2); // true
// The address "Tokyo Shibuya 1-1-1" is one and the same in the world
// Mr. Tanaka's address and Mr. Yamada's address can both be treated as "the same address"
Example 2: a user
// A user is an entity
$user1 = new User(id: 1, name: 'Taro Tanaka', address: $address1);
$user2 = new User(id: 2, name: 'Hanako Yamada', address: $address1);
// Different users if the IDs differ (even if they live at the same address)
$user1->equals($user2); // false
// Even if Mr. Tanaka moves
$user1->changeAddress(new Address('Osaka', 'Umeda', '2-2-2'));
// Mr. Tanaka is still Mr. Tanaka (the ID does not change)
Comparison table
| Aspect | Value object | Entity |
|---|---|---|
| Identifier | None | Yes (required) |
| Basis for identity | All attribute values | The identifier only |
| Mutability | Immutable (cannot change) | Mutable (the state changes) |
| Lifecycle | None | Yes (create → update → delete) |
| Example | Money, address, email address | User, order, product |
confirm(): cannot confirm if the line items are emptycancel(): cannot cancel if already shippedaddItem(): cannot add products after confirmation
Entity ≠ table
What is important here is that an entity is not the same as a table.
A table is a mere container of data and holds no business rules. An entity is a domain object that holds business rules.
Identity judgment by identifier
An entity judges identity by its identifier.
$user1 = new User(id: 1, name: 'Yuki', email: 'yuki@example.com');
$user2 = new User(id: 1, name: 'Yuki Igarashi', email: 'new@example.com');
$user3 = new User(id: 2, name: 'Yuki', email: 'yuki@example.com');
$user1->equals($user2); // true ← the same if the identifier is the same (even if attributes change)
$user1->equals($user3); // false ← different if the identifier differs, even if the attributes are the same
To use a human analogy, even if Mr. Tanaka moves and his address changes, Mr. Tanaka is still Mr. Tanaka. This is the identity of an entity.
Examples of implementing an entity
The order-line entity (OrderLine)
The code below makes use of the value objects (OrderLineId, ProductId, Money) you learned about in the previous chapter.
final class OrderLine
{
public function __construct(
private readonly OrderLineId $id, // Identifier (value object)
private readonly ProductId $productId, // Identifier (value object)
private readonly int $quantity,
private readonly Money $unitPrice, // Money (value object)
) {
if ($quantity <= 0) {
throw new InvalidArgumentException('Quantity must be 1 or greater');
}
}
public function subtotal(): Money
{
return $this->unitPrice->multiply($this->quantity);
}
public function equals(OrderLine $other): bool
{
return $this->id->equals($other->id); // Identity judged by identifier
}
// Getters
public function id(): OrderLineId { return $this->id; }
public function productId(): ProductId { return $this->productId; }
public function quantity(): int { return $this->quantity; }
public function unitPrice(): Money { return $this->unitPrice; }
}
The order entity (Order) — the aggregate root
An aggregate root is the entity that serves as the entry point to an "aggregate" that groups related objects. It is explained in detail in the next chapter.
// The Order entity: the aggregate root (the entry point for access from outside)
final class Order
{
/** @var OrderLine[] PHPDoc type annotation: makes the element type of the array explicit */
private array $orderLines;
// private constructor: cannot be newed directly
// Use the create() or reconstruct() factory methods instead
private function __construct(
private readonly OrderId $id, // The identifier is immutable (readonly)
private OrderStatus $status, // The status changes, so no readonly
private readonly ShippingAddress $shippingAddress, // The shipping address is immutable
array $orderLines,
) {
$this->orderLines = $orderLines;
}
/**
* A factory method to create a new order
* Sets the initial state (DRAFT, empty line items) at creation
* self: indicates that it returns the type of this class itself
*/
public static function create(OrderId $id, ShippingAddress $shippingAddress): self
{
// On new creation, the status is always DRAFT and the line items are empty
return new self($id, OrderStatus::DRAFT, $shippingAddress, []);
}
/**
* A factory method to restore from persisted data
* Used when the repository converts data read from the DB into an entity
* Unlike create(), it restores the saved state as-is
*/
public static function reconstruct(
OrderId $id,
OrderStatus $status,
ShippingAddress $shippingAddress,
array $orderLines
): self {
return new self($id, $status, $shippingAddress, $orderLines);
}
/**
* Add a product
* Business rule: can be added only in the draft state (not after confirmation)
* void: no return value. An operation that changes the state
*/
public function addItem(
OrderLineId $lineId,
ProductId $productId,
int $quantity,
Money $unitPrice
): void {
// The business rule check happens inside the entity
// Having the domain object—not the UseCase—hold the rules is a characteristic of DDD
if (!$this->status->isDraft()) {
throw new DomainException('Products can be added only in the draft state');
}
$this->orderLines[] = new OrderLine($lineId, $productId, $quantity, $unitPrice);
}
/**
* Confirm the order
* Business rule: must be in the draft state and have at least one line item
*/
public function confirm(): void
{
if (!$this->status->canBeConfirmed()) {
throw new DomainException('This order cannot be confirmed');
}
// Business rule: cannot confirm if the line items are empty
if (empty($this->orderLines)) {
throw new DomainException('An order with no line items cannot be confirmed');
}
// Change the state (changeable because it is not readonly)
$this->status = OrderStatus::CONFIRMED;
}
/**
* Cancel the order
* Business rule: can be cancelled only when in the draft or confirmed state (not after shipping)
*/
public function cancel(): void
{
if (!$this->status->canBeCancelled()) {
throw new DomainException('This order cannot be cancelled');
}
$this->status = OrderStatus::CANCELLED;
}
// Getters: methods that expose the internal state to the outside
public function id(): OrderId { return $this->id; }
public function status(): OrderStatus { return $this->status; }
public function shippingAddress(): ShippingAddress { return $this->shippingAddress; }
public function orderLines(): array { return $this->orderLines; }
}
The factory method pattern
An entity usually provides two factory methods.
- create() — for new creation. Sets the initial state (e.g.
Order::create()→ status = DRAFT) - reconstruct() — for restoration from the DB. Restores the saved state as-is (e.g.
Order::reconstruct()→ status = the saved value)
// New creation
$order = Order::create($orderId, $shippingAddress);
// → status = DRAFT
// Restore from the DB (used inside the repository)
$order = Order::reconstruct($id, $status, $address, $lines);
// → status = the value saved in the DB
Why two factory methods are needed
This design pattern has an important intent.
The problem: with a single constructor
// Anti-pattern: trying to handle everything in the constructor
class Order
{
public function __construct(
OrderId $id,
OrderStatus $status,
ShippingAddress $shippingAddress,
array $orderLines
) {
$this->id = $id;
$this->status = $status;
$this->shippingAddress = $shippingAddress;
$this->orderLines = $orderLines;
}
}
// The problem at new creation
$order = new Order(
$orderId,
OrderStatus::DRAFT, // You have to specify DRAFT every time
$address,
[] // You have to specify an empty array every time
);
// → Forces the caller to set the initial state (a risk of forgetting)
// The problem at DB restoration
$order = new Order($id, $status, $address, $lines);
// → Indistinguishable from new creation (the intent is unclear)
The solution: make the intent clear with two factory methods
class Order
{
// private constructor: cannot be newed directly from outside
private function __construct(...) {}
// New creation: automatically sets the initial state
public static function create(OrderId $id, ShippingAddress $address): self
{
return new self(
$id,
OrderStatus::DRAFT, // Always starts as DRAFT
$address,
[] // Always starts with empty line items
);
}
// DB restoration: restores the saved state as-is
public static function reconstruct(
OrderId $id,
OrderStatus $status,
ShippingAddress $address,
array $lines
): self {
return new self($id, $status, $address, $lines);
}
}
// Benefit 1: enforces setting the initial state
$order = Order::create($orderId, $address);
// → The initial state (DRAFT, empty line items) is guaranteed
// Benefit 2: the intent is clear
$order = Order::reconstruct($id, $status, $address, $lines);
// → You can tell at a glance it is a DB restoration
The benefits of factory methods
| Benefit | Description | Example |
|---|---|---|
| Enforcing the initial state | create() guarantees the correct initial state | Always starts in the DRAFT state |
| Centralizing validation | The creation logic is collected in one place | Such as the empty-line-items check |
| Clarifying intent | The method name reveals the purpose | create vs reconstruct |
| Localizing change | Easy to change the initialization logic | Just fix create() |
- Make the constructor private → do not allow direct newing
- Provide static factory methods → control the creation logic
- Separate methods by purpose → make the intent clear
This controls object creation and prevents objects from being created in an invalid state.
State transitions and business rules
The state transitions of an entity become clearer when expressed with an enum.
The relationship between state and business rules
An entity's state determines which operations are allowed. This is a business rule.
[The states of an order and the operations they allow]
DRAFT state
✓ Can add products
✓ Can confirm
✓ Can cancel
✗ Cannot ship
CONFIRMED state
✗ Cannot add products ← cannot be changed after confirmation
✗ Cannot confirm (already confirmed)
✓ Can cancel
✓ Can ship
SHIPPED state
✗ No operations allowed ← nothing can be done after shipping
CANCELLED state
✗ No operations allowed ← cannot be changed after cancellation
Expressing state-transition rules with an enum
// PHP 8.1+: a Backed Enum (an enum that holds values)
// By holding a string value, that value is used when saving to the DB
enum OrderStatus: string
{
// case: defines each value of the enum
// = 'draft': the actual string value stored in the DB
case DRAFT = 'draft';
case CONFIRMED = 'confirmed';
case SHIPPED = 'shipped';
case CANCELLED = 'cancelled';
// You can define methods on an enum too
// Collecting state-judgment logic in the enum improves readability
public function isDraft(): bool { return $this === self::DRAFT; }
// Methods that judge whether a state transition is allowed
// They express the business rule of "which state can transition to which state"
public function canBeConfirmed(): bool { return $this === self::DRAFT; }
public function canBeCancelled(): bool { return $this === self::DRAFT || $this === self::CONFIRMED; }
public function canBeShipped(): bool { return $this === self::CONFIRMED; }
}
Controlling state transitions in the entity
class Order
{
private OrderStatus $status;
public function confirm(): void
{
// Business rule 1: state-transition check
if (!$this->status->canBeConfirmed()) {
throw new DomainException(
"This order cannot be confirmed (current state: {$this->status->value})"
);
}
// Business rule 2: line-item check
if (empty($this->orderLines)) {
throw new DomainException('An order with no line items cannot be confirmed');
}
// State transition
$this->status = OrderStatus::CONFIRMED;
}
public function cancel(): void
{
// Business rule: a shipped order cannot be cancelled
if (!$this->status->canBeCancelled()) {
throw new DomainException(
"This order cannot be cancelled (current state: {$this->status->value})"
);
}
$this->status = OrderStatus::CANCELLED;
}
}
State-transition diagram
cancel()is possible from the DRAFT or CONFIRMED state- SHIPPED and CANCELLED are terminal states (no further transitions)
The benefits of collecting business rules in the entity
| Benefit | Description | Example |
|---|---|---|
| Centralized management of rules | Business rules are collected in one place | "Cannot add after confirmation" lives in the Order class |
| Preventing invalid states | The entity guarantees its own consistency | A shipped order cannot be changed |
| Localizing change | The scope affected by a rule change is clear | Just fix the Order class |
| Easy to test | The entity can be tested on its own | Test domain logic without a UseCase |
- Define states with an enum → ensure type safety
- Put transition-allowed judgments in the enum → such as
canBeConfirmed() - Put the transition processing in the entity → such as
confirm() - Have the entity enforce business rules → prevent invalid transitions
This design prevents business rules from being scattered across the entire codebase.
In the next chapter, we look at aggregates in detail.