You are browsing a version that has not yet been released. |
Aggregate Fields
You will often come across the requirement to display aggregate values of data that can be computed by using the MIN, MAX, COUNT or SUM SQL functions. For any ORM this is a tricky issue traditionally. Doctrine ORM offers several ways to get access to these values and this article will describe all of them from different perspectives.
You will see that aggregate fields can become very explicit features in your domain model and how this potentially complex business rules can be easily tested.
An example model
Say you want to model a bank account and all their entries. Entries into the account can either be of positive or negative money values. Each account has a credit limit and the account is never allowed to have a balance below that value.
For simplicity we live in a world where money is composed of
integers only. Also we omit the receiver/sender name, stated reason
for transfer and the execution date. These all would have to be
added on the Entry
object.
Our entities look like:
1 <?php
namespace Bank\Entities;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
#[ORM\Entity]
class Account
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id;
#[ORM\OneToMany(targetEntity: Entry::class, mappedBy: 'account', cascade: ['persist'])]
private Collection $entries;
public function __construct(
#[ORM\Column(type: 'string', unique: true)]
private string $no,
#[ORM\Column(type: 'integer')]
private int $maxCredit = 0,
) {
$this->entries = new ArrayCollection();
}
}
#[ORM\Entity]
class Entry
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id;
public function __construct(
#[ORM\ManyToOne(targetEntity: Account::class, inversedBy: 'entries')]
private Account $account,
#[ORM\Column(type: 'integer')]
private int $amount,
) {
// more stuff here, from/to whom, stated reason, execution date and such
}
public function getAmount(): Amount
{
return $this->amount;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
Using DQL
The Doctrine Query Language allows you to select for aggregate values computed from fields of your Domain Model. You can select the current balance of your account by calling:
The $em
variable in this (and forthcoming) example holds the
Doctrine EntityManager
. We create a query for the SUM of all
amounts (negative amounts are withdraws) and retrieve them as a
single scalar result, essentially return only the first column of
the first row.
This approach is simple and powerful, however it has a serious drawback. We have to execute a specific query for the balance whenever we need it.
To implement a powerful domain model we would rather have access to
the balance from our Account
entity during all times (even if
the Account was not persisted in the database before!).
Also an additional requirement is the max credit per Account
rule.
We cannot reliably enforce this rule in our Account
entity with
the DQL retrieval of the balance. There are many different ways to
retrieve accounts. We cannot guarantee that we can execute the
aggregation query for all these use-cases, let alone that a
userland programmer checks this balance against newly added
entries.
Using your Domain Model
Account
and all the Entry
instances are connected through a
collection, which means we can compute this value at runtime:
Now we can always call Account::getBalance()
to access the
current account balance.
To enforce the max credit rule we have to implement the "Aggregate Root" pattern as described in Eric Evans book on Domain Driven Design. Described with one sentence, an aggregate root controls the instance creation, access and manipulation of its children.
In our case we want to enforce that new entries can only added to
the Account
by using a designated method. The Account
is
the aggregate root of this relation. We can also enforce the
correctness of the bi-directional Account
<-> Entry
relation with this method:
Now look at the following test-code for our entities:
1 <?php
use PHPUnit\Framework\TestCase;
class AccountTest extends TestCase
{
public function testAddEntry()
{
$account = new Account("123456", maxCredit: 200);
$this->assertEquals(0, $account->getBalance());
$account->addEntry(500);
$this->assertEquals(500, $account->getBalance());
$account->addEntry(-700);
$this->assertEquals(-200, $account->getBalance());
}
public function testExceedMaxLimit()
{
$account = new Account("123456", maxCredit: 200);
$this->expectException(Exception::class);
$account->addEntry(-1000);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
To enforce our rule we can now implement the assertion in
Account::addEntry
:
1 <?php
class Account
{
// .. previous code
private function assertAcceptEntryAllowed(int $amount): void
{
$futureBalance = $this->getBalance() + $amount;
$allowedMinimalBalance = ($this->maxCredit * -1);
if ($futureBalance < $allowedMinimalBalance) {
throw new Exception("Credit Limit exceeded, entry is not allowed!");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
We haven't talked to the entity manager for persistence of our
account example before. You can call
EntityManager::persist($account)
and then
EntityManager::flush()
at any point to save the account to the
database. All the nested Entry
objects are automatically
flushed to the database also.
The current implementation has a considerable drawback. To get the
balance, we have to initialize the complete Account::$entries
collection, possibly a very large one. This can considerably hurt
the performance of your application.
Using an Aggregate Field
To overcome the previously mentioned issue (initializing the whole
entries collection) we want to add an aggregate field called
"balance" on the Account and adjust the code in
Account::getBalance()
and Account:addEntry()
:
1 <?php
class Account
{
#[ORM\Column(type: 'integer')]
private int $balance = 0;
public function getBalance(): int
{
return $this->balance;
}
public function addEntry(int $amount): void
{
$this->assertAcceptEntryAllowed($amount);
$this->entries[] = new Entry($this, $amount);
$this->balance += $amount;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
This is a very simple change, but all the tests still pass. Our
account entities return the correct balance. Now calling the
Account::getBalance()
method will not occur the overhead of
loading all entries anymore. Adding a new Entry to the
Account::$entities
will also not initialize the collection
internally.
Adding a new entry is therefore very performant and explicitly hooked into the domain model. It will only update the account with the current balance and insert the new entry into the database.
Tackling Race Conditions with Aggregate Fields
Whenever you denormalize your database schema race-conditions can potentially lead to inconsistent state. See this example:
1 <?php
use Bank\Entities\Account;
// The Account $accId has a balance of 0 and a max credit limit of 200:
// request 1 account
$account1 = $em->find(Account::class, $accId);
// request 2 account
$account2 = $em->find(Account::class, $accId);
$account1->addEntry(-200);
$account2->addEntry(-200);
// now request 1 and 2 both flush the changes.
2
3
4
5
6
7
8
9
10
11
12
13
14
15
The aggregate field Account::$balance
is now -200, however the
SUM over all entries amounts yields -400. A violation of our max
credit rule.
You can use both optimistic or pessimistic locking to safe-guard your aggregate fields against this kind of race-conditions. Reading Eric Evans DDD carefully he mentions that the "Aggregate Root" (Account in our example) needs a locking mechanism.
Optimistic locking is as easy as adding a version column:
The previous example would then throw an exception in the face of whatever request saves the entity last (and would create the inconsistent state).
Pessimistic locking requires an additional flag set on the
EntityManager::find()
call, enabling write locking directly in
the database using a FOR UPDATE.
Keeping Updates and Deletes in Sync
The example shown in this article does not allow changes to the
value in Entry
, which considerably simplifies the effort to
keep Account::$balance
in sync. If your use-case allows fields
to be updated or related entities to be removed you have to
encapsulate this logic in your "Aggregate Root" entity and adjust
the aggregate field accordingly.
Conclusion
This article described how to obtain aggregate values using DQL or your domain model. It showed how you can easily add an aggregate field that offers serious performance benefits over iterating all the related objects that make up an aggregate value. Finally I showed how you can ensure that your aggregate fields do not get out of sync due to race-conditions and concurrent access.