Let me go through your questions one by one.
Why is it better to have a Component than a Saga for this?
As Steven outlined in his answer, the lifecycle of such a Saga would be very vague. It’s not really a business transaction with multiple steps, which sagas are often used for, but it’s a single technical transaction.
The component would be a single event processor, listening to Project- and Image-events to update its table of content. Now, when a ProjectDeleted event is encountered it can query his own table, fire the commands and the delete those rows so it’s consistent again.
Now, when we try to do this in a Saga, when does it start? In order to keep all the data, the Saga would need to start when a Project is created, then kept up to date, and eventually ended. It’s certainly possible, but due to performance reasons it’s always good to keep sagas data-wise as lean as possible.
Would it make sense to have a CommandHandler component to coordinate the cascading delete or should it really be an EventHandler (or Saga).
I have seen this exact approach recently, for this same use case. The Project command handler fired commands at the images, which then fired commands at the annotations, and then to the labels. However, with an event processor you can uncouple this huge transaction. Project Delete events lead to images delete commands, and each image delete event leads to annotation delete commands, etc. It’s much more friendly for the performance of your system.
What is the appropriate time/place to markDeleted on the parent aggregate?
We can be very pragmatic about this; the only reason to call this, is to no longer accept commands. So, you should call this the moment you don’t want to accept commands to that aggregate any longer. Reading this, I think that this is the moment the delete command on the parent aggregate has succeeded.
What should we do with the child-deletion events?
I think I answered this in the earlier questions, but expanding on that I think that the child-deletion events should mark the child as deleted, and if needed an event processor can cascade the delete down the hierarchy.
Reading your questions, I wonder what the reason was for splitting these two aggregates into a hierarchy in the first place. The aggregate is your transaction boundary. Only in this boundary can you guarantee transactionality. Trying to build this outside of this boundary is hard, and considered an anti-pattern. Outside this boundary, you should rely on eventual consistency, as outlined with the event processor approach.
Please consider to model the child-aggregate as an
@Entity inside of the
@Aggregate. The model will be almost the same, but much more friendly to work with technically. I think if one of these applies, it warrants a separate child aggregate:
- The child has a separate lifespan and process than its parent and has a clear ending of his own. For example, a
Customer can have an
Order is aware of the customer and cannot exist without it, but it has a separate process and lifespan. However, deleting a
Customer would not involve deleting the
- There are performance requirements that, without the split, a single aggregate instance would be unable to handle. This level of performance requirement, however, is very rare.
I hope this helps you!