esdf
Version:
a frugal event-sourced domain-driven design framework with elements of cqrs
472 lines (443 loc) • 20.8 kB
JavaScript
/**
* @module esdf/core/EventSourcedAggregate
* @exports module.exports
*/
var AggregateSnapshot = require('./utils/AggregateSnapshot.js').AggregateSnapshot;
var SnapshotStrategy = require('./utils/SnapshotStrategy.js');
var when = require('when');
var uuid = require('uuid');
var util = require('util');
var Commit = require('./Commit.js').Commit;
/**
* Aggregate-event type mismatch. Generated when a commit labelled with another AggregateType is applied to an aggregate.
* Also occurs when a snapshot type mismatch takes place.
* This prevents loading an aggregateID as another, unrelated aggregate type and trashing the database or bypassing logic restrictions.
* @constructor
* @extends Error
*/
function AggregateTypeMismatch(expected, got){
this.name = 'AggregateTypeMismatch';
this.message = 'Aggregate type mismatch: expected (' + typeof(expected) + ')' + expected + ', got (' + typeof(got) + ')' + got;
this.labels = {
expected: expected,
got: got,
critical: true // This error precludes retry attempts (assuming a sane retry strategy is employed).
};
}
util.inherits(AggregateTypeMismatch, Error);
/**
* Event handler missing. An EventSourcedAggregate typically needs to implement on* handlers for all event types that it emits.
* You can disable this check by setting _allowMissingEventHandlers to true in the aggregate - this will let missing event handlers go unnoticed.
* @constructor
* @extends Error
*/
function AggregateEventHandlerMissingError(message){
this.name = 'AggregateEventHandlerMissingError';
this.message = message;
this.labels = {
critical: true
};
}
util.inherits(AggregateEventHandlerMissingError, Error);
/**
* Generated when an aggregate was attempted to be used incorrectly.
* This currently only occurs when a snapshot operation is requested, but the aggregate lacks snapshot support.
* @constructor
* @extends Error
*/
function AggregateUsageError(message){
this.name = 'AggregateUsageError';
this.message = message;
this.labels = {
critical: true
};
}
util.inherits(AggregateUsageError, Error);
/**
* Basic constructor for creating an in-memory object representation of an Aggregate. Aggregates are basic business objects in the domain and the primary source of events.
* An aggregate should typically listen to its own events (define on\* event handlers) and react by issuing such state changes, since it is the only keeper of its own internal state.
* You __are__ supposed to extend this prototype (preferably via Node's util.inherits or equivalent, for example CoffeeScript's extends).
* @constructor
*/
function EventSourcedAggregate(){
/**
* Aggregate ID, used when loading (rehydrating) the object from an Event Sink.
* @private
* @type string
*/
this._aggregateID = undefined;
/**
* Pending event sequence number, used for event ordering and optimistic concurrency collision detection.
* @private
* @type number
*/
this._nextSequenceNumber = 1;
/**
* Array of the events to be saved to the Event Sink within a single commit when commit() is called.
* @private
* @type module:esdf/core/Event~Event[]
*/
this._stagedEvents = undefined;
/**
* The assigned Event Sink that events will be committed to. This variable should be assigned from the outside using the assignEventSink method.
* @private
* @type Object
*/
this._eventSink = undefined;
/**
* Aggregate's proper type name - used to check if commits belong here when loading. Validation makes it impossible to apply another class's commits.
* @private
* @type string
*/
this._aggregateType = this._aggregateType || this.constructor.name;
/**
* Snapshotting strategy used while committing changes. The default is to save a snapshot every commit (if a snapshotter is at all available).
* @private
* @type function
*/
this._snapshotStrategy = SnapshotStrategy.every(1);
/**
* Whether to ignore missing event handlers when applying events (both online and during rehydration).
* For example, if "Done" is the event name and there is no onDone method defined within the aggregate, an error would normally be thrown.
* This safety mechanism is in place to catch programmer errors early.
* Setting this flag to true will prevent error generation in such cases (when you need events without any handlers). It should be done in the aggregate's constructor or prototype, preferably.
* @private
* @type boolean
*/
this._allowMissingEventHandlers = false;
/**
* The object whose emit(operationOutcomeName, operationDetails) method shall be called when I/O operations finish or fail.
* Supported operation names are ['CommitSinkSuccess', 'CommitSinkFailure', 'SnapshotSaveSuccess' 'SnapshotSaveFailure'].
* For CommitSinkSuccess and CommitSinkFailure, the following fields in the operationDetails object are defined:
* commitObject: contains the complete commit object which was due to be saved
* Additionally, CommitSinkFailure defines the following fields:
* failureReason: an error (from a lower layer - not necessarily an Error object) explaining why the sink operation failed
* For SnapshotSaveSuccess and SnapshotSaveFailure, the following fields in the operationDetails object are defined:
* snapshotObject: contains the complete snapshot object which was due to be saved
* Additionally, SnapshotSaveFailure defines the following fields:
* failureReason: an error (from a lower layer - not necessarily an Error object) explaining why the save operation failed
* @private
* @type {external:EventEmitter}
*/
this._IOObserver = null;
}
/**
* Set the Event Sink to be used by the aggregate during commit.
* @method
* @public
* @param {module:esdf/interfaces/EventSinkInterface} eventSink The Event Sink object to use.
*/
EventSourcedAggregate.prototype.setEventSink = function setEventSink(eventSink){
this._eventSink = eventSink;
};
/**
* Get the Event Sink in use.
* @method
* @public
* @returns {module:esdf/interfaces/EventSinkInterface}
*/
EventSourcedAggregate.prototype.getEventSink = function getEventSink(){
return this._eventSink;
};
/**
* Set the Aggregate ID of the instance. Used when saving commits, to mark as belonging to a particular entity.
* @method
* @public
* @param {string} aggregateID The identity (aggregate ID) to assume when committing.
*/
EventSourcedAggregate.prototype.setAggregateID = function setAggregateID(aggregateID){
this._aggregateID = aggregateID;
};
/**
* Get the Aggregate ID used when committing.
* @method
* @public
* @returns {string}
*/
EventSourcedAggregate.prototype.getAggregateID = function getAggregateID(){
return this._aggregateID;
};
/**
* Set the snapshotter to use when committing. Setting a snapshotter is optional, and if available,
* it is only used when indicated by the snapshot strategy employed by the aggregate.
* @method
* @public
* @param {module:esdf/interfaces/AggregateSnapshotterInterface} snapshotter The snapshotter object whose snapshot saving service to use.
*/
EventSourcedAggregate.prototype.setSnapshotter = function setSnapshotter(snapshotter){
this._snapshotter = snapshotter;
};
/**
* Get the snapshotter object in use.
* @method
* @public
* @returns {module:esdf/interfaces/AggregateSnapshotterInterface}
*/
EventSourcedAggregate.prototype.getSnapshotter = function getSnapshotter(){
return this._snapshotter;
};
/**
* Get the sequence number that will be used when saving the next commit. For a cleanly-initialized aggregate, this equals 1.
* @method
* @public
* @returns {number}
*/
EventSourcedAggregate.prototype.getNextSequenceNumber = function getNextSequenceNumber(){
return this._nextSequenceNumber || 1;
};
/**
* Get an array of all staged events which are awaiting commit, in the same order they were staged.
* @method
* @public
* @returns {module:esdf/core/Event~Event[]}
*/
EventSourcedAggregate.prototype.getStagedEvents = function getStagedEvents(){
return this._stagedEvents;
};
/**
* Apply the given commit to the aggregate, causing it to apply each event, individually, one after another.
* @method
* @public
* @param {module:esdf/core/Commit~Commit} commit The commit object to apply.
* @throws {module:esdf/core/EventSourcedAggregate~AggregateTypeMismatch} if the instance's _aggregateType does not equal the commit's saved aggregateType.
* @throws {module:esdf/core/EventSourcedAggregate~AggregateEventHandlerMissingError} if the handler for at least one of the commit's events is missing (and _allowMissingEventHandlers is false).
*/
EventSourcedAggregate.prototype.applyCommit = function applyCommit(commit){
var self = this;
// Check if the commit's saved aggregateType matches our own. If not, bail out - this is not our commit for sure!
if(this._aggregateType !== commit.aggregateType){
throw new AggregateTypeMismatch(this._aggregateType, commit.aggregateType);
}
commit.events.forEach(function(event){
// The handler only gets the event and the commit metadata. It is not guaranteed to have access to other commit members.
self._applyEvent(event, commit);
});
// Increment our internal sequence number counter.
this._updateSequenceNumber(commit.sequenceSlot);
};
/**
* Apply the event to the Aggregate by calling the appropriate registered event handlers.
* @method
* @private
* @param {module:esdf/core/Event~Event} event The event to apply.
* @throws {module:esdf/core/EventSourcedAggregate~AggregateEventHandlerMissingError} if the handler for the passed event (based on event type) is missing.
*/
//TODO: Document the "on*" handler function contract.
EventSourcedAggregate.prototype._applyEvent = function _applyEvent(event){
var handlerFunctionName = 'on' + event.eventType;
if(typeof(this[handlerFunctionName]) === 'function'){
this[handlerFunctionName](event);
}
else{
if(!this._allowMissingEventHandlers){
throw new AggregateEventHandlerMissingError('Event type ' + event.eventType + ' applied, but no handler was available - bailing out to avoid programmer error!');
}
}
};
/**
* Conditionally increase the internal next sequence number if the passed argument is greater or equal to it. Sets the next sequence number to the last commit number + 1.
* @method
* @private
* @param {number} lastCommitNumber The number of the processed commit.
*/
EventSourcedAggregate.prototype._updateSequenceNumber = function _updateSequenceNumber(lastCommitNumber){
if(typeof(this._nextSequenceNumber) !== 'number'){
this._nextSequenceNumber = 1;
}
if(Number(lastCommitNumber) >= this._nextSequenceNumber){
this._nextSequenceNumber = Number(lastCommitNumber) + 1;
}
};
/**
* Apply the event to the Aggregate from an outside source (i.e. non-intrinsic).
* @method
* @public
* @param {module:esdf/core/Event~Event} event The event to apply.
* @param {?module:esdf/core/Commit~Commit} commit The commit that the event is part of. If provided, the internal next slot number counter is increased to the commit's slot + 1.
*/
EventSourcedAggregate.prototype.applyEvent = function applyEvent(event, commit){
this._applyEvent(event);
if(commit && typeof(commit.sequenceSlot) === 'number'){
this._updateSequenceNumber(commit.sequenceSlot);
}
};
/**
* Stage an event for committing later. Immediately applies the event to the Aggregate (via the built-in EventEmitter), so rolling back is not possible
* (reloading the Aggregate from the Event Sink and retrying can be used instead, see utils.tryWith).
* @method
* @private
* @param {module:esdf/core/Event~Event} event The event to be enqueued for committing later.
*/
EventSourcedAggregate.prototype._stageEvent = function _stageEvent(event){
// If this is the first call, we need to initialize the pending event array.
// It can not be done via prototypes, because that would mean the array is shared among all instances (a big problem!).
if(!this._stagedEvents){
this._stagedEvents = [];
}
// Enrich the event using the aggregate's specific enrichment function.
this._enrichEvent(event);
this._stagedEvents.push(event);
this._applyEvent(event);
return true;
};
/**
* Apply a snapshot object to the aggregate instance. The aggregate must support snapshot application (can be checked via supportsSnapshotApplication()).
* After snapshot application, the instance should be indistinguishable from one that has only been processing events.
* Only the aggregate implementation is responsible for applying snapshots to itself. The framework does not aid state restoration in any way, besides setting appropriate sequence counters.
* @method
* @public
* @param {module:esdf/utils/AggregateSnapshot} snapshot The snapshot object to apply.
* @throws {module:esdf/core/EventSourcedAggregate~AggregateTypeMismatch} if the instance's _aggregateType does not equal the aggregate type indicated in the snapshot.
* @throws {module:esdf/core/EventSourcedAggregate~AggregateUsageError} if the instance does not support snapshot application or the passed object is not a valid snapshot.
*/
EventSourcedAggregate.prototype.applySnapshot = function applySnapshot(snapshot){
if(AggregateSnapshot.isAggregateSnapshot(snapshot)){
if(this.supportsSnapshotApplication()){
if(snapshot.aggregateType === this._aggregateType){
this._applySnapshot(snapshot);
this._updateSequenceNumber(snapshot.lastSlotNumber);
}
else{
throw new AggregateTypeMismatch(this._aggregateType, snapshot.aggregateType);
}
}
else{
throw new AggregateUsageError('This aggregate does not support snapshot application (needs to implement _applySnapshot).');
}
}
else{
throw new AggregateUsageError('Not a snapshot object - can not apply!');
}
};
/**
* Check if the aggregate instance supports snapshot application.
* Note that a passed check does not guarantee support for saving snapshots - only re-applying them on top of an instance.
* @method
* @public
* @returns {boolean} whether a snapshot can be applied to this aggregate.
*/
EventSourcedAggregate.prototype.supportsSnapshotApplication = function supportsSnapshotApplication(){
return (typeof(this._applySnapshot) === 'function');
};
/**
* Check if the aggregate instance supports snapshot generation (i.e. whether it can supply the data for a snapshot object's payload).
* Note that snapshot generation support alone is not enough to guarantee that a snapshot can be re-applied later.
* Moreover, keep in mind that, although snapshot *application* is often done externally (for example, by the loader before event-based rehydration), only the aggregate itself manages snapshot *generation and saving*.
* @method
* @public
* @returns {boolean} whether this aggregate can be asked to generate a snapshot of itself.
*/
EventSourcedAggregate.prototype.supportsSnapshotGeneration = function supportsSnapshotGeneration(){
return (typeof(this._getSnapshotData) === 'function');
};
/**
* Get a Commit object containing all staged events.
* Such an object is suitable for saving in a database as an atomic transaction.
* @method
* @public
* @param {Object} metadata - The metadata to assign to the constructed Commit.
* @returns {module:esdf/core/Commit~Commit}
*/
EventSourcedAggregate.prototype.getCommit = function getCommit(metadata) {
return new Commit((this._stagedEvents || []).slice(), this._aggregateID, this._nextSequenceNumber || 1, this._aggregateType, metadata);
};
/**
* Save all staged events to the Event Sink (assigned earlier manually from outside to the Aggregate's "_eventSink" property).
* A snapshot save is automatically triggered (in the background) if the snapshotting strategy allows it.
* @method
* @public
* @param {Object} metadata The data that should be saved to storage along with the commit object. Should contain serializable data - as a basic heuristic, if JSON can stringify it, it qualifies.
* @returns {external:Promise} Promise/A-compliant promise object which supports then(). The promise is resolved when the commit is saved, and rejected if the saving fails for any reason (including optimistic concurrency).
*/
EventSourcedAggregate.prototype.commit = function commit(metadata){
if(typeof(metadata) !== 'object' || metadata === null){
metadata = {};
}
// In case of child prototypes, this will often be undefined - set it so that the programmer does not have to remember doing that in every child's constructor.
if(typeof(this._nextSequenceNumber) !== 'number'){
this._nextSequenceNumber = 1;
}
var self = this;
// Try to sink the commit. If empty, return success immediately.
if(!this._stagedEvents){
this._stagedEvents = [];
}
if(this._stagedEvents.length === 0){
return when.resolve();
}
// Construct the commit object. We use slice() to make a point-in-time snapshot of the array's structure, so that further pushes/removals do not affect it.
var commitObject = this.getCommit(metadata);
// ... and tell the sink to try saving it, reacting to the result:
return when.try(self._eventSink.sink.bind(self._eventSink), commitObject).then(function _commitSinkSucceeded(result) {
// Now that the commit is sunk, we can clear the event staging area - new events will end up in subsequent commits.
self._stagedEvents = [];
self._updateSequenceNumber(commitObject.sequenceSlot);
//NOTE: The check/log emission below is a good candidate for refactoring into Aspect-Oriented Programming.
// ESDF Core does not support AOP as of now, though.
if(self._IOObserver){
self._IOObserver.emit('CommitSinkSuccess', {
commitObject: commitObject
});
}
// Now that the commit has been saved, we proceed to save a snapshot if the snapshotting strategy tells us to (and we have a snapshot save provider).
// Note that _snapshotStrategy is called with "this" set to the current aggregate, which makes it behave like a private method.
if (self.supportsSnapshotGeneration() && self._snapshotter && self._snapshotStrategy && self._snapshotStrategy(commitObject)) {
when.try(self._saveSnapshot.bind(self)).catch(function(error) {
//TODO: We should not be using console directly, but there is currently
// no way to inject a custom logger.
console.error('Error saving snapshot for %s [%s]: %s', self._aggregateID, self._aggregateType, error);
});
// Since saving a snapshot is never mandatory for correct operation of an event-sourced application, we do not have to react to errors.
}
return result;
}, function _commitSinkFailed(reason) {
// Sink failed - do nothing. An upper layer can either retry the sinking, or reload the aggregate and retry (in the latter case, the sequence number will probably get refreshed).
if(self._IOObserver){
self._IOObserver.emit('CommitSinkFailure', {
commitObject: commitObject,
failureReason: reason
});
}
throw reason;
});
};
/**
* Save a snapshot of the current aggregate state.
* The aggregate needs to support snapshot generation, as determined by its supportsSnapshotGeneration() method's return value.
* @method
* @private
* @returns {external:Promise} the promise that snapshot save will be carried out.
*/
EventSourcedAggregate.prototype._saveSnapshot = function _saveSnapshot(){
var self = this;
return when.try(function() {
if (!self.supportsSnapshotGeneration()) {
return when.reject(new AggregateUsageError('An aggregate needs to implement _getSnapshotData in order to be able to save snapshots'));
}
var snapshotObject = new AggregateSnapshot(self._aggregateType, self._aggregateID, self._getSnapshotData(), (self._nextSequenceNumber - 1));
return self._snapshotter.saveSnapshot(snapshotObject);
}).then(function _snapshotSaveSuccess() {
if (self._IOObserver) {
self._IOObserver.emit('SnapshotSaveSuccess', { snapshotObject: snapshotObject });
}
}, function _snapshotSaveFailure(reason) {
if (self._IOObserver) {
self._IOObserver.emit('SnapshotSaveFailure', {
snapshotObject: snapshotObject,
failureReason: reason
});
}
throw reason;
});
};
/**
* Enrich staged events before emission. Modifies the event object passed to it. This is so that the programmer does not have to specify all data with every event construction - instead, some keys of the payload can be assigned via enriching.
* This method does nothing by default - it is up to individual aggregate implementations based on this prototype to override this function. Using a "switch" statement based on eventType is recommended.
* @method
* @protected
* @param {module:esdf/core/Event~Event} event The event to be enriched. New key-value pairs can be added to event.eventPayload.
*/
EventSourcedAggregate.prototype._enrichEvent = function _enrichEvent(event){
// Do nothing, unless this function is overloaded.
};
module.exports.EventSourcedAggregate = EventSourcedAggregate;