Aggregates

Entity clusters treated as a single unit. Enforce business invariants and emit domain events.

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 event
  • eventName - Name of the event
  • aggregateId - ID of the aggregate that emitted it
  • occurredOn - Timestamp when the event occurred
  • payload - Event-specific data
  • toJSON() - 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 BaseAggregateRoot for event-sourced aggregates
  • ✅ Use factory methods (create, reconstitute)
  • ✅ Enforce all invariants in domain methods
  • ✅ Use InvariantViolationError for business rule violations
  • ✅ Emit domain events for state changes
  • ✅ Return copies of collections from getters
  • ✅ Reference other aggregates by ID only
  • ✅ Use version for optimistic locking
  • ✅ Use markLoaded() in reconstitute when loading partial data
  • ✅ Use requireLoaded() in getters for optional relations
  • ❌ Never create with new directly
  • ❌ Never reference other aggregates directly
  • ❌ Never publish events before persisting
  • ❌ Never access fields that weren't marked as loaded