I have following requirements:
- In my system there are conferences and editions.
- Each edition belongs to one conference
- Each conference can have at most one current edition
- Each edition has status of draft or published
- Only published edition can be current edition of conference (it has to be published before it becomes current edition of conference it belongs to)
I am trying to model root aggregates on this system. My initial design looks like this:
class Conference extends RootAggregate {
static createConference(/* ... */) { /* ... */ }
guid: Guid;
editions: Edition[];
currentEdition: Edition[];
createEditionDraft() {
this.editions.push(new Edition({..., status: 'draft' }));
}
publishEdition(editionGuid) {
const edition = this.editions.find(edition => edition.guid === editionGuid);
edition.status = 'published';
}
unpublishEdition(editionGuid) {
const edition = this.editions.find(edition => edition.guid === editionGuid);
if (edition.guid == this.currentEditionGuid) {
throw Error('you can not unpublish current edition');
}
edition.status = 'draft';
}
setCurrentEdition(editionGuid) {
const edition = this.editions.find(edition => edition.guid === editionGuid);
if (edition.status != 'published') {
throw Error('only published editions can be set as current');
}
this.currentEdition = edition;
}
}
class Edition extends Aggregate {
guid: Guid;
status: 'draft' | 'published';
}
Everything here will work as expected because editions can only be altered by root aggregate (conference) and it's not possible that draft edition will be set as current edition of conference. However it requires loading all conference editions into conference aggregate. If there will be many editions it may suffer from performance issues. AFAIK I have two options here:
- Don't load all editions to conference aggregate and instead have a method to lazy load edition by its id to only load the edition that is supposed to be set as current edition of conference. In this case code could look like this:
class Conference {
// ...
async setCurrentEdition(editionGuid) {
const edition = await this._loadEdition(editionGuid);
if (edition.status != 'published') {
throw Error('only published editions can be set as current');
}
this.currentEdition = edition;
}
// ...
}
AFAIK people have many different opinions about lazy loading in such situations - here are few comments I found about lazy loading:
- "It should be generally avoided"
- "Of course you can if it works for you! You almost always must break some rules. There is no ideal solution"
- "Probably you should reconsider your aggregate models and divide them to something smaller and make use of eventual consistency"
So I am not asking IF I can use lazy loading here, but what are cons of using this approach (except breaking some principles). I mean can you imagine situation in which it may cause some problems in the future? I am asking because second option (described below) with eventual consistency feels much more complicated. I know that eventual consistency is not a bad thing if the does not break requirements but still I would choose simpler solution over more complicated one...
- Split conferences and editions to separate root aggregates and make use of eventual consistency:
class Conference extends RootAggregate {
static createConference(/* ... */) { /* ... * / };
guid: Guid;
currentEditionGuid: Guid;
currentEditionCandidateGuid;
trySetCurrentEdition(editionGuid) {
if (this.currentEditionCandidateGuid !== null) {
throw new Error('Another edition is being promoted at the moment');
}
this.currentEditionCandidateGuid = editionGuid;
// CurrentEditionCandidateSetDomainEvent => PrepareEditionToBeSetAsCurrentIntegrationEvent
}
// this should run in response to event EditionReadyForPromotionIntegrationEvent
setCurrentEdition(editionGuid) {
if (editionGuid !== this.currentEditionCandidateGuid) {
// this should cause EditionRejectedToBeSetAsCurrentIntegrationEvent
} else {
this.currentEditionGuid = editionGuid;
this.currentEditionCandidateGuid = null;
// CurrentEditionSetDomainEvent => CurrentEditionSetIntegrationEvent
}
}
// this should run in response to EditionNotReadyToBePromoted
clearCurrentEditionCandidate() {
this.currentEditionCandidateGuid = null;
}
// this should run in response to CheckIfEditionIsAllowedToBeUnpublishedIntegrationEvent
decideIfEditionIsReadyToBeUnpublished(editionGuid) {
if (this.currentEditionGuid === editionGuid) {
EditionRejectedToBeUnpublishedIntegrationEvent
} else {
EditionAcceptedToBeUnpublishedIntegrationEvent
}
}
}
class Edition extends RootAggregate {
static createEdition(/* ... */) { /* ... * / };
guid: Guid;
conferenceGuid: Guid;
status: 'draft' | 'published';
promotionInProgress: bool;
unpublishingInProgress: bool;
publishEdition() {
this.status = 'published';
}
tryUnpublishEdition() {
if (this.promotionInProgress || this.unpublishingInProgress) {
throw new Error('Can not unpublish because it is being promoted or published');
}
this.unpublishingInProgress = true;
// this should cause CheckIfEditionIsAllowedToBeUnpublishedIntegrationEvent
}
// this should run in response to event PrepareEditionToBeSetAsCurrentIntegrationEvent
prepareEditionForPromotion() {
if (this.status !== 'published' || this.unpublishingInProgress) {
// this should cause EditionNotReadyToBePromoted
} else {
this.promotionInProgress = true;
// EditionReadyForPromotionDomainEvent => EditionReadyForPromotionIntegrationEvent
}
}
// this should run in response to:
// EditionRejectedToBeSetAsCurrentIntegrationEvent
// and
// CurrentEditionSetIntegrationEvent
stopPromotingEdition() {
this.promotionInProgress = false;
}
// this should run in response to:
// EditionRejectedToBeUnpublishedIntegrationEvent
stopUnpublishingEdition() {
this.unpublishingInProgress = false;
}
// this should run in response to:
// EditionAcceptedToBeUnpublishedIntegrationEvent
unpublishEdition() {
this.unpublishingInProgress = false;
this.status = 'draft';
}
}
As you can see it's muuuuuch more complicated, even if it's easy to test single aggregate root then it's kind of hard to test the whole process I think. Also I am not sure if this design is correct - some aggregate "methods" don't even change any state - they just check the state and cause some integration events. I guess some of these steps should be placed in different (higher) "level" and be based on read models even? Also what comes to my mind is to use Saga pattern here and move whole promoting and unpublishing process to different place? Anyway this simple case becomes complicated when using eventual consistency. Moreover know when I think of it, it seems that maybe it's not event eventual consistency what I wrote - looks more like two-phase commit or something like that?
Edit:
I am trying to figure out proper way of using domain service approach here and this is how I imagine it for now (simplified code):
domain service:
abstract class DomainService {
public abstract execute(transactionContext: TransactionContext): Promise<void>;
}
abstract class CheckIfEditionIsReadyToBePromoted extends DomainService {
editionGuid: Guid;
constructor(editionGuid: Guid) {
super();
this.editionGuid = editionGuid;
}
}
class CheckIfEditionIsReadyToBePromotedImpl extends CheckIfEditionIsReadyToBePromoted {
constructor(editionGuid: Guid) {
super(editionGuid);
}
execute(transactionContext: TransactionContext) {
// make use of EditionRepository to read edition by guid and check if its published or not
// if not raise exception
// I assume here that root aggregate repository method getByGuid(guid)
// (which is the only method to read aggregate) out of the box locks reading for aggregate ("select for update" in infrastructure layer)
}
}
aggregate root:
class Conference {
current_edition_guid: Guid;
setCurrentEdition(
editionGuid: Guid,
// here we pass domain service to aggregate method
checkIfEditionIsReadyToBePromoted: new(editionGuid: Guid) => CheckIfEditionIsReadyToBePromoted
) {
this.current_edition_guid = editionGuid;
// domain service is added to current "context" same as domain events for example ("this.addDomainEvent(...)")
this.addDomainService(new checkIfEditionIsReadyToBePromoted(editionGuid));
}
}
aggregate repository:
class ConferenceRepository {
transactionContext: TransactionContext;
// ...
async saveChanges() {
// save aggregate changes
// dispatchDomainEvents
// executeDomainServices <= not sure if those should be executed at the end or beginning of this method
}
// ...
}
This way we can test conference easily, keep domain service abstraction, have a simple method to pass domain services to aggregates (addDomainService can be method in AggregateRoot base class). Essentialy domain services are treated as domain events (executed in same transaction) however they come from outside of aggregate and they don't "go" outside of it (like domain events which can be handled in application layer (outside of domain layer)).
Not sure if this is how domain services should be implemented but it seems it could work?