Skip to content

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.

ts
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.

src/ordering/domain/entities/order-line.entity.ts
ts
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 ​

❌ Avoid: src/ordering/domain/entities/order-line.entity.ts
ts
set quantity(quantity: number) {
	this.currentQuantity = quantity;
}
✅ Prefer: src/ordering/domain/entities/order-line.entity.ts
ts
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.

src/ordering/domain/aggregates/order.aggregate.ts
ts
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.

src/ordering/domain/aggregates/order.aggregate.ts
ts
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.

ts
lineA.equals(lineB);

Reference ​

ts
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 parameterDescription
IdThe identifier of the entity.
SnapshotThe plain data its state is saved as.
MemberTypeDescription
constructor(id)protectedSets the identifier.
idIdThe identifier, read-only.
equals(other)booleanSame concrete class and equal identifiers.
toSnapshot()abstract, SnapshotReturns the state as plain data.
fromSnapshot(snapshot)static, by conventionRebuilds the entity. Written by you.

AnyEntity is the type of any entity.

Caveats

  • equals requires the same concrete class: an entity is never equal to an instance of a subclass with the same identifier.
  • Declare the snapshot with type, not interface: an interface does not satisfy AnySnapshot.
  • TypeScript has no abstract static methods: fromSnapshot is 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 ​

Released under the MIT License.