@nivinjoseph/n-domain
Version:
Domain Driven Design and Event Sourcing based framework for business layer implementation
455 lines (358 loc) • 21.6 kB
text/typescript
import { given } from "@nivinjoseph/n-defensive";
import { Deserializer, Serializable, serialize } from "@nivinjoseph/n-util";
import { createHash } from "node:crypto";
import { AggregateRootData } from "./aggregate-root-data.js";
import { AggregateStateFactory } from "./aggregate-state-factory.js";
import { AggregateState, clearBaseState } from "./aggregate-state.js";
import { DomainContext } from "./domain-context.js";
import { DomainEventData } from "./domain-event-data.js";
import { DomainEvent } from "./domain-event.js";
// import { AggregateRebased } from "./aggregate-rebased";
import { AggregateStateHelper } from "./aggregate-state-helper.js";
// public
export abstract class AggregateRoot<T extends AggregateState, TDomainEvent extends DomainEvent<T>> extends Serializable<AggregateRootData>
{
private readonly _domainContext: DomainContext;
private readonly _stateFactory: AggregateStateFactory<T>;
private readonly _state: T;
private readonly _retroEvents: ReadonlyArray<DomainEvent<T>>;
private readonly _retroVersion: number;
private readonly _currentEvents = new Array<DomainEvent<T>>(); // track unit of work stuff
private readonly _isNew: boolean = false;
private _isReconstructed = false;
private _reconstructedFromVersion = 0;
protected get state(): T { return this._state; }
public get context(): DomainContext { return this._domainContext; }
public get id(): string { return this._state.id; }
public get retroEvents(): ReadonlyArray<DomainEvent<T>> { return this._retroEvents.orderBy(t => t.version); }
public get retroVersion(): number { return this._retroVersion; }
public get currentEvents(): ReadonlyArray<DomainEvent<T>> { return this._currentEvents.orderBy(t => t.version); }
public get currentVersion(): number { return this._state.version; }
public get events(): ReadonlyArray<DomainEvent<T>> { return [...this._retroEvents, ...this._currentEvents].orderBy(t => t.version); }
public get version(): number { return this.currentVersion; }
public get createdAt(): number { return this._state.createdAt; }
public get updatedAt(): number { return this._state.updatedAt; }
public get isNew(): boolean { return this._isNew; } // this will always be false for anything that is reconstructed
public get hasChanges(): boolean { return this.currentVersion !== this.retroVersion; }
public get isReconstructed(): boolean { return this._isReconstructed; }
public get reconstructedFromVersion(): number { return this._reconstructedFromVersion; }
public get isRebased(): boolean { return this._state.isRebased; }
public get rebasedFromVersion(): number { return this._state.rebasedFromVersion; }
protected constructor(domainContext: DomainContext, events: ReadonlyArray<DomainEvent<T>>,
stateFactory: AggregateStateFactory<T>, currentState?: T)
{
super({} as any);
given(domainContext, "domainContext").ensureHasValue()
.ensureHasStructure({ userId: "string" });
this._domainContext = domainContext;
given(events, "events").ensureHasValue().ensureIsArray();
given(stateFactory, "stateFactory").ensureHasValue().ensureIsObject();
this._stateFactory = stateFactory;
given(currentState as object, "currentState").ensureIsObject();
this._state = Object.assign(this._stateFactory.create(), currentState);
if (this._state.version)
{
given(events, "events")
.ensure(t => t.length === 0, "no events should be passed when constructing from snapshot");
this._retroEvents = [];
}
else
{
given(events, "events")
.ensure(t => t.length > 0, "no events passed")
.ensure(t => t.some(u => u.isCreatedEvent), "no created event passed")
.ensure(t => t.count(u => u.isCreatedEvent) === 1, "more than one created event passed");
this._retroEvents = events;
if (this._retroEvents.some(t => (<any>t)._aggregateId == null)) // Deliberate workaround to access aggregateId
this._isNew = true;
if (this._isNew)
this._retroEvents.forEach(t => t.apply(this, this._domainContext, this._state));
else
this._retroEvents.orderBy(t => t.version).forEach(t => t.apply(this, this._domainContext, this._state));
}
this._state = this._stateFactory.update(this._state);
this._retroVersion = this.currentVersion;
}
public static deserializeFromEvents<TAggregate extends AggregateRoot<TAggregateState, TAggregateDomainEvent>,
TAggregateState extends AggregateState, TAggregateDomainEvent extends DomainEvent<TAggregateState>>(domainContext: DomainContext,
aggregateType: new (...args: Array<any>) => TAggregate, eventData: ReadonlyArray<DomainEventData>): TAggregate
{
given(domainContext, "domainContext").ensureHasValue().ensureHasStructure({ userId: "string" });
given(aggregateType, "aggregateType").ensureHasValue().ensureIsFunction();
given(eventData, "eventData").ensureHasValue().ensureIsArray().ensure(t => t.length > 0);
// given(data, "data").ensureHasValue().ensureIsObject()
// .ensureHasStructure({
// $id: "string",
// $version: "number",
// $createdAt: "number",
// $updatedAt: "number",
// $events: [{
// $aggregateId: "string",
// $id: "string",
// $userId: "string",
// $name: "string",
// $occurredAt: "number",
// $version: "number",
// $isCreatedEvent: "boolean"
// }]
// });
const deserializedEvents = eventData.map((eventData) =>
{
return Deserializer.deserialize(eventData);
// const name = eventData.$name;
// const event = eventTypes.find(t => (<Object>t).getTypeName() === name);
// if (!event)
// throw new ApplicationException(`No event type supplied for event with name '${name}'`);
// if (!(<any>event).deserializeEvent)
// throw new ApplicationException(`Event type '${name}' does not have a static deserializeEvent method defined.`);
// return (<any>event).deserializeEvent(eventData);
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
return new (<any>aggregateType)(domainContext, deserializedEvents) as TAggregate;
}
// public serialize(): AggregateRootData
// {
// return {
// $id: this.id,
// $version: this.version,
// $createdAt: this.createdAt,
// $updatedAt: this.updatedAt,
// $events: this.events.map(t => t.serialize())
// };
// }
// public serialize(): AggregateRootData
// {
// return super.serialize() as AggregateRootData;
// }
public static deserializeFromSnapshot<TAggregate extends AggregateRoot<TAggregateState, TAggregateDomainEvent>,
TAggregateState extends AggregateState, TAggregateDomainEvent extends DomainEvent<TAggregateState>>(domainContext: DomainContext,
aggregateType: new (...args: Array<any>) => TAggregate, stateFactory: AggregateStateFactory<TAggregateState>,
stateSnapshot: TAggregateState | object): TAggregate
{
given(domainContext, "domainContext").ensureHasValue().ensureHasStructure({ userId: "string" });
given(aggregateType, "aggregateType").ensureHasValue().ensureIsFunction();
given(stateFactory, "stateFactory").ensureHasValue().ensureIsObject();
given(stateSnapshot, "stateSnapshot").ensureHasValue().ensureIsObject()
.ensureHasStructure({
id: "string",
version: "number",
createdAt: "number",
updatedAt: "number"
});
return new aggregateType(domainContext, [], stateFactory.deserializeSnapshot(stateSnapshot as any));
}
public snapshot(...cloneKeys: ReadonlyArray<string>): T | object
{
return AggregateStateHelper.serializeStateIntoSnapshot(this.state, ...cloneKeys);
}
public constructVersion(version: number): this
{
given(version, "version").ensureHasValue().ensureIsNumber()
.ensure(t => t > 0 && t <= this.version, `version must be > 0 and <= ${this.version} (current version)`);
given(this, "this").ensure(t => t.retroEvents.length > 0, "invoking method on object without retro events");
const ctor = (<Object>this).constructor;
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const result = new (<any>ctor)(this._domainContext, this.events.filter(t => t.version <= version)) as this;
result._isReconstructed = true;
result._reconstructedFromVersion = this.version;
return result;
}
public constructBefore(dateTime: number): this
{
given(dateTime, "dateTime").ensureHasValue().ensureIsNumber()
.ensure(t => t > this.createdAt, "dateTime must be before createdAt");
given(this, "this").ensure(t => t.retroEvents.length > 0, "invoking method on object without retro events");
const ctor = (<Object>this).constructor;
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const result = new (<any>ctor)(this._domainContext, this.events.filter(t => t.occurredAt < dateTime)) as this;
result._isReconstructed = true;
result._reconstructedFromVersion = this.version;
return this;
}
public hasEventOfType<TEventType extends DomainEvent<T>>(eventType: new (...args: Array<any>) => TEventType): boolean
{
given(eventType, "eventType").ensureHasValue().ensureIsFunction();
given(this, "this").ensure(t => t.retroEvents.length > 0, "invoking method on object without retro events");
const eventTypeName = (<Object>eventType).getTypeName();
return this.events.some(t => t.name === eventTypeName);
}
public hasRetroEventOfType<TEventType extends DomainEvent<T>>(eventType: new (...args: Array<any>) => TEventType): boolean
{
given(eventType, "eventType").ensureHasValue().ensureIsFunction();
given(this, "this").ensure(t => t.retroEvents.length > 0, "invoking method on object without retro events");
const eventTypeName = (<Object>eventType).getTypeName();
return this._retroEvents.some(t => t.name === eventTypeName);
}
public hasCurrentEventOfType<TEventType extends DomainEvent<T>>(eventType: new (...args: Array<any>) => TEventType): boolean
{
given(eventType, "eventType").ensureHasValue().ensureIsFunction();
const eventTypeName = (<Object>eventType).getTypeName();
return this._currentEvents.some(t => t.name === eventTypeName);
}
public getEventsOfType<TEventType extends DomainEvent<T>>(eventType: new (...args: Array<any>) => TEventType): Array<TEventType>
{
given(eventType, "eventType").ensureHasValue().ensureIsFunction();
given(this, "this").ensure(t => t.retroEvents.length > 0, "invoking method on object without retro events");
const eventTypeName = (<Object>eventType).getTypeName();
return this.events.filter(t => t.name === eventTypeName) as Array<TEventType>;
}
public getRetroEventsOfType<TEventType extends DomainEvent<T>>(eventType: new (...args: Array<any>) => TEventType): Array<TEventType>
{
given(eventType, "eventType").ensureHasValue().ensureIsFunction();
given(this, "this").ensure(t => t.retroEvents.length > 0, "invoking method on object without retro events");
const eventTypeName = (<Object>eventType).getTypeName();
return this._retroEvents.filter(t => t.name === eventTypeName) as Array<TEventType>;
}
public getCurrentEventsOfType<TEventType extends DomainEvent<T>>(eventType: new (...args: Array<any>) => TEventType): Array<TEventType>
{
given(eventType, "eventType").ensureHasValue().ensureIsFunction();
const eventTypeName = (<Object>eventType).getTypeName();
return this._currentEvents.filter(t => t.name === eventTypeName) as Array<TEventType>;
}
/**
*
* @param domainContext - provide the Domain Context
* @param createdEvent - provide a new created event to be used by the clone
* @param serializedEventMutatorAndFilter - provide a function that can mutate the serialized event if required and returns a boolean indicating whether to include the event or not.
* @returns - cloned Aggregate
*/
public clone(domainContext: DomainContext, createdEvent: DomainEvent<T>,
serializedEventMutatorAndFilter?: (event: { $name: string; }) => boolean): this
{
given(domainContext, "domainContext").ensureHasValue()
.ensureHasStructure({ userId: "string" });
given(createdEvent, "createdEvent").ensureHasValue().ensureIsInstanceOf(DomainEvent)
.ensure(t => t.isCreatedEvent, "must be created event");
given(serializedEventMutatorAndFilter as Function, "serializedEventMutator").ensureIsFunction();
given(this, "this").ensure(t => t.retroEvents.length > 0, "invoking method on object without retro events");
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const clone: this = new (<any>this.constructor)(domainContext, [createdEvent]);
this.events
.where(t => !t.isCreatedEvent)
.forEach(t =>
{
const serializedEvent = t.serialize();
if (serializedEventMutatorAndFilter != null)
{
const keep = serializedEventMutatorAndFilter(serializedEvent as any);
if (!keep)
return;
}
serializedEvent.$aggregateId = null;
serializedEvent.$id = null;
serializedEvent.$userId = null;
// serializedEvent.$name = null; // we keep the name intact
serializedEvent.$occurredAt = null;
serializedEvent.$version = null;
// serializedEvent.$isCreatedEvent = null; // we dont need to touch this
clone.applyEvent(Deserializer.deserialize(serializedEvent));
});
return clone;
}
public test(): void
{
const type = (<Object>this).constructor as new (...params: Array<any>) => this;
given(type, "type").ensureHasValue().ensureIsFunction()
.ensure(t => (<Object>t).getTypeName() === (<Object>this).getTypeName(), "type name mismatch");
const defaultState = this._stateFactory.create();
given(defaultState, "defaultState").ensureHasValue().ensureIsObject()
.ensure(t => JSON.stringify(t) === JSON.stringify(this._stateFactory.create()), "multiple default state creations are not consistent");
const deserializeEvents: Function = (<any>type).deserializeEvents;
given(deserializeEvents, "deserializeEvents").ensureHasValue().ensureIsFunction();
const eventsSerialized = this.serialize();
given(eventsSerialized, "eventsSerialized").ensureHasValue().ensureIsObject()
.ensureHasStructure({
$id: "string",
$version: "number",
$createdAt: "number",
$updatedAt: "number",
$events: ["object"]
})
.ensure(t => JSON.stringify(t) === JSON.stringify(this.serialize()), "multiple serializations are not consistent");
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const eventsDeserializedAggregate: this = (<any>type).deserializeEvents(this._domainContext, eventsSerialized.$events);
given(eventsDeserializedAggregate, "eventsDeserializedAggregate").ensureHasValue().ensureIsObject().ensureIsType(type);
const eventsDeserializedAggregateState = eventsDeserializedAggregate.state;
console.log("eventsDeserializedAggregateState", JSON.stringify(eventsDeserializedAggregateState));
console.log("state", JSON.stringify(this.state));
const eventsDeserializedAggregateStateHash = createHash("sha512")
.update(JSON.stringify(eventsDeserializedAggregateState).trim())
.digest("hex").toUpperCase();
const originalStateHash = createHash("sha512")
.update(JSON.stringify(this.state).trim())
.digest("hex").toUpperCase();
given(eventsDeserializedAggregateStateHash, "eventsDeserializedAggregateStateHash").ensureHasValue().ensureIsString()
.ensure(t => t === originalStateHash, "state is not consistent with original state");
const deserializeSnapshot: Function = (<any>type).deserializeSnapshot;
given(deserializeSnapshot, "deserializeSnapshot").ensureHasValue().ensureIsFunction();
const snapshot = this.snapshot();
given(snapshot, "snapshot").ensureHasValue().ensureIsObject()
.ensure(t => JSON.stringify(t) === JSON.stringify(this.snapshot()), "multiple snapshots are not consistent");
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const snapshotDeserializedAggregate: this = (<any>type).deserializeSnapshot(this._domainContext, snapshot);
given(snapshotDeserializedAggregate, "snapshotDeserializedAggregate").ensureHasValue().ensureIsObject().ensureIsType(type);
const snapshotDeserializedAggregateState = snapshotDeserializedAggregate.state;
given(snapshotDeserializedAggregateState, "snapshotDeserializedAggregateState").ensureHasValue().ensureIsObject()
.ensure(t => JSON.stringify(t) === JSON.stringify(this.state), "state is not consistent with original state");
}
protected rebase(version: number, rebasedEventFactoryFunc: (defaultState: object, rebaseState: object, rebaseVersion: number) => TDomainEvent): void
{
given(version, "version").ensureHasValue().ensureIsNumber()
.ensure(t => t > 0 && t <= this.version, `version must be > 0 and <= ${this.version} (current version)`);
given(rebasedEventFactoryFunc, "rebasedEventFactoryFunc").ensureHasValue().ensureIsFunction();
const rebaseVersionInstance = this.constructVersion(version);
given(rebaseVersionInstance, "rebaseVersionInstance")
.ensure(t => t.version === version, "could not reconstruct rebase version");
const rebaseVersion = rebaseVersionInstance.version;
const rebaseState = AggregateStateHelper.serializeStateIntoSnapshot(rebaseVersionInstance.state);
clearBaseState(rebaseState);
const defaultState = AggregateStateHelper.serializeStateIntoSnapshot(this._stateFactory.create());
clearBaseState(defaultState);
// const rebaseEvent = rebasedEventFactoryFunc != null
// ? rebasedEventFactoryFunc(defaultState, rebaseState, rebaseVersion)
// : new AggregateRebased({ defaultState, rebaseState, rebaseVersion });
const rebaseEvent = rebasedEventFactoryFunc(defaultState, rebaseState, rebaseVersion);
this.applyEvent(rebaseEvent);
// console.log("rebaseEvent");
// console.dir(rebaseEvent);
// console.log("rebaseEvent serialized");
// console.dir(rebaseEvent.serialize());
// console.log("rebaseEvent deserialized");
// console.dir(Deserializer.deserialize(rebaseEvent.serialize()));
}
protected applyEvent(event: TDomainEvent): void
{
given(event, "event").ensureHasValue().ensureIsObject().ensureIsInstanceOf(DomainEvent)
.ensure(t => t.isCreatedEvent ? this._retroEvents.isEmpty && this._currentEvents.isEmpty : true,
"'isCreatedEvent = true' cannot be the case for multiple events");
event.apply(this, this._domainContext, this._state);
this._currentEvents.push(event);
// if (this._retroEvents.length > 0)
// {
// const trimmed = this.trim(this._retroEvents.orderBy(t => t.version)).orderBy(t => t.version);
// given(trimmed, "trimmed").ensureHasValue().ensureIsArray()
// .ensure(t => t.length > 0, "cannot trim all retro events")
// .ensure(t => t.length <= this._retroEvents.length, "only contraction is allowed")
// .ensure(t => t.some(u => u.isCreatedEvent), "cannot trim created event")
// .ensure(t => t.count(u => u.isCreatedEvent) === 1, "cannot add new created events")
// .ensure(t => t.every(u => this._retroEvents.contains(u)), "cannot add new events")
// ;
// this._retroEvents = trimmed;
// }
}
// /**
// *
// * @deprecated DO NOT USE
// * @description override to trim retro events on the application of a new event
// */
// protected trim(retroEvents: ReadonlyArray<DomainEvent<T>>): ReadonlyArray<DomainEvent<T>>
// {
// given(retroEvents, "retroEvents").ensureHasValue().ensureIsArray().ensure(t => t.length > 0);
// return retroEvents;
// }
}