Entities
An entity is an object defined by its identity: it stays the same object while its attributes change. It lives inside an aggregate, exposes behaviour rather than setters, and saves its state as a snapshot.
export class OrderLine extends Entity<OrderLineId, OrderLineSnapshot> {
changeQuantity(quantity: number): Result<void, InvalidQuantity> { … }
toSnapshot(): OrderLineSnapshot { … }
}When to use
Use an entity for something you track over time inside an aggregate, such as a line of an order: two lines with the same product and quantity are still two lines. If it is described only by its values, use a value object. If it has its own consistency rules and lifecycle, make it an aggregate.
Usage
Declare an entity
Extend Entity with its identifier and its snapshot type. Keep the constructor private, change state through methods that return a Result, and expose reads as getters.
import { Entity, err, ok, type Result } from "@alveolus/core";
import { InvalidQuantity } from "../errors/invalid-quantity.error";
import { OrderLineId } from "../value-objects/order-line-id.identifier";
import { ProductId } from "../value-objects/product-id.identifier";
export type OrderLineSnapshot = { id: string; productId: string; quantity: number };
export class OrderLine extends Entity<OrderLineId, OrderLineSnapshot> {
private constructor(
id: OrderLineId,
private readonly productId: ProductId,
private currentQuantity: number,
) {
super(id);
}
static create(id: OrderLineId, productId: ProductId): OrderLine {
return new OrderLine(id, productId, 1);
}
static fromSnapshot(snapshot: OrderLineSnapshot): OrderLine {
return new OrderLine(new OrderLineId(snapshot.id), new ProductId(snapshot.productId), snapshot.quantity);
}
get quantity(): number {
return this.currentQuantity;
}
changeQuantity(quantity: number): Result<void, InvalidQuantity> {
if (quantity <= 0) {
return err(new InvalidQuantity({ quantity }));
}
this.currentQuantity = quantity;
return ok();
}
toSnapshot(): OrderLineSnapshot {
return { id: this.id.value, productId: this.productId.value, quantity: this.currentQuantity };
}
}The product is referenced by its identifier: an entity, like an aggregate, never holds another aggregate (reference-by-identity).
Expose behaviour, not setters
set quantity(quantity: number) {
this.currentQuantity = quantity;
}changeQuantity(quantity: number): Result<void, InvalidQuantity> {
if (quantity <= 0) {
return err(new InvalidQuantity({ quantity }));
}
this.currentQuantity = quantity;
return ok();
}A setter accepts any value; a business method protects the rule and says, in its signature, how it can fail. Every public method of an entity returns a Result (errors-as-values).
Change it through its aggregate
Code outside the aggregate never changes an entity directly: it calls the root, which finds the entity, applies the change and records the event. Only the root records events; an entity has no record.
changeLineQuantity(lineId: OrderLineId, quantity: number): Result<void, LineNotFound | InvalidQuantity> {
const line = this.lines.find((candidate) => candidate.id.equals(lineId));
if (line === undefined) {
return err(new LineNotFound({ lineId: lineId.value }));
}
return line.changeQuantity(quantity);
}Save it inside the snapshot of its aggregate
An entity returns its state as plain data with toSnapshot() and is rebuilt by a static fromSnapshot. The root composes the snapshots of its entities.
export type OrderSnapshot = { id: string; lines: readonly OrderLineSnapshot[] };
toSnapshot(): OrderSnapshot {
return { id: this.id.value, lines: this.lines.map((line) => line.toSnapshot()) };
}A snapshot holds only SnapshotValues: strings, numbers, booleans, null, bigint, Date, and arrays or objects of them. Identifiers and value objects are written as their raw values.
Compare entities
Two entities are equal when they are of the same class and their identifiers are equal, whatever their other attributes.
lineA.equals(lineB);Reference
abstract class Entity<Id extends AnyIdentifier, Snapshot extends AnySnapshot = AnySnapshot>
type SnapshotValue = string | number | boolean | null | bigint | Date | readonly SnapshotValue[] | { readonly [key: string]: SnapshotValue };
type AnySnapshot = { readonly [key: string]: SnapshotValue };| Type parameter | Description |
|---|---|
Id | The identifier of the entity. |
Snapshot | The plain data its state is saved as. |
| Member | Type | Description |
|---|---|---|
constructor(id) | protected | Sets the identifier. |
id | Id | The identifier, read-only. |
equals(other) | boolean | Same concrete class and equal identifiers. |
toSnapshot() | abstract, Snapshot | Returns the state as plain data. |
fromSnapshot(snapshot) | static, by convention | Rebuilds the entity. Written by you. |
AnyEntity is the type of any entity.
Caveats
equalsrequires the same concrete class: an entity is never equal to an instance of a subclass with the same identifier.- Declare the snapshot with
type, notinterface: an interface does not satisfyAnySnapshot. - TypeScript has no abstract static methods:
fromSnapshotis a convention.
Import from @alveolus/core or @alveolus/core/entities.
Troubleshooting
Type 'OrderLineSnapshot' does not satisfy the constraint 'AnySnapshot': the snapshot is an interface, or a field holds an identifier or a value object. Declare it with type and write raw values (productId: string).
See also
- Aggregates, the root that owns entities
- Value objects, for identifiers and things without identity
- Rules:
errors-as-values,reference-by-identity,placement