0

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:

  1. 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...

  1. 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?

2
  • ” If there will be many editions it may suffer from performance issues”. Start with figuring out if you have an actual problem.
    – Rik D
    Feb 13 '21 at 20:31
  • Yeah I know that even with weekly editions over 10 years it will be 480 editions which actually should not be a big problem I guess, but let's assume for this example that there may be daily editions which would generate 3650 editions over 10 years and that could be a problem I think? Anyway I am curious of solution when performance indeed becomes issue...
    – user606521
    Feb 13 '21 at 20:39
1

Very common problem indeed. I faced it many times in the past.

What I suggest (highly opinionated):

  1. If you know that there will be max few dozens editions per conference than go with lazy loading (but try to avoid N+1 problem). Ultimately loading 50-100 records from DB only when editing something is not an overkill.

  2. If the above is not the case (e.g. there are thousands entities under aggregate root) then consider makeing a conference an aggregate root as well, and utilize a concept of DomainService. My understanding of DomainService is that it contains logic which does not belongs to any AR (in your case, there will be logic that handles both ARs together - conferences and editions). You can then call DomainService logic only with these aggregates you need (e.g. current conference, current edition, new edition) which will eliminate need of fetching all editions of a conference.

Hint: I empirically realized that majority of my entities are Aggregate Roots. There are just rare cases when AR has child entities.

Considering your case again, let's ask question "does it make sense to edit edition directly, without accessing it through AR? I'm guessing YES (e.g. changing edition's promotion. I believe promotion is per edition and can be changed without accessing through AR).

Another example: catalog has sections, section has products. Featured product of a category can only be active (not disabled) product. I'll make all of these a separate aggregate roots.

Edit Consider DomainEvents too https://stackoverflow.com/questions/59583401/correct-way-for-communicating-aggregates-in-ddd

4
  • Okay, it seems I don't understand DomainEvents purpose correctly. From what you say and from then link you provided it seems that actually I can: in response to domain event of one aggregate root I can (within same transaction!) do whatever I want with other aggregate root (same or different type, in my case different). So I could just Conference.setCurrentEdition(guid) => CurrentEditionSetDomainEvent => in response to this event load editon aggregate to check if it's published or not and if not then raise exception which would rollback whole transaction?
    – user606521
    Feb 13 '21 at 21:19
  • And this is perfect question where my knowledge ends. I will keep monitoring this thread, because I want to find answer myself. Feb 14 '21 at 6:33
  • 1
    I asked question that may help: softwareengineering.stackexchange.com/questions/422238/… Feb 14 '21 at 7:09
  • Please see my last edit: this is how I imagine domain services could work - what do you think? Essentialy it does exactly the same as raising exception in response to domain event, but it allows to organise domain service to be defined in domain logic and passed to aggregate from outside so in the end I think it does not break DDD principles (at least it pretends not to :).
    – user606521
    Feb 14 '21 at 11:35

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.