Do I have to always create an aggregate root with all available data
or can I have also another aggregate that addresses the same domain
but a different process / part of it? So I would have a more
simplified aggregate that just relies on having the id present?
It actually depends. Is there a difference between an invoice which is being prepared, i.e. lines are being added, removed,... and only at a certain point of time the invoice is finalised so that no further changes can be made to the invoice and the invoice is ready to be accounted?
If there's a clear distinction between an invoice which is being made and a finalised invoice, you might be actually looking at two bounded contexts:
- invoice administration,
- accounting.
I could imagine, in the first bounded context, your invoice model could have the following methods:
Create
AddLine
RemoveLine
Delete
- completely deletes the invoice
Finalise
- finalises the invoice and locks it for further changes (and emits a InvoiceHasBeenFinalised
event
In the second bounded context, your invoice model might be simpler, having only a referencing id of an invoice from the first BD, a sum of lines (so that the amount of the invoice might be calculated) and theoretically, besides a method for construction (as a reaction to the InvoiceHasBeenFinalised
event 1*), this invoice could have only a single method: Cancel
.
The cancel operation would not delete the invoice but (based on the requirements from your business) would most likely create a reversal operation.
1* Of course, if your application is a monolith and not split into concepts such as micro-services, the event might not be needed at all and different models across your bounded context might be simply represented by pulling the data from the database using a different query and reconstructing the loaded domain model using two different ways.
As you can see, if there's a distinction between those two invoice concepts, yes, introducing a different model is a valid case and cancelling will definitely be simpler, since you only need to construct an object having (theoretically) three properties:
- identifier of the original invoice which had been finalised,
- the sum of all lines for accounting purposes,
- the
state
of the invoice (so that you can not cancel an already cancelled invoice).
But what if the distinction between the contexts does not exist and you actually do need to cancel an invoice with its lines?
This is one of the reasons why loaders for domain objects are exposed as interfaces, rather than concrete implementations, and sometimes implementation of the interface from the domain might live in a completely different, DAL related package.
Because sometimes reconstructing a domain model might be quite a complicated operation (about which the domain does not care), a simple interface guards you from the complexity of the actual implementation.
Consider the simplest scenario, where you only need to load an invoice by id. An interface might look like this (language agnostic):
interface InvoiceRepository {
findById(id: InvoiceId): Invoice?
}
for which the implementation is fairly simple, you simply query all the data, construct the invoice in the implementation class and return the object (if an invoice with the id exists).
However a new requirement might come where it's necessary to load all invoices by a user, so your interface changes to the following:
interface InvoiceRepository {
findById(id: InvoiceId): Invoice?
findByUserId(userId: UserId): List<Invoice>
}
From the domain point of view, the interface is still very simple, but the implementation of the logic to load the data becomes much more complicated, since searching by an id or user id needs different search criteria and once you need to construct a single invoice and the other time a list of them.
In such case it's sometimes the best to start approaching your DAL layer differently and model it e.g. like this:
- method
findInvoiceIdsByUserId
,
- method
loadInvoicesByIds
,
and have an implementation of the InvoiceRepository
kind of like the following:
class SomeInvoiceRepositoryImpl implements InvoiceRepository {
private InvoiceLoaders invoiceLoaders;
SomeInvoiceRepositoryImpl(InvoiceLoaders invoiceLoaders) {
this.invoiceLoaders = invoiceLoaders;
}
findById(id: InvoiceId): Invoice? {
List<Invoice> invoiceList = this.invoiceLoaders
.loadInvoicesByIds(id);
return invoiceList.size() == 0 ? null : invoiceList.get(0);
}
findByUserId(userId: UserId): List<Invoice> {
return this.invoiceLoaders.loadInvoicesByIds(
this.invoiceLoaders.findInvoiceIdsByUserId(userId);
);
}
}
Thanks to this, the only place where construction of complicated Invoice
objects lives is in the InvoiceLoaders::loadInvoicesByIds
methods and you can very easily create different search methods to load the data. Yes, this introduces an extra SELECT
on the database (first you need to obtain the ids and then load the entities), but from my personal experience, systems are usually garbage anyway and an extra SELECT
will really not hurt you at all.