Entwickler, der außergewöhnliche CRM- und Laravel-Lösungen liefert

Als erfahrener Entwickler spezialisiere ich mich auf Laravel- und Vue.js-Entwicklung, die Implementierung von Vtiger CRM sowie auf vielfältige WordPress-Projekte. Meine Arbeit zeichnet sich durch kreative, dynamische und benutzerzentrierte Weblösungen aus, die individuell an die Bedürfnisse meiner Kunden angepasst werden.

Das Zustandsmuster gehört zu den besten Möglichkeiten, um einer Modellklasse zusätzliche Geschäftslogik hinzuzufügen und gleichzeitig die Verwaltung ihres Zustands sauber und übersichtlich zu gestalten – ohne dabei gegen die Prinzipien von Clean Code zu verstoßen.

Praktische Anwendung des Zustandsmusters (State Pattern)

Das Zustandsmuster gehört zu den besten Möglichkeiten, um einer Modellklasse zusätzliche Geschäftslogik hinzuzufügen und gleichzeitig die Verwaltung ihres Zustands sauber und übersichtlich zu gestalten – ohne dabei gegen die Prinzipien von Clean Code zu verstoßen.

In diesem Artikel zeige ich ein Beispiel aus einem meiner Projekte, in dem ich in einer CRM-Lösung auf Laravel den Zustand von Modellen verwalte. Am Ende erhalten Sie einen weiteren Ansatz, um Geschäftslogik in separate Klassen auszulagern. Wie viele andere Entwickler bevorzuge ich es, Controller und Modelle möglichst schlank und von der Kernlogik des Projekts getrennt zu halten.

Doch was tun, wenn es darum geht, die Geschäftslogik in separate Klassen auszulagern – und dabei stellt sich die Frage: Wie verwaltet man den Zustand der Modelle?

Betrachten wir als Beispiel ein CRM-System, in dem der zentrale Baustein der "Deals" (Geschäfte) sind. Diese können verschiedene Stati aufweisen, wie etwa "Vorbereitung eines Angebots", "Verhandlung der Bedingungen", "Warten auf Antwort" usw. Abhängig vom Status kann ein Deal unterschiedlich agieren – sei es der berechnete Ratingwert, der prognostizierte Deal-Wert oder das Datum des nächsten Kontakts. Aber wie können wir in einem solchen Fall die Geschäftslogik direkt mit dem Zustand des Modells verknüpfen?

Genau hier kommt das Zustandsmuster ins Spiel, das eine sehr mächtige Funktionalität bietet. Nehmen wir als Beispiel einen Deal, der drei Phasen durchläuft: "In Verhandlung", "Angebot unterbreitet" und "Erfolgreich abgeschlossen".

Wenn wir den Ansatz wählen, bei dem die gesamte Logik direkt in der Modellklasse untergebracht ist, könnte der Code folgendermaßen aussehen:

class Potential extends Model
{
    public function getNextContactDate(): ?Carbon
    {
        if ($this->stage->value == PotentialStage::NEGOTIATION) {
            return $this->last_contact_date->addDays(3);
            // Hier kann zusätzliche Logik zur Datumsberechnung stehen
        }
        if ($this->stage->value == PotentialStage::PROPOSAL) {
            return $this->last_contact_date->addDays(5);
        }
        return null;
    }
}

In diesem Beispiel verwenden wir einen ENUM-Klasse, um den Zustand des Objekts zu speichern. Eine einfache Variante besteht darin, die Logik zur Berechnung des nächsten Kontaktdatums in diese ENUM-Klasse zu verschieben:

<?php

class PotentialStage extends Enum
{
    public const NEGOTIATION = 'negotiation';
    public const PROPOSAL = 'proposal';

    public function getNextContactDate(Carbon $last_contact_date): ?Carbon
    {
        if ($this->value == self::NEGOTIATION) {
            return $last_contact_date->addDays(3);
        }
        if ($this->value == self::PROPOSAL) {
            return $last_contact_date->addDays(5);
        }
        return null;
    }
}

Die Modellklasse greift dann einfach auf diese Logik zu:

class Potential extends Model
{
    public function getNextContactDate(): ?Carbon
    {
        return $this->state->getNextContactDate($this->last_contact_date);
    }
}

Dies ist der einfachste Ansatz – man prüft in if/else- oder switch/case-Blöcken, welcher Zustand aktuell vorliegt, und führt je nach Fall die entsprechenden Berechnungen durch. Mit zunehmender Komplexität verschlechtert sich diese Struktur jedoch, und die Lesbarkeit leidet erheblich.

Um dieser Problematik zu begegnen, können wir die Logik in separate Klassen auslagern. Dazu erstellen wir zunächst eine abstrakte Klasse PotentialStage, die alle Funktionen, die eine bestimmte Phase bereitstellen soll, zusammenfasst – in diesem Beispiel berechnet sie zunächst nur das Datum des nächsten Kontakts, aber es kann problemlos erweitert werden um Logik zur Ermittlung des Ratings, des prognostizierten Deal-Werts und so weiter.

abstract class PotentialStage
{
    protected Potential $potential;
    
    public function __construct(Potential $potential)
    {
        $this->potential = $potential;
    }
    
    abstract public function getNextContactDate(): ?Carbon;
}

Anschließend definieren wir drei Klassen, die jeweils einen spezifischen Zustand abbilden:

class NegotiationPotentialState extends PotentialStage
{
    public function getNextContactDate(): ?Carbon
    {
        return $this->potential->last_contact_date->addDays(3);
    }
}

class ProposalPotentialState extends PotentialStage
{
    public function getNextContactDate(): ?Carbon
    {
        return $this->potential->last_contact_date->addDays(5);
    }
}

class ClosedPotentialState extends PotentialStage
{
    public function getNextContactDate(): ?Carbon
    {
        return null;
    }
}

Ein wesentlicher Vorteil dieses Ansatzes besteht in der leichten Testbarkeit der einzelnen Klassen. Beispielsweise könnte ein Unit-Test für den ClosedPotentialState so aussehen:

class PotentialStateTest extends TestCase
{
    public function test_null_last_contact_date_in_closed_stage(): void
    {
        $potential = Potential::factory()->createOne(['stage' => 'Closed']);
        $state = new ClosedPotentialState($potential);
        $this->assertNull($state->getNextContactDate());
    }
}

Doch das Beispiel mit dem nächsten Kontaktdatum ist nur ein Teil der Möglichkeiten, die das Zustandsmuster bietet. Stellen Sie sich vor, es gäbe eine Logik, die prüft, ob einem Deal zusätzliche Kontakte hinzugefügt werden müssen. Die Anzahl und Art der Kontakte könnte beispielsweise von der Phase, dem Deal-Wert oder weiteren Parametern abhängen. So könnte es sein, dass im Zustand "Angebot unterbreitet" mindestens zwei Kontakte vorliegen müssen, während im Zustand "In Verhandlung" andere Bedingungen gelten – und im Falle spezieller Quellen, wie etwa einem "Ausschreibungsverfahren", sogar andere Mindestvorgaben bestehen.

Um diese Geschäftslogik nochmals zu isolieren, erweitern wir unsere abstrakte Klasse und fügen eine Methode hinzu:

abstract class PotentialStage
{
    protected Potential $potential;
    
    public function __construct(Potential $potential)
    {
        $this->potential = $potential;
    }
    
    abstract public function getNextContactDate(): ?Carbon;
    abstract public function isNeedMoreContacts(): bool;
}

Die Implementierung der Methode isNeedMoreContacts() erfolgt dann in den konkreten Klassen:

class NegotiationPotentialState extends PotentialStage
{
    public function isNeedMoreContacts(): bool
    {
        return $this->potential->contacts->count() < 3;
    }
}

class ProposalPotentialState extends PotentialStage
{
    public function isNeedMoreContacts(): bool
    {
        if ($this->potential->type == 'Tender') {
            return $this->potential->contacts->count() < 4;
        }
        return $this->potential->contacts->count() < 5;
    }
}

class ClosedPotentialState extends PotentialStage
{
    public function isNeedMoreContacts(): bool
    {
        return $this->potential->contacts->firstWhere('position', 'Director') === null;
    }
}

Die Deal-Klasse (Potential) greift dann einfach auf den jeweiligen Zustand zu:

class Potential extends Model
{
    public function getStageAttribute(): PotentialStage
    {
        return new $this->stage_class($this);
    }
    
    public function isNeedMoreContacts(): bool
    {
        return $this->state->isNeedMoreContacts();
    }
}

Die tatsächliche Implementierung setzt voraus, dass in der Datenbank das Feld state_class gespeichert wird – dadurch wissen wir, welche konkrete Zustand-Klasse angewendet werden soll.

Das Zustandsübergangs-Muster

Die bloße Implementierung des Zustandsmusters entspricht dabei erst der halben Lösung. Es bleibt noch die Frage der Zustandsübergänge: Wie lassen sich Zustandsänderungen so steuern, dass sie immer in der vorgesehenen Reihenfolge erfolgen? Beispielsweise sollte ein Deal nach der Phase "Bedingungen abgestimmt" nicht direkt in "Erfolgreich abgeschlossen" übergehen, ohne dass Zwischenphasen wie "Verhandlung" oder "Angebot unterbreitet" durchlaufen wurden.

Ein solider Ansatz besteht darin, für die Zustandsübergänge ebenfalls separate Klassen zu verwenden. Diese Klassen prüfen ausschließlich, ob ein Zustandswechsel – etwa von "Abgestimmt" zu "Erfolgreich abgeschlossen" – zulässig ist, und führen den Übergang durch, sofern alle Bedingungen erfüllt sind. Dabei sollten unerwünschte Nebenwirkungen wie Datenbankänderungen oder das Versenden von Nachrichten vermieden werden. Die Zustandsübergangs-Klassen dürfen ausschließlich die Logik zur Überprüfung und Durchführung des Zustandswechsels enthalten.

Ein Beispiel für einen solchen Transition-Klassen-Ansatz könnte so aussehen:

class ApprovalToCloseTransition
{
    public function __invoke(Potential $potential): Potential
    {
        if ($potential->isNeedMoreContacts()) {
            throw new InvalidTransitionException(self::class, $potential);
        }
    
        $potential->stage_class = ClosedPotentialState::class;
        $potential->save();
    
        info($potential, 'Closed successfully');
    
        return $potential;
    }
}

Mit diesem Muster können Sie:

  • Alle möglichen Zustandsübergänge Ihrer Modelle deklarativ in separaten Klassen zusammenfassen.
  • Abhängig von den übergebenen Parametern automatisch den korrekten Folgezustand ermitteln.
  • Zustandsänderungen zentral steuern und testen, wodurch Sie den Einsatz großer if/else- oder switch/case-Blöcke vermeiden.


Fazit

Die Trennung der Geschäftslogik in Zustandsklassen und die Verwendung von eigenen Übergangsmustern (State Transitions) machen Ihren Code klarer, leichter testbar und reduzieren die kognitive Belastung bei der Entwicklung. Durch dieses Muster bleibt auch sichergestellt, dass ein Deal nur in zulässiger Reihenfolge seine Zustände wechselt – ganz im Sinne des Prinzips der Einzelverantwortlichkeit.

In der Praxis stellt das Zustandsmuster – gerade in komplexen Systemen wie CRM-Lösungen – eine hervorragende Möglichkeit dar, Geschäftslogik elegant zu strukturieren und die Wartbarkeit des Codes erheblich zu verbessern.