DEV Community

Cover image for Your architecture rules are code too: mutation-testing a DDD ruleset in PHP 8.4
Szj
Szj

Posted on

Your architecture rules are code too: mutation-testing a DDD ruleset in PHP 8.4

In September 2017 Lukas Taake published the first part of a write-up of his master's thesis about static code analysis for Domain-Driven Design. It was built as a SonarQube plugin for Java, and its thesis is the most honest sentence in the genre: static analysis cannot show that DDD is done correctly; it can help keep some basic principles intact over time.

I wanted to know what that looks like in PHP today, nine years later. Two things have changed: the language now enforces a lot by itself (readonly, enums, asymmetric visibility, attributes), and we have mutation testing, which gives us a way to ask the question the 2017 post could not: if someone breaks a rule, would your checks notice?

Everything below was run, not described. The demo domain is small (13 files), the mutants are hand-written, and I list the limits at the end.

The eight rules, then and now

Taake's rules, as I read them in his post:

  1. Every entity type has an identity.
  2. Value objects are immutable.
  3. No anemic domain model (entities that are only getters and setters).
  4. Aggregates are trees and link to other aggregates only by identity.
  5. An aggregate has a maximum size.
  6. Domain services exist only where behaviour cannot sit in an entity.
  7. Repository implementations live outside the domain model.
  8. The domain model depends on nothing else in the application.

The 2017 approach discovers the "model parts" by class hierarchy, name patterns or annotations, and the author admits the weak spot: you must tell the tool what is an entity and what is a value object, and the analysis is only as good as that configuration. In PHP 8 an attribute is the natural answer, because it is part of the class and the compiler sees it:

namespace App\Sales\Domain;

#[\Attribute(\Attribute::TARGET_CLASS)] final class AggregateRoot {}
#[\Attribute(\Attribute::TARGET_CLASS)] final class Entity {}
#[\Attribute(\Attribute::TARGET_CLASS)] final class ValueObject {}

#[ValueObject]
final readonly class Money
{
    public function __construct(public int $amount, public string $currency) {}
    public function add(self $o): self { return new self($this->amount + $o->amount, $this->currency); }
}

#[AggregateRoot]
final class Customer
{
    public function __construct(private CustomerId $id, private string $name) {}
    public function id(): CustomerId { return $this->id; }
    public function rename(string $name): void
    {
        if ($name === '') { throw new \DomainException('empty name'); }
        $this->name = $name;
    }
}
Enter fullscreen mode Exit fullscreen mode

The demo domain has two aggregate roots (Customer with Address value objects, Order with OrderLine entities), value objects for money, ids and the order number, an OrderRepository interface in the domain and an implementation in Infrastructure. Customer and order refer to each other by id (CustomerId, OrderId), as rule 4 asks.

Versions used: PHP 8.4.16, PHPStan 2.3.5, phpat 0.12.5, Deptrac 4.7.2, PHP Arkitect 1.3.1.

Rule 2 first, because PHP does half of it

final readonly class Money makes the compiler and the engine refuse most of what rule 2 was written for. Writing to a readonly property from outside, or from a setter, throws:

Error: Cannot modify readonly property Money::$amount
Enter fullscreen mode Exit fullscreen mode

Two caveats, both worth knowing:

  • This is a runtime error. The code that does it is still valid PHP; the engine only complains when it runs. A static check is still useful (PHPStan reports an assignment to a readonly property outside the constructor on its own, with no custom rule).
  • Asymmetric visibility (public private(set) int $amount, PHP 8.4) is not immutability. The class itself may still change the value: Cannot modify private(set) property appears only for a write from outside.

And one that no keyword fixes: readonly is shallow. A readonly value object holding an ArrayObject is still mutable through it. More on that below.

Rules 1 and 4: where only a custom rule helps

Layer tools know about namespaces; they know nothing about aggregates or identity. For rule 4 (an aggregate root must not hold another aggregate root), a PHPStan rule is about twenty lines:

/** @implements Rule<InClassNode> */
final class AggregateReferencesByIdRule implements Rule
{
    public function __construct(private ReflectionProvider $rp) {}
    public function getNodeType(): string { return InClassNode::class; }

    public function processNode(Node $node, Scope $scope): array
    {
        $class = $node->getClassReflection();
        if (!Marker::has($class, 'AggregateRoot')) { return []; }
        $errors = [];
        foreach ($class->getNativeReflection()->getProperties() as $p) {
            $type = $class->getProperty($p->getName(), $scope)->getReadableType();
            foreach ($type->getReferencedClasses() as $name) {
                if ($name !== $class->getName() && $this->rp->hasClass($name)
                    && Marker::has($this->rp->getClass($name), 'AggregateRoot')) {
                    $errors[] = RuleErrorBuilder::message(sprintf(
                        'Aggregate root %s holds another aggregate root %s in $%s; reference it by id.',
                        $class->getName(), $name, $p->getName()))
                        ->identifier('ddd.aggregateByObject')->build();
                }
            }
        }
        return $errors;
    }
}
Enter fullscreen mode Exit fullscreen mode

Marker::has() is a three-line helper that looks for the attribute on a class. Rule 1 (an entity has a public id() method) is the same shape: if a class is marked #[Entity] or #[AggregateRoot] and has no public id(), report it. The rules are registered in phpstan.neon like any other:

parameters:
    level: 8
    paths: [src]
services:
    - { class: Rules\AggregateReferencesByIdRule, tags: [phpstan.rules.rule] }
    - { class: Rules\EntityHasIdentityRule, tags: [phpstan.rules.rule] }
    - { class: Rules\ValueObjectIsImmutableRule, tags: [phpstan.rules.rule] }
Enter fullscreen mode Exit fullscreen mode

Rules 7 and 8: layers, with two tools that disagree

Deptrac states the dependency direction as data (use the deptrac/deptrac package; the old qossmic/deptrac is flagged as abandoned by Composer):

deptrac:
  paths: [./src]
  layers:
    - name: Domain
      collectors: [{ type: classLike, value: '^App\\Sales\\Domain\\.*' }]
    - name: Application
      collectors: [{ type: classLike, value: '^App\\Sales\\Application\\.*' }]
    - name: Infrastructure
      collectors: [{ type: classLike, value: '^App\\Sales\\Infrastructure\\.*' }]
    - name: Doctrine
      collectors: [{ type: classLike, value: '^Doctrine\\.*' }]
  ruleset:
    Domain: ~
    Application: [Domain]
    Infrastructure: [Domain, Application, Doctrine]
Enter fullscreen mode Exit fullscreen mode

phpat expresses architecture rules as PHP, as a PHPStan extension:

public function testDomainDependsOnNothing(): PHPatRule
{
    return PHPat::rule()
        ->classes(Selector::inNamespace('App\Sales\Domain'))
        ->canOnlyDependOn()
        ->classes(
            Selector::inNamespace('App\Sales\Domain'),
            Selector::classname('/^[^\\\\]+$/', true), // PHP built-ins in the global namespace
        )
        ->because('the domain depends on nothing else');
}

public function testRepositoryImplementationIsNotInDomain(): PHPatRule
{
    return PHPat::rule()
        ->classes(Selector::inNamespace('App\Sales\Domain'))
        ->shouldNotImplement()
        ->classes(Selector::classname(OrderRepository::class))
        ->because('repository implementations live outside the domain model');
}
Enter fullscreen mode Exit fullscreen mode

(The regular expression lets DomainException and other global classes through, otherwise phpat flags them as outside dependencies.) I ran PHP Arkitect on the dependency and immutability rules too; it caught the same mutants as the other two there.

Rules 3 and 5: a number you choose

I did not find a standard tool for "anemic entity" or "aggregate too big", so the demo has a thirty-line script. It counts, per #[Entity] or #[AggregateRoot] class, behaviour methods against getters and setters, and the members (entities and value objects) reachable inside an aggregate:

anemia  App\Sales\Domain\Customer   behaviour=3 accessors=3 share=0.50 ok
anemia  App\Sales\Domain\Order      behaviour=2 accessors=3 share=0.40 ok
size    App\Sales\Domain\Order      members=3 (max 5) ok [Money,OrderLine,OrderNumber]
Enter fullscreen mode Exit fullscreen mode

Both thresholds are arbitrary and that is the point of rules 3 and 5: they are project decisions, written down and enforced, not facts about DDD.

The question the rules cannot answer about themselves

All of the above is green on the clean domain. That says nothing about whether the rules would go red on a violation. This is exactly the question mutation testing answers for code: change the code a little, run the tests, and count the changes nobody noticed. The idea carries over to a ruleset. A mutant here is a violation of one rule, applied to a copy of the domain as a textual patch, and the check is whether any tool exits with an error.

The runner is short: copy the clean src/, apply one patch, run every tool, record "caught" when its exit code is not zero, restore, repeat. A mutant looks like this:

'M3  Customer holds an Order object' => function () use ($D) {
    edit($D . 'Customer.php', 'public function id(): CustomerId',
        'private ?Order $lastOrder = null;' . "\n    " .
        'public function lastOrder(): ?Order { return $this->lastOrder; }' . "\n    " .
        'public function id(): CustomerId');
},
Enter fullscreen mode Exit fullscreen mode

Twelve mutants, seven checks (php -l, a runtime probe that instantiates the value object and tries to change it, PHPStan with the three custom rules, Deptrac, phpat, Arkitect, the metrics script):

Mutant probe PHPStan + rules Deptrac phpat Arkitect metrics
M1 setter on a (non-readonly) VO caught caught caught caught
M1b setter on a readonly VO the language caught
M2 public mutable VO property caught caught caught caught
M3 Customer holds an Order object caught
M4 domain imports Doctrine caught caught caught
M5 entity without identity caught
M6 repository implementation in the domain caught
M7 anemic entity caught
M8 aggregate over the size limit caught
M9 an equivalent rewrite (+=)
M10 readonly VO holding a mutable object
M11 an invariant dropped

php -l caught nothing, because every mutant is valid PHP.

What the table says

  • Different tools see different mutants. M3 and M5 are caught only by my two custom PHPStan rules; the layer tools know nothing about aggregates or identity. M7 and M8 are caught only by the metrics script. M6, a repository implementation put into the domain namespace, is caught only by phpat: Deptrac sees a domain class implementing a domain interface, which is an allowed dependency. I did not write an Arkitect rule for it, and it can probably do the same. No single tool has the whole picture, which is an argument for a small set instead of a big one.
  • The language is the cheapest rule. M1b is stopped by PHP at runtime and by PHPStan statically without any custom rule; final readonly class plus an attribute is enough for most of rule 2.
  • Three mutants slip through everything, and each has a different reason:
    • M9 is equivalent: a rewrite that changes nothing observable. Correctly caught by nothing. This is the same class as in ordinary mutation testing.
    • M10 is a real blind spot: readonly is shallow, and a value object that holds a mutable object is still mutable. A rule could look at property types, but then you have to decide what counts as mutable.
    • M11 drops an invariant (a validation in the constructor). That is behaviour, and static rules cannot see it. Only unit tests, or mutation testing of the domain's own tests, will.

That last point is where the two kinds of mutation meet: architecture rules protect the shape of the model, and classic mutation testing (Infection in PHP) protects the behaviour inside it. A rule set with a high detection rate and a domain test suite with a high mutation score cover different failures. I did not run Infection on this toy domain; with 13 files it would prove nothing.

Limits, honestly

  • 13 files and 12 hand-written mutants: this is a demonstration of a method, not a benchmark of the tools. Do not read the table as "phpat beats Deptrac".
  • The thresholds (an anemia share, five members per aggregate) are arbitrary.
  • The mutants are textual patches. Real violations are subtler, and generating them automatically, the way mutation tools do for code, is the interesting next step.
  • Everything ran on one machine (Windows, PHP 8.4). The runner (php bin/mutants.php, about three minutes) restores the sources afterwards.
  • The 2017 post's own limitation stands: no tool tells you that your aggregate boundaries are right. These checks keep a model from drifting; they do not design it.

References

I maintain a PostgreSQL search library, Fuzzphony, and the habit of asking "would a test notice if this were wrong?" came from there; I wrote about what a 95% mutation score still misses in the previous post. If you run something like this on a real codebase, I would like to hear which mutants your rules missed.

Top comments (0)