BaseEntity and BaseAggregateRoot
The library provides base classes for entities and aggregates:
import {
BaseEntity,
BaseAggregateRoot,
BaseDomainEvent,
} from '@cosmneo/onion-lasagna/backend/core/onion-layers';
BaseEntity
Entities have identity and can be compared by ID:
abstract class BaseEntity<
TId extends BaseValueObject<unknown>,
TProps extends object,
> {
private readonly _id: TId;
private readonly _version: number;
protected _props: TProps;
// Public API
get id(): TId;
get version(): number;
equals(other: BaseEntity<TId, TProps>): boolean;
// Protected (for subclasses)
protected get props(): TProps;
protected idEquals(a: TId, b: TId): boolean;
protected nextVersion(): number;
}
BaseAggregateRoot
Aggregates extend BaseEntity with domain event support:
abstract class BaseAggregateRoot<
TId extends BaseValueObject<unknown>,
TProps extends object,
> extends BaseEntity<TId, TProps> {
// Add a domain event to be published
protected addDomainEvent(event: BaseDomainEvent): void;
// Get and clear all domain events (for publishing)
public pullDomainEvents(): BaseDomainEvent[];
// Peek at events without clearing
public peekDomainEvents(): readonly BaseDomainEvent[];
// Check if there are pending events
public get hasDomainEvents(): boolean;
// Clear all events (called after publishing)
protected clearDomainEvents(): void;
}
Creating an Aggregate
import {
BaseAggregateRoot,
BaseDomainEvent,
InvariantViolationError,
} from '@cosmneo/onion-lasagna/backend/core/onion-layers';
interface OrderProps {
customerId: CustomerId;
items: OrderItem[];
status: OrderStatus;
createdAt: Date;
}
class OrderAggregate extends BaseAggregateRoot<OrderId, OrderProps> {
private constructor(id: OrderId, props: OrderProps, version = 0) {
super(id, props, version);
}
// Factory for NEW instances
static create(data: CreateOrderData): OrderAggregate {
const order = new OrderAggregate(
OrderId.generate(),
{
customerId: CustomerId.create(data.customerId),
items: [],
status: OrderStatus.DRAFT,
createdAt: new Date(),
},
);
// Emit creation event
order.addDomainEvent(new OrderCreatedEvent({
orderId: order.id.value,
customerId: data.customerId,
totalAmount: 0,
}));
return order;
}
// Factory for EXISTING instances (from DB)
static reconstitute(data: OrderData, version: number): OrderAggregate {
return new OrderAggregate(
OrderId.create(data.id),
{
customerId: CustomerId.create(data.customerId),
items: data.items.map(OrderItem.reconstitute),
status: data.status,
createdAt: data.createdAt,
},
version,
);
}
// Domain methods (enforce invariants)
addItem(item: AddItemData): void {
if (this._props.status !== OrderStatus.DRAFT) {
throw new InvariantViolationError({
message: 'Cannot add items to non-draft order',
code: 'ORDER_NOT_EDITABLE',
});
}
if (this._props.items.length >= 50) {
throw new InvariantViolationError({
message: 'Maximum 50 items per order',
code: 'ORDER_LIMIT_EXCEEDED',
});
}
this._props.items.push(OrderItem.create(item));
this.addDomainEvent(new OrderItemAddedEvent({
orderId: this.id.value,
productId: item.productId,
quantity: item.quantity,
}));
}
submit(): void {
if (this._props.items.length === 0) {
throw new InvariantViolationError({
message: 'Cannot submit empty order',
code: 'EMPTY_ORDER',
});
}
this._props.status = OrderStatus.PENDING;
this.addDomainEvent(new OrderSubmittedEvent({
orderId: this.id.value,
submittedAt: new Date(),
}));
}
// Getters
get customerId(): CustomerId { return this._props.customerId; }
get status(): OrderStatus { return this._props.status; }
get items(): readonly OrderItem[] { return [...this._props.items]; }
}
Domain Events
Create domain events by extending BaseDomainEvent. Use a payload object for type-safe construction:
import { BaseDomainEvent } from '@cosmneo/onion-lasagna/backend/core/onion-layers';
interface OrderCreatedPayload {
orderId: string;
customerId: string;
totalAmount: number;
}
class OrderCreatedEvent extends BaseDomainEvent<OrderCreatedPayload> {
constructor(payload: OrderCreatedPayload) {
super('OrderCreated', payload.orderId, payload);
}
}
interface OrderSubmittedPayload {
orderId: string;
submittedAt: Date;
}
class OrderSubmittedEvent extends BaseDomainEvent<OrderSubmittedPayload> {
constructor(payload: OrderSubmittedPayload) {
super('OrderSubmitted', payload.orderId, payload);
}
}
Info:
The payload object pattern ensures all event data is self-contained. The aggregateId is extracted from the payload rather than passed separately.
BaseDomainEvent provides:
eventId- Unique ID for the eventeventName- Name of the eventaggregateId- ID of the aggregate that emitted itoccurredOn- Timestamp when the event occurredpayload- Event-specific datatoJSON()- Serialize for storage/transport
Publishing Domain Events
Use pullDomainEvents() to get and clear events after persisting:
class CreateOrderCommand {
constructor(
private readonly orderRepo: OrderWriteRepositoryOutboundPort,
private readonly eventPublisher: EventPublisherOutboundPort,
) {}
async execute(input: CreateOrderInput): Promise<void> {
const order = OrderAggregate.create(input);
// Persist first
await this.orderRepo.save(order);
// Then publish events (get and clear)
const events = order.pullDomainEvents();
await this.eventPublisher.publishAll(events);
}
}
Optimistic Locking
Use version for optimistic concurrency. Increment the version when persisting:
class OrderRepository {
async save(order: OrderAggregate): Promise<void> {
const affectedRows = await this.db.query(
`UPDATE orders SET ..., version = ? WHERE id = ? AND version = ?`,
[order.version + 1, order.id.value, order.version],
);
if (affectedRows === 0) {
throw new ConcurrencyError('Order was modified by another process');
}
}
}
Info:
The version property is public for reading. The nextVersion() method is protected - use version + 1 in repositories to increment the version when saving.
Protected Utility Methods
BaseEntity provides utility methods for subclasses:
idEquals
Compares two IDs of the same type using the Value Object's equals method:
class OrderAggregate extends BaseAggregateRoot<OrderId, OrderProps> {
// Use idEquals for comparing aggregate IDs (same TId type)
isSameOrder(otherId: OrderId): boolean {
return this.idEquals(this.id, otherId);
}
// For child entity IDs, use equals() directly
hasItem(itemId: OrderItemId): boolean {
return this._props.items.some((item) => item.id.equals(itemId));
}
}
Version Increment
When saving to the database, increment the version:
// In repository
async save(order: OrderAggregate): Promise<void> {
await this.db.update({
...this.toRow(order),
version: order.version + 1,
}).where({ id: order.id.value, version: order.version });
}
Info:
The version property is public for reading. Use version + 1 in your repository when persisting changes to implement optimistic locking.
Hydration
Repositories are responsible for hydrating aggregates:
class OrderRepository extends BaseOutboundAdapter {
async findById(id: OrderId): Promise<OrderAggregate | null> {
const row = await this.persistence.findById(id.value);
if (!row) return null;
return OrderAggregate.reconstitute(row, row.version);
}
}
Load State Tracking
When loading aggregates from the database, you may not always load all fields or relations. For example, a query might load only id and status, skipping heavy relations like items. If code later tries to access items, it would get undefined or stale data.
BaseAggregateRoot provides load state tracking to prevent this:
abstract class BaseAggregateRoot<TId, TProps> extends BaseEntity<TId, TProps> {
// Mark fields as loaded during reconstitution
protected markLoaded(...fields: (keyof TProps | string)[]): void;
// Check if a field is loaded
protected isLoaded(field: keyof TProps | string): boolean;
// Get field value or throw PartialLoadError
protected requireLoaded<K extends keyof TProps>(field: K, errorCode?: string): TProps[K];
// View loaded fields (for debugging)
public get loadedFields(): ReadonlySet<keyof TProps | string>;
}
Using Load State Tracking
Mark fields as loaded in your reconstitute factory:
class OrderAggregate extends BaseAggregateRoot<OrderId, OrderProps> {
// Factory for EXISTING instances
static reconstitute(
data: OrderData,
version: number,
options?: { includeItems?: boolean },
): OrderAggregate {
const order = new OrderAggregate(
OrderId.create(data.id),
{
customerId: CustomerId.create(data.customerId),
items: options?.includeItems ? data.items.map(OrderItem.reconstitute) : [],
status: data.status,
createdAt: data.createdAt,
},
version,
);
// Mark which fields were actually loaded
order.markLoaded('customerId', 'status', 'createdAt');
if (options?.includeItems) {
order.markLoaded('items');
}
return order;
}
// Safe getter that throws if items weren't loaded
get items(): readonly OrderItem[] {
return this.requireLoaded('items');
}
// Conditional logic based on load state
get itemCount(): number {
if (!this.isLoaded('items')) {
return 0; // Or throw, depending on your needs
}
return this._props.items.length;
}
}
Repository with Load Options
class OrderRepository extends BaseOutboundAdapter {
async findById(
id: OrderId,
options?: { includeItems?: boolean },
): Promise<OrderAggregate | null> {
const row = await this.persistence.findById(id.value);
if (!row) return null;
// Load items only if requested
const items = options?.includeItems
? await this.persistence.findItemsByOrderId(id.value)
: [];
return OrderAggregate.reconstitute(
{ ...row, items },
row.version,
options,
);
}
}
Custom Error Codes
By default, requireLoaded generates error codes like ITEMS_NOT_LOADED. You can provide custom codes:
get customer(): Customer {
return this.requireLoaded('customer', 'ORDER_CUSTOMER_NOT_LOADED');
}
Info:
Load state tracking is optional. If you always load all fields, you don't need to use it. It's useful for performance optimization when loading partial aggregates.
Rules
- ✅ Extend
BaseAggregateRootfor event-sourced aggregates - ✅ Use factory methods (
create,reconstitute) - ✅ Enforce all invariants in domain methods
- ✅ Use
InvariantViolationErrorfor business rule violations - ✅ Emit domain events for state changes
- ✅ Return copies of collections from getters
- ✅ Reference other aggregates by ID only
- ✅ Use
versionfor optimistic locking - ✅ Use
markLoaded()inreconstitutewhen loading partial data - ✅ Use
requireLoaded()in getters for optional relations - ❌ Never create with
newdirectly - ❌ Never reference other aggregates directly
- ❌ Never publish events before persisting
- ❌ Never access fields that weren't marked as loaded