Value Objects

Value Objects in PHP können Sie vor schlechten Daten schützen

vg

Eines der Dinge, auf die wir bei der Arbeit mit objektorientierter Programmierung (OOP) achten müssen, ist, dass die Daten, mit denen wir arbeiten, jederzeit gültig sind. Dies kann erreicht werden, indem wir alle Validierungen hinzufügen, die wir im Konstruktor der Objekte benötigen, die wir bearbeiten werden. Die Prämisse ist einfach: Wenn die Daten gültig sind, erhalten Sie das Objekt. Andernfalls erhalten Sie eine AusnahmeValue Objects können von dieser Technik stark profitieren.

Eine gute Möglichkeit, die Vorteile von Value Objects zu erkennen, besteht darin, Code mit und ohne Value Objects zu vergleichen. Lassen Sie uns also ein Szenario festlegen, um ein paar Beispiele zu zeigen.

Einrichten von Code ohne Value Objects

Lassen Sie uns ein einfaches Szenario einrichten. Wir können eine Task-Entität erstellen, die von einem TaskRepository abgerufen und beibehalten werden kann. Ein Repository ist ein gängiges Muster zum Abrufen und Beibehalten von Entitäten aus einem Speicher. Die Anwendung weiß nicht, wo oder wie Entitäten gespeichert sind, und sollte es auch nicht. Aber wie wir sehen werden, bedeutet das nicht, dass der Entwickler sich der Auswirkungen auf die Leistung durch die Verwendung des Musters nicht bewusst sein sollte.

final class Task {
    public function __construct(
        private TaskId $id,
        private string $status,
        private DateTimeImmutable $scheduledAt
    ) {
        Assert::oneOf($status, ['pending', 'done', 'missed']);
    }
}

interface TaskRepository
{
    public function get(TaskId $id): Task;
    public function save(Task $task): void;
}

Beachten Sie, dass der Status in der Task-Entität ein String-Primitiv ist. Wir validieren es, wenn wir die Entität erschaffen, aber wir werden dies als einen Fall von etwas sehen, was man als primitive Besessenheit bezeichnen kann, und zeigen, wie dies zu zukünftigen Problemen führen kann. Und wir können die tatsächliche Implementierung des Repositorys ignorieren, nehmen wir an, es steckt eine Datenbank dahinter, aber die Details sind hier aus dem Kontext gerissen.

Nun können wir anhand von zwei Zuweisungen zeigen, wie Value Objects eingeführt werden können.

Erste Aufgabe: Befehl zum Abschließen einer Aufgabe

Die erste, einfach genug, besteht darin, einen Befehl zu erstellen, der eine Aufgabe abschließt, wodurch der Status auf „erledigt“ geändert wird, was bereits einer der akzeptierten Status in der Aufgabenentität ist.

Wir können dies tun, indem wir eine Aufgabe aus dem Repository abrufen, die Änderung mit einer geeigneten Domänenmethode anwenden und sie dann mit den Änderungen wieder speichern. Fügen wir also die Methode zur Domäne hinzu.

final class Task {
public function __construct(
private TaskId $id,
private string $status,
private DateTimeImmutable $scheduledAt
) {
Assert::oneOf($status, ['pending', 'done', 'missed']);
}

public function complete(): void
{
$this->status = 'done';
}
}

Erstellen Sie dann den Befehl und den Handler.

final readonly class CompleteTaskCommand
{
public function __construct(public string $taskId) {}
}

final readonly class CompleteTaskCommandHandler
{
public function __construct(private TaskRepository $taskRepository) {}

public function __invoke(CompleteTaskCommand $command): void
{
$task = $this->taskRepository->get(
new TaskId($command->taskId)
);

$task->complete();
$this->taskRepository->save($task);
}
}

Fertig! Das war einfach. Wenn wir uns jedoch die vollständige Methode in der Task-Entität genau ansehen, werden wir feststellen, dass es keine Validierung für den neuen Statuswert gibt, der zugewiesen wird. Es wäre seltsam, eine Assertion für einen statischen Wert hinzuzufügen, und wir sollten Assertions sowieso nicht duplizieren. Aber was passiert, wenn wir eine Änderung in der Liste der Werte für den Status vornehmen und diese Methode vergessen? Hoffentlich werden Unit-Tests es erkennen, aber es ist immer noch etwas, das in Zukunft Probleme verursachen kann. Wir werden einen besseren Weg finden.

Zweite Aufgabe: Markieren Sie alle Aufgaben nach dem geplanten Datum als verpasst

Wie auch immer, nun zum zweiten: Markieren Sie alle Aufgaben, die ihr geplantes Datum überschritten haben, als „verpasst“. Der Einfachheit halber können wir andere Bedingungen wie den bereits vorhandenen Status ignorieren. Nun, für diesen könnten wir auch das Repository verwenden. Nehmen wir an, wir haben eine neue scheduledBeforeMethode eingebaut, um alle Aufgaben zu erhalten, die vor einem bestimmten Zeitstempel geplant sind.

interface TaskRepository
{
    public function get(TaskId $id): Task;
    public function save(Task $task): void;
    /**
     * @return Task[]
     */
    public function scheduledBefore(DateTimeImmutable $before): array;
}

Die Aufgabenentität benötigt außerdem eine neue Methode, um sie als vermisst zu markieren.

final class Task {
public function __construct(
private TaskId $id,
private string $status,
private DateTimeImmutable $scheduledAt)
{
Assert::oneOf($status, ['pending', 'done', 'missed']);
}

public function complete(): void
{
$this->status = 'done';
}

public function miss(): void
{
$this->status = 'missed';
}
}

Und schließlich der Befehl und der Handler.

final readonly class MarkMissedTasksCommand
{
    public function __construct() {}
}

final readonly class MarkMissedTasksCommandHandler
{
    public function __construct(private TaskRepository $taskRepository) {}

    public function __invoke(MarkMissedTasksCommand $command): void
    {
        $now = new DateTimeImmutable();
        $tasks = $this->taskRepository->scheduledBefore($now);

        foreach ($tasks as $task) {
            $task->miss();
            $this->taskRepository->save($task);
        }
    }
}

Der Befehl sieht nicht viel anders aus als der andere, oder? Anstatt ein Update durchzuführen, werden mehrere durchgeführt. Aber an dieser Stelle sollten wir darüber nachdenken, was sich hinter der Repository-Schnittstelle verbirgt. Die scheduledBeforeMethode führt wahrscheinlich eine SELECT-Abfrage in einer relationalen Datenbank aus, die alle übereinstimmenden Zeilen über ein internes Netzwerk an die App-Laufzeit zurückgibt. Dann wird jeder Speichervorgang wahrscheinlich eine zusätzliche Anforderung an die Datenbank ausführen, um die UPDATE-Abfragen durchzuführen.

Dies wird kein Problem sein, wenn wir erwarten, dass wir jedes Mal, wenn wir den Befehl ausführen, ein paar Aufgaben haben, aber was ist, wenn wir mit Hunderten oder Tausenden von ihnen umgehen müssen? In diesem Fall gibt die scheduledBeforeMethode eine große Datenmenge über das Netzwerk zurück, was sich auf die Antwortzeit auswirkt. Dann müssen die Daten in den App-Speicher geladen werden. Und dann fügt jede der „Save“-Anforderungen, auch wenn sie in der Datenbank schnell sind, ihre Latenz zur Befehlszeit hinzu.

Umgestalten mithilfe eines Update-Service

Das funktioniert also, die Daten sind gültig, aber sie skalieren nicht. Welche andere Möglichkeit haben wir?

Wir können über einen spezialisierten Service nachdenken. Diese Schnittstelle empfängt das Datum und ruft keine Daten ab, sondern sendet nur das Update in einer einzigen Abfrage an den Speicher, wahrscheinlich eine relationale Datenbank. Dies wird die Leistung erheblich verbessern, indem die Bandbreite der Daten gespart wird, die wir nicht abrufen werden, und die Latenz für alle Speicheranfragen reduziert wird, die durch eine einzige ersetzt werden. Um es flexibler zu machen, machen wir auch den neuen Status zu einem Parameter, damit wir andere Zustandsänderungen mit demselben Dienst vornehmen können. Klingt gut, sehen wir uns die Benutzeroberfläche und den neuen Befehlshandler an.

interface UpdateAllTaskStatus {
    public function before(DateTimeImmutable $date, string $newStatus): void;
}

final readonly class MarkMissedTasksCommandHandler
{
    public function __construct(private UpdateAllTaskStatus $updateAllTaskStatus) {}

    public function __invoke(MarkMissedTasksCommand $command): void
    {
        $now = new DateTimeImmutable();
        $this->updateAllTaskStatus->before($now, 'Missed');
    }
}

Hinweis: In PHP können wir dies normalerweise tun, ohne viel darüber nachzudenken, da jede Anfrage zustandslos ist, sodass wir normalerweise davon ausgehen können, dass wir keine Entitäten im Speicher haben, wenn wir diesen Dienst verwenden. Wenn wir dies jedoch aus irgendeinem Grund tun, benötigen wir eine Möglichkeit, die vorhandenen Entitäten zu aktualisieren, damit sie das Update erhalten. Möglicherweise kann ein Ereignis ausgelöst werden, um einem Listener zu ermöglichen, die Entitäten zu aktualisieren oder sie ungültig zu machen, sodass sie nicht mehr verwendet werden, sondern erneut abgerufen werden.

Diese Lösung ist viel einfacher, und die Implementierung könnte mit einer einfachen UPDATE-Abfrage mit einem einzigen WHERE implementiert werden. Wir haben auch das Skalierungsproblem gelöst, aber hier gibt es ein Problem. Das wird nicht funktionieren. Die Daten sind ungültig und haben die Domänenvalidierung umgangen. Der Status in diesem Code wird zuerst in Großbuchstaben geschrieben, wenn die Task-Entität einen Wert in Kleinbuchstaben vom Typ „all“ überprüft.

Code-Smells

Dies ist die Zeit, in der wir uns an die Erwähnung der primitiven Besessenheit erinnern und darüber nachdenken, wie die Adressierung dieses Code-Smells uns helfen könnte, diesen neuen Dienst mit einer angemessenen Datenvalidierung zu nutzen. Natürlich könnten wir stattdessen die Validierung im Befehl oder in der Serviceimplementierung duplizieren, aber das sind keine guten Optionen, das Duplizieren von Geschäftsregeln sollte ein No-Go sein. Verschieben wir stattdessen die Validierung in ein Value Object.

Einführung in das Value Object

Versuchen wir, es besser zu machen, indem wir ein Value Object verwenden, das hauptsächlich zwei Vorteile bietet: Absicht und Kapselung. Absicht, weil beim Übergeben einer Zeichenfolge alles Mögliche sein kann und Sie beobachten müssen, um zu verstehen, was ihr Zweck ist, während beim Übergeben eines TaskStatus nur eine Sache sein kann. Und Kapselung, weil es alles, was mit dem Aufgabenstatus zu tun hat, am selben Ort halten kann, was die Arbeit erleichtert.

final readonly class TaskStatus {
public function __construct(string $status) {
Assert::oneOf($status, ['pending', 'done', 'missed']);
}

public static function done(): self
{
return new self('done');
}

public static function missed(): self
{
return new self('missed');
}
}

Nett! Jetzt haben wir einen wiederverwendbaren Typ, der die eindeutige Absicht kommuniziert, einen gültigen Aufgabenstatus und nichts weiter zu enthalten. Beachten Sie die benannten Konstruktoren für die Anwendungsfälle, die wir haben. Als nächstes können wir das Primitiv in der Entität und in unserem neuen Dienst ersetzen.

final class Task {
public function __construct(
private TaskId $id,
private TaskStatus $status,
private DateTimeImmutable $scheduledAt
) {}

public function complete(): void
{
$this->status = TaskStatus::done();
}

public function miss(): void
{
$this->status = TaskStatus::missed();
}
}

interface UpdateAllTaskStatus {
public function before(DateTimeImmutable $date, TaskStatus $newStatus): void;
}

Das Letzte, was wir tun müssen, ist, den Befehlshandler zu aktualisieren, und wir sind fertig.

final readonly class MarkMissedTasksCommandHandler
{
public function __construct(private UpdateAllTaskStatus $updateAllTaskStatus) {}

public function __invoke(MarkMissedTasksCommand $command): void
{
$now = new DateTimeImmutable();
$this->updateAllTaskStatus->before($now, TaskStatus::missed());
}
}

Damit haben wir unsere beiden Probleme behoben. Die Überprüfung des Aufgabenstatus wird nicht wiederholt oder irgendwo übersehen, solange wir das value-Objekt verwenden. Und für jeden Dienst, der einen Aufgabenstatus verwenden muss, ist dieser verfügbar, sodass er nie einen ungültigen Wert verwendet.

Hinweis: In neueren PHP-Versionen könnte dieses Value Object alternativ als Enum implementiert werden.

FAZIT

Value Objects sind eine großartige Ergänzung zu unserem Werkzeugkasten, die es uns ermöglicht, überall in unserem Code mit einem sinnvollen und gültigen Wert zu arbeiten. Wenn wir später den Wert irgendwie ändern oder verwandte Funktionen hinzufügen müssen, können wir sicher sein, dass es nur einen Ort zum Aktualisieren gibt.

Zu einem weiteren interessanten Thema: Repository Pattern

com

Newsletter Anmeldung

Bleiben Sie informiert! Wir informieren Sie über alle neuen Beiträge (max. 1 Mail pro Woche – versprochen)