UNPKG

@nivinjoseph/n-domain

Version:

Domain Driven Design and Event Sourcing based framework for business layer implementation

458 lines (361 loc) 21.8 kB
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; } @serialize("$id") 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; } @serialize("$events") public get events(): ReadonlyArray<DomainEvent<T>> { return [...this._retroEvents, ...this._currentEvents].orderBy(t => t.version); } @serialize("$version") public get version(): number { return this.currentVersion; } @serialize("$createdAt") public get createdAt(): number { return this._state.createdAt; } @serialize("$updatedAt") 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 after 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 result; } 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"); // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 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"); // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 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"); // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 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; // } }