2

I have an invoice root aggregate that has many invoice line entities, a due date value object and two reminder entities. We're not using event sourcing yet.

The post data I receive from the form the user has filled looks like this. I simplified it, the important part is that when I create a NEW invoice I don't have any identity for the lines and there are many.

[invoice_number]
[line][0][item_name] // Beer
[line][1][item_name] // Coffee
[line][2][item_name] // Water
// ...

So how do I create the invoice aggregate now? There are several ways of doing it but I'm not sure what the best one is.

Invoice::create(/* Invoice data goes here */);
// Then adding each line in a loop calling:
Invoice::addLine(/* ... */)
Invoice::addLine(/* ... */)

Another approach could be:

$lines = new InvoiceLinesCollection()
$lines->add(/* ... */);
Invoice::create($lines);

Also, how would I remove a line later when editing the invoice? In our old implementation where the ORM did the work for us, it automatically figured out the missing lines from the list and removed them for us. We haven't had to write a single line of code to do so. How would I do this with my aggregate? Is this at all the aggregates job or would I simply do this logic in my repositories save/edit method?

So lets go to editing, we changed some stuff and removed a line:

[id]
[invoice_number]
// Beer is gone
[line][0][item_name] // Coffee
[line][1][item_name] // Water
// ...

Assuming the beer was removed from the invoice and that I'll construct my model as I did above. Without having an identity for the lines, my only way to update the invoice in my persistence layer is to

  1. delete all lines associated to an invoice, insert again
  2. or to make a diff based on the article number and figure out the lines that aren't present in the post data and remove these lines. Because in the DB each line has an identity.

Is this correct?

Pseudocode to illustrate what I try to describe.

InvoicesRepository::save(Invoice $invoice) {
    $this->query
        ->delete()
        ->from('lines')
        ->where(['invoice_id' => $invoice->identity()])
        ->execute;

    foreach ($invoice->getLines() as $line) {
       $this->query
        ->insertInto('lines')
        ->fields($line->toArray())
        ->execute();
    }
}
  • Why is invoice line an entity? – Rik D May 6 at 17:23
  • Because how else do I identify a specific line later in my persistence layer? I've already asked a similar question in the comments of the answer I've got. I know that the model doesn't represent a DB but how do I remove lines properly when the invoice gets saved and edited later (because its in a draft status) and a line gets removed, how do I updated my persistence layer? I could remove all lines for the invoice in question and insert again? – burzum May 6 at 19:15
  • Forget the DB for a minute. How would the business identify the line to change? The expert would say: "the third line", or "the line for product X". It could very well still be an entity, but an entity inside an aggregate root has only local identity relative to the root (basically "the third line"). If you really require a globally unique identity for the invoice line, it should be it's own aggregate root, which can then be referenced by it's global Id from Invoice. – Rik D May 6 at 21:11
  • I've updated the question, maybe it makes it a little more clear now? – burzum May 6 at 21:34
  • I don't know about you, but when I place an order my mind focuses on "what" and "how much". Similarly when I need to change an order, I might say something like, "just one $item, not two". The focus on InvoiceLines confuses me, as this not something the consumer (of an API) would need to know/care about. While deleting and re-inserting the entire Invoice for each change is possible, I would suggest you model your system in a way that mandates the consumer do the heavy lifting (force the consumer to provide the diff!). This will yield a much simpler system overall. – king-side-slide May 7 at 13:50
1

Your use case entirely depends on the demands from your business analytics.

If an invoice without a single line makes no sense and must not exist, creating an empty invoice should not be possible within your code either and creating an invoice should always require a line (or lines).

If an invoice might be created without specifying lines and the lines might be added later (and it's a valid case), then you should be able to instantiate an invoice and add the lines later on:

$invoice = Invoice::create(/* Invoice data goes here */);

foreach ($lines as $line) {
    $invoice->addLine($line);
}

DDD approaches are not universal, they're crafted to the needs of a given business, code should reflect that.


In our old implementation where the ORM did the work for us, it automatically figured out the missing lines from the list and removed them for us. We haven't had to write a single line of code to do so. How would I do this with my aggregate?

Yes, this should now be responsibility of the aggregate. You need to find an element by which you're able to reliably remove a line and which makes sense to the business. From business point of view, lines usually do not have any id, what they have is a position on the invoice (first line, second line,... all the way until the last line).

So your code could perhaps look like this:

$invoice = $this->invoiceRepository->findByIdentifier($invoiceId);

$invoice->removeLineAtPosition($givenPosition);

$this->invoiceRepository->save($invoice);

The form data is always sending all lines that are present in the UI (Vue frontend). So there is no direct "remove a single line" API call or action.

REST API is an extremely high level concept which, unfortunately, does not work well with DDD. But even then, the fact that you're accepting all lines on the input of the API does not necessarily mean your domain needs to be designed the same way. If you wanted to strictly follow domain modelling, what you could do is create an application layer between your REST controller and domain which would:

  1. obtain a copy of the domain-level invoice,
  2. compare the lines in the request to the current lines on the stored invoice,
  3. generate a list of RemoveLineCommand and AddLineCommand commands based on the difference and issue those commands onto your domain layer.

If you'd like a more DDD friendly API approach, I would recommend you either RPC or (the newly favoured) GraphQL.

  • Thank you! Yes, I'm aware that the model should reflect the business. We don't want to have empty invoices so the line is required. So the collection is the way to go and checking that it is not empty? I thought about the lines having an identity or not, but position is not really a reliable way of determining the line. The order might change? Thats the reason why I gave them an identity. They'll end up with an id in the database in any case, so the UUID is required for them. So when saving I could always delete all "manually" and then insert again what I have in my request. – burzum May 6 at 12:38
  • @burzum, the goal of DDD is to create ubiquitous language between business and a code. If you're ever unsure how a certain concept should be modelled, go ask a domain expert, someone from accounting. If you ask them how are lines corrected on an invoice, most likely nobody will tell you "we delete all the current lines and re-create them with their fixed versions". The answer is likely to be something like "well, we simply fix the typo in the line and save the line again". The code, as I have mentioned, should reflect that. – Andy May 6 at 13:27
  • I'm aware of this, but this is an issue of the persistence not the domain or the ubiquitous language. The business is just interested in that they can add / remove lines as long as the invoice hasn't been finalized. The form data is always sending all lines that are present in the UI (Vue frontend). So there is no direct "remove a single line" API call or action. – burzum May 6 at 13:31
  • I've updated my question, maybe my problem is a little more clear now. – burzum May 6 at 21:35
  • @burzum, I've updated my answer, too. Hope that helps. :) – Andy May 10 at 19:19

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.