I feel like you were really close, but just missed it
order = orderRepository.Get(123)
OrderService.AddOrderLine(order: order, product: "Chocolate Cake", amount: 3))
orderRepository.Save(order)
What you were looking for is more like this:
order = orderRepository.Get(123)
order.AddOrderLine(orderService: orderService, product: "Chocolate Cake", amount: 3))
orderRepository.Save(order)
Domain services support queries within the domain model - they don't write changes to the model; the aggregates change and protect the domain state. So as a rule, you want to pass the domain service to the aggregate, then let the aggregate pass current state to the service, as needed. For instance
// Order.AddOrderLine()
orderLine = orderService.createOrderLine(this.id, product, amount);
this.lines.add(orderLine);
Note the (implied) separation of responsibilities
- The domain service creates an instance of the orderLine, using only the context provided to it. But there's no persistence here, it's just transient data in memory at this point.
- The aggregate then evaluates the orderLine to determine whether or not it satisfies the invariant. If it does, the orderLine will become part of the graph of entities reachable via the aggregate root.
However, having a Save method on repositories that concern non-root aggregates, allows for saving changes made to them directly
That's not a good thing?
orderLine = orderLineRepository.Get(31923)
orderLine.Amount = 5
OrderLineRepository.Save(orderLine)
But if this orderLine is part of an order aggregate, then presumably the Order is expected to check that the state of the order is consistent. It can't do that if you insist on being able to mutate the subordinate entities directly.
Part of the point to aggregates is that all changes to the domain model must go through paths that force the model to remain consistent. Are all of the OrderLine methods going to walk back up the graph to the Order to ensure that the invariant is still satisfied?
Of course, this might actually be a hint that the aggregate boundaries are in the wrong place. If you should be able to modify OrderLine without the entire Order, then perhaps OrderLines are aggregate roots, and not merely subordinate entities. Your domain experts might tell you that discrepancies between the Order and OrderLines aren't actually particularly expensive; ensuring that they are rare (rather than eliminating them entirely) may suffice. Horses for courses.
You should review Udi Dahan's arguments in Don't Delete - Just Don't.
Of course, since then GDPR has become a thing. So you may want to include Forget Me as a domain concept.
Is it ok to let the implementation of the repository find orphaned children and remove them automatically?
That seems perfectly acceptable to me.
Evans, in the blue book, writes
A delete operation must remove everything within the AGGREGATE boundary at once.
I'm a bit disappointed at the lack of attention that he pays to the topic in the book.
Evans describes REPOSITORY as an abstraction of an in memory collection. Fundamentally, a delete operation breaks the link between the key (identifier) and the aggregate root, which allows the aggregate root to eventually be garbage collected in a domain agnostic way.
If your backing store were actually a RDBMS, rather than an in memory collection, then a cascade delete is a pretty reasonable approximation. That could be implemented in the data store itself, or written into your repository/ORM logic.
But if your delete protocol requires domain specific actions -- sending messages to other systems, and so on, then you should probably have a method within the aggregate that expresses that logic. Transforming a representation of an active aggregate to a representation of an end-of-lifed aggregate is a domain model responsibility.
"Delete" could reasonably mean either or both of these actions, but the responsibilities for them are clearly separated.
EDIT
I missed this on the first pass
its child entities from their repository?
Child entities don't normally have a repository of their own. Once you have decided that an entity belongs to an aggregate, it is subordinate to the aggregate root. You would normally arrange that all of the entities in the aggregate are loaded by the repository used to load the root.
Best Answer
Project.addDocument is the right approach.
The guiding principle is the definition of an aggregate.
In other words, if you have a collection of documents, then all of the consistency rules for the collection of documents belong with the collection, all contained within one aggregate root.
Putting the consistency rules into a domain service tends to lead to an anemic domain model, an anti-pattern.
In ddd, all the work of changing state belongs in the aggregate; stateless domain services provide query support to the aggregate that is considering a change.
Warning: contrived example ahead
For instance, if you were trying to implement the invariant that a project isn't allowed to include more than $20 worth of documents, then you might see a signature like
The pricing service would know how to calculate the price of a document, or even a collection of documents, but it wouldn't know anything about the rule that the total needs to be below $20. The project would know how to pass the (updated) document collection to the domain service for pricing, but not anything about how pricing is done.
Typically, the application component is responsible for finding the pricing service before running the Project.addDocument command.