Search by

patterns / unit

ecom

Unit pattern - a named, versioned identity for code that produces data, so every record can say which unit and which version made it.

Package info

github.com/patterns-php/unit

pkg:composer/patterns/unit

Statistics

Installs: 27

Dependents: 4

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.1 2026-10-11 02:46 UTC

This package is auto-updated.

Last update: 2026-10-11 02:48:11 UTC


README

A named, versioned identity for the code that produces your data.

Two interfaces turn any piece of code that writes something — a score, a price, a report row, a log line — into something whose output can be attributed later: by you, by a cron job, by an auditor, months after the code that produced it has been refactored twice.

Part of the Patterns collection: small, rock-solid, dependency-free building blocks. One pattern, one package.

  • Package: patterns/unit
  • Contracts: Patterns\IUnit, Patterns\IUnitSchema, Patterns\IOperator
  • Dependencies: none
  • PHP: >=8.1

Install

composer require patterns/unit

Contracts only — no implementation ships. Whatever a unit is in your codebase, it only has to be able to name and version itself.

The problem

Something in production computes a number, and later that number is wrong. The first question is always the same:

Which code produced this, and which version of it was running?

In most PHP codebases the answer lives in a git blame over a class that may have been renamed since, on a server whose checkout you are not sure about. The data itself knows nothing about where it came from. So you cannot tell whether a figure is stale because the input changed or because the formula changed — and you cannot safely re-run anything, because you do not know what produced what.

The pattern

namespace Patterns;

interface IUnit
{
    public static function create(array $props): self;
    public function dna(): IUnitSchema;
    public function whoami(): string;
}

interface IUnitSchema extends JsonSerializable
{
    public function id(): string;                       // stable name, never a class name
    public function version(): string;                  // semantic version of that name
    public function label(): string;                    // "SupplierRanking v1.1.0"
    public function description(): string;              // one sentence on what it is for
    public function toArray(): array;                   // the persisted shape
    public static function create(array $props): self;  // read back what toArray() wrote
}

Three methods for a unit, and its DNA. Nothing else.

create() is the only way in — never construct. dna() is the identity. whoami() is the voice.

Unit vs ValueObject

These are the two categories, and they are not alternatives — a unit often contains value objects.

ValueObject Unit
Identity its value its name — id() + version()
Two equal ones interchangeable still two versions of the same named thing
Serialization the whole object the DNA; the rest is the implementation's business
Lifetime created, compared, discarded created, versioned, deployed, referenced by stored data
Change over time a changed value is a different value the same name, a new version
Answers is this a valid value? which code produced this, and which version?

A Money(1000, 'EUR') is its data: two of them with the same amount and currency are the same thing, and it would be strange to ask which one was made first.

A ranking unit has a name. Two runs of the same unit producing different numbers is not a contradiction — it is the interesting case, and you need to know which version of the ranking you are looking at.

If you cannot say which version produced a number, it is not a value — it is an unattributed claim.

Boilerplate

Two files per unit. Copy them.

1. The DNA

<?php
declare(strict_types=1);

namespace App\Ranking;

use Patterns\IUnitSchema;

final class RankingSchema implements IUnitSchema
{
    /** @param array<string, mixed> $props */
    private function __construct(private readonly array $props)
    {
    }

    /** @param array<string, mixed> $props */
    public static function create(array $props): self
    {
        return new self([
            'id'          => (string) $props['id'],
            'version'     => (string) $props['version'],
            'label'       => (string) ($props['label'] ?? $props['id'] . ' v' . $props['version']),
            'description' => (string) ($props['description'] ?? ''),
        ]);
    }

    public function id(): string           { return $this->props['id']; }
    public function version(): string      { return $this->props['version']; }
    public function label(): string        { return $this->props['label']; }
    public function description(): string  { return $this->props['description']; }
    public function toArray(): array       { return $this->props; }
    public function jsonSerialize(): array { return $this->props; }
}

You may extend Patterns\ValueObject for equality and JSON for free — or not. The contract asks for six methods and nothing about your base class.

2. The unit

<?php
declare(strict_types=1);

namespace App\Ranking;

use Patterns\IUnit;
use Patterns\IUnitSchema;

final class RankingUnit implements IUnit
{
    public const VERSION = '1.1.0';

    /** The identity, declared once. */
    private const DNA = [
        'id'          => 'supplier-ranking',
        'version'     => self::VERSION,
        'description' => 'Ranks suppliers by weighted velocity.',
    ];

    /** @param array<string, mixed> $config */
    private function __construct(
        private readonly RankingSchema $dna,
        private readonly array $config,
    ) {
    }

    /** @param array<string, mixed> $props */
    public static function create(array $props): self
    {
        return new self(
            RankingSchema::create($props['dna'] ?? self::DNA),
            (array) ($props['config'] ?? []),
        );
    }

    public function dna(): IUnitSchema
    {
        return $this->dna;
    }

    public function whoami(): string
    {
        return $this->dna->label() . ' | ' . json_encode($this->config);
    }

    // ---- the actual work ----

    /** @return array{ranked: list<array<string, mixed>>} */
    public function rank(array $suppliers): array
    {
        // ...
        return ['ranked' => []];
    }
}

That is the whole pattern: a schema, a factory, the work. No base class, no registry, no capability table.

dna() is instance-only by contract, so the identity literal lives in a private constant instead of a second method. That gives exactly one place to change the identity - and avoids two methods competing for the same name.

DNA in practice

The reason DNA exists is that it can be persisted next to the data. A price intelligence system ranks products by a computed force; the number is only meaningful together with the algorithm that produced it.

// 1. write: attach the DNA of the unit that produced the row
$unit = RankingUnit::create(['config' => ['w_flow' => 1.0, 'w_cadence' => 0.8]]);

foreach ($suppliers as $supplier) {
    $rows[] = [
        'ean'   => $supplier['ean'],
        'force' => $unit->rank($supplier),
        'unit'  => $unit->dna()->toArray(),   // provenance, stored with the data
    ];
}
{
  "ean": "5901234123457",
  "force": 63723.0,
  "unit": {
    "id": "supplier-ranking",
    "version": "1.1.0",
    "label": "SupplierRanking v1.1.0",
    "description": "Ranks suppliers by weighted velocity."
  }
}
// 2. read: which version produced this row
$dna = RankingSchema::create($row['unit']);

$dna->label();       // "SupplierRanking v1.1.0"
$dna->version();     // "1.1.0" - the answer to "which code made this number?"
$dna->description(); // what that version was for

The version is the part that pays off, and it only works because it travels with the data: a figure can explain itself.

These numbers came from supplier-ranking v1.1.0.

What changed between two versions is the repository's job - the tag, the diff, the changelog. The row only has to name the version; git explains it. Copying a release ledger into every row would mean a table of price rows each carrying the full history of the algorithm that produced them, which is payload nothing reads. If a unit ever has to run several versions of itself at once, and pin each to versions of other units, that is a per-unit contract of its own - not the general shape.

Use cases where unit versioning is a game changer

Rankings, scores and reports. Comparing two runs is meaningless until you know the runs used the same formula version. With DNA you can group and diff by version instead of hoping.

Backfills and reprocessing. "Which rows are stale?" becomes a query — WHERE unit->>'version' = '1.0.0' — instead of a reasoning exercise about deployment dates. You re-run exactly what needs re-running.

Debugging production data. A wrong price, a strange score, a missing row: the row names the code and the version, so you read the right implementation instead of the current one.

Rollouts and A/B. Run 1.1.0 for a slice of traffic and compare outcomes, because every result carries which side it came from. Rolling back does not rewrite history — old rows keep their truth.

Audit and compliance. "Who or what produced this number, when, and under which rules?" is answered from the data itself, not from logs that have rotated.

Multi-service and multi-language systems. The id is a plain string, not a class name, so a service in another language can read and write the same provenance. Renaming or moving a class never breaks the trail.

Observability. A unit that logs can tag every line with its own DNA, so a log stream tells you which version of which component is speaking.

What is deliberately not in this pattern

Not here Where it belongs
props(), toArray() on the unit the implementation — raw state is its own business
params(), help() discoverability and documentation, a different axis from identity
capabilities, registries, execute() composition is a constructor; dispatch is the caller's job
teach / learn, runtime capability transfer a registry's problem, not a unit's
events, emitters, channels compose patterns/event-channel when you need it
validators, builders, base classes the pattern asks for six methods; supply the rest yourself

The contract is intentionally narrow so that a unit can be anything: a strongly typed class with readonly props, an array-backed record, a database row, a remote proxy, an enum case.

Implementing a unit

  1. Name it. Pick an id you will never want to change — a name, not a class.
  2. Version it. Semantic version. Change it whenever the output can change.
  3. Bump it when the output can change. Then any stored record can name the code that produced it. What changed goes in the changelog and the tag.
  4. Make create() the only entry point. No public constructor.
  5. Persist the DNA with the data. A version you did not store is a version you cannot recover.
  6. Depend on IUnit, never on a concrete unit. The contract stays out of signatures; implementations stay replaceable.

Tests

composer install
composer test
# or
vendor/bin/phpunit

The suite pins the shape of both interfaces (it fails if either grows), proves they are satisfiable without extending anything, and exercises the provenance round-trip.

License

MIT.