Das Strategy-Muster ist eines jener Muster, die dann Sinn machen, wenn das Problem es erfordert.
Die Idee ist einfach: Man hat dieselbe geschäftliche Absicht, aber mehrere Möglichkeiten, diese umzusetzen. Solange der Umfang überschaubar bleibt, ist das in der Regel kein Problem. Das Problem beginnt, wenn diese Variationen zunehmen, sich in einer zentralen Klasse vermischen und jeder neue Fall dazu führt, dass immer mehr Entscheidungen an derselben Stelle angesammelt werden.
In Symfony-Projekten kommt das häufig vor: eine Operation, die sich je nach Kanal, Anbieter, Format oder einer bestimmten Geschäftsregel ändert. Und genau hier kann das Strategy-Muster eine nützliche Möglichkeit sein, Verhaltensweisen zu trennen, ohne das Design unnötig zu verkomplizieren.
Der Schlüssel liegt nicht darin, das Muster zu verwenden, nur weil es existiert, sondern darin, zu erkennen, wann es wirklich dabei hilft, Verantwortlichkeiten zu verteilen und den Code in einem überschaubaren Zustand zu halten.
Das eigentliche Problem ist nicht das „if“
Ein „if“ ist kein Problem.
Zwei „if“-Anweisungen auch nicht.
Das Problem beginnt erst, wenn ein und dieselbe Geschäftslogik auf verschiedene Arten ausgeführt werden kann und all diese Varianten letztendlich in einer einzigen Klasse zusammengefasst werden:
- je nach Kanal;
- je nach Anbieter;
- je nach Kundentyp;
- je nach Land;
- je nach Format;
- je nach Vorgang;
- je nach Ausführungskontext.
An dieser Stelle beginnt der Code auf ziemlich vorhersehbare Weise an Qualität zu verlieren:
- Die Klasse wird immer größer;
- der Konstruktor füllt sich zunehmend mit Abhängigkeiten;
- das Hinzufügen eines neuen Falls erfordert Änderungen am alten Code;
- das Testen einer bestimmten Variante wird zunehmend umständlich;
- und der Hauptdienst wird zu einem Sammelpunkt für Entscheidungen.
Das ist das Szenario, in dem das Strategy-Muster in der Regel Sinn macht.
Was ist das Strategy-Muster?
Die Idee ist recht einfach: Wenn du mehrere Verhaltensweisen für denselben Zweck hast, packe nicht die gesamte Logik in eine zentrale Klasse.
Du definierst eine gemeinsame Schnittstelle, erstellst eine Implementierung pro Variante und delegierst die Ausführung an die jeweils passende.
Mit anderen Worten:
- eine Absicht;
- mehrere mögliche Implementierungen;
- eine Auswahl zur Laufzeit je nach Kontext.
Der Wert des Musters liegt nicht in der theoretischen Definition, sondern darin, was es verhindert: Klassen mit zu vielen Verantwortlichkeiten, endlos wachsende Bedingungen und Logik, die schwer zu erweitern oder zu testen ist.
Nicht jedes variable Verhalten erfordert eine Strategy
An dieser Stelle sollte man einen Moment innehalten, denn genau hier besteht die größte Gefahr, das Design zu überfrachten.
Verhaltensvariationen zu erkennen und automatisch auf das Strategy-Muster zurückzugreifen, ist meist ein Fehler. In manchen Fällen ist die beste Lösung kein Resolver mit mehreren Strategien, sondern Polymorphismus.
Wenn das Verhalten naturgemäß zum Objekt selbst gehört, ist es sicherlich sinnvoller, es dort zu belassen.
Beispielsweise ist eine Berechnung der Versandkosten je nach Spediteur kein guter Grund, eine „Strategy“ zu rechtfertigen. Wenn jeder Spediteur seine eigene Logik hat und dies ein natürlicher Bestandteil seiner Zuständigkeit ist, wäre es wahrscheinlich sinnvoller, dies wie folgt zu modellieren:
interface Carrier
{
public function calculateShippingCost(float $weight): float;
}
final class SeurCarrier implements Carrier
{
public function calculateShippingCost(float $weight): float
{
return 5 + ($weight * 0.80);
}
}
end class DhlCarrier implements Carrier
{
public function calculateShippingCost(float $weight): float
{
return 7 + ($weight * 0.65);
}
}
Und die Verwendung wäre ganz einfach:
$cost = $carrier->calculateShippingCost($weight);
Hier ist weder eine zusätzliche Ebene zum Durchlaufen von Strategien noch eine `supports()`-Methode erforderlich, um zu entscheiden, welche Strategie angewendet wird. Das Verhalten ist bereits dort, wo es sein sollte.
Das ist ein wichtiger Unterschied: Es geht nicht darum, zwischen „if“ und „Strategy“ zu wählen, sondern darum, die richtige Abstraktion zu wählen.
Und manchmal ist die richtige Abstraktion nicht „Strategy“, sondern einfach ein besseres Modell.
Wann lohnt sich der Einsatz von „Strategy“?
„Strategy“ eignet sich am besten, wenn die Auswahl des Verhaltens nicht von Natur aus zu einer bestimmten Entität gehört, sondern von außen bestimmt wird – durch den Kontext, durch Anwendungsregeln oder durch dynamische Komposition.
Zum Beispiel:
- wenn sich eine Geschäftsrichtlinie je nach äußeren Bedingungen ändert;
- wenn mehrere Implementierungen eine gemeinsame Absicht verfolgen, aber nicht Teil desselben Domänenmodells sind;
- wenn Sie Integrationen mit Drittanbietern entkoppeln;
- wenn die Entscheidung in einem Anwendungsdienst getroffen wird;
- wenn Sie Varianten hinzufügen möchten, ohne eine zentrale Klasse erneut zu öffnen.
In solchen Szenarien lohnt es sich in der Regel.
Warum es so gut zu Symfony passt
Symfony erleichtert die saubere Umsetzung dieses Musters erheblich, da der Service-Container bereits für die Arbeit mit mehreren Implementierungen ausgelegt ist.
Du kannst:
- mehrere Klassen zu haben, die dieselbe Schnittstelle implementieren;
- sie automatisch registrieren;
- sie mit Tags versehen;
- sie als iterierbare Objekte einzubinden;
- die passende Implementierung zu ermitteln, ohne sich an bestimmte Klassen zu binden.
Beispiel:
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
final class DiscountResolver
{
public function __construct(
#[TaggedIterator('app.discount_strategy')]
private iterable $strategies
) {
}
}
Und in services.yaml:
services:
App\Discount\Strategy\:
resource: '../src/Discount/Strategy/*'
tags: ['app.discount_strategy']
In Symfony läuft das ganz natürlich ab. Man muss keine seltsame Infrastruktur aufbauen oder eigene Mechanismen zur Erkennung von Implementierungen erfinden.
Diese gute Integration in das Framework ist einer der Gründe, warum sich das Muster in diesem Ökosystem besonders gut eignet.
Praktische Anwendungsfälle des Strategy-Musters in Symfony
Benachrichtigungen nach Kanal
Dies ist einer der eindeutigsten Anwendungsfälle.
Man verfolgt dieselbe geschäftliche Absicht: etwas zu benachrichtigen. Doch die Art und Weise, wie dies geschieht, hängt vom jeweiligen Kanal ab:
- E-Mail;
- SMS;
- Push-Benachrichtigung;
- Slack;
- Webhook.
Hier unterscheidet sich die Implementierung erheblich, die Abhängigkeiten sind in der Regel unterschiedlich und die Entscheidung wird normalerweise außerhalb des Objekts selbst getroffen. Man modelliert nicht das natürliche Verhalten einer Entität, sondern legt eine Versandrichtlinie fest.
Hier passt „Strategy“ gut.
interface NotificationStrategyInterface
{
public function supports(string $channel): bool;
public function send(Notification $notification): void;
}
final class EmailNotificationStrategy implements NotificationStrategyInterface
{
public function __construct(
private MailerInterface $mailer
) {
}
public function supports(string $channel): bool
{
return $channel === 'email';
}
public function send(Notification $notification): void
{
// Versand per E-Mail
}
}
end class SmsNotificationStrategy implements NotificationStrategyInterface
{
public function __construct(
private SmsClient $smsClient
) {
}
public function supports(string $channel): bool
{
return $channel === 'sms';
}
public function send(Notification $notification): void
{
// Versand per SMS
}
}
end class NotificationSender
{
/**
* @param iterable<NotificationStrategyInterface> $strategies
*/
public function __construct(
private iterable $strategies
) {
}
public function send(string $channel, Notification $notification): void
{
foreach ($this->strategies as $strategy) {
if ($strategy->supports($channel)) {
$strategy->send($notification);
return;
}
}
throw new \RuntimeException(sprintf('Kanal „%s“ wird nicht unterstützt', $channel));
}
}
Integrationen mit externen Anbietern
Ein weiterer sehr typischer Fall.
Funktional gesehen möchtest du „das Gleiche“ tun, aber der Anbieter ändert sich:
- Zahlungsgateways;
- externe Spediteure;
- CRMs;
- Signatursysteme;
- Verifizierungsdienste;
- Identitätsanbieter.
In solchen Szenarien führt der Versuch, die gesamte Logik zu zentralisieren, meist zu einer Klasse voller Verzweigungen, HTTP-Clients, Anpassungen der Nutzdaten und spezifischer Fehlerbehandlung.
Eine Aufteilung nach Strategien ist in der Regel wesentlich sinnvoller.
interface PaymentStrategyInterface
{
public function supports(string $provider): bool;
public function charge(PaymentRequest $request): PaymentResult;
}
Jeder Anbieter kapselt sein Verhalten, seine Abhängigkeiten und sein Mapping. Der Anwendungsfall oder der Anwendungsdienst ermittelt lediglich, welcher Anbieter benötigt wird.
Exporte in verschiedenen Formaten
Sehr häufig in Verwaltungs- und Backoffice-Anwendungen.
Das Ziel ist dasselbe: Daten zu exportieren. Aber es ist nicht dasselbe, in folgende Formate zu exportieren:
- CSV;
- Excel;
- JSON;
- XML;
- PDF.
Hier wird die Wahl des Verhaltens in der Regel durch den Anwendungskontext bestimmt und nicht durch eine natürliche Zuständigkeit einer Domänenentität. Und jedes Format kann eigene Bibliotheken, Strukturen und Verarbeitungsschritte erfordern.
Schnittstelle ExportStrategyInterface
{
public function supports(string $format): bool;
public function export(array $data): string;
}
Jedes Format in seiner eigenen Klasse, jede Implementierung isoliert und ein wesentlich übersichtlicherer Hauptdienst.
Kontextbezogene Geschäftsregeln
Dies ist eine der interessantesten Anwendungen, auch wenn sie weniger auffällig ist.
Beispiel:
- Rabatte gemäß Geschäftsregeln gewähren;
- Festlegung einer Verarbeitungsrichtlinie je nach Herkunft;
- je nach Übergang eine unterschiedliche Validierung durchführen;
- eine Aktion je nach Art des Vorgangs auswählen;
- je nach Kanal oder Umgebung eine andere Regel anwenden.
In diesen Fällen ist es wichtig, dass die Verhaltensabweichung nicht eindeutig einer bestimmten Entität zugeordnet ist, sondern einer Richtlinie, die von der Anwendung umgesetzt werden muss.
Hier ist „Strategy“ in der Regel eine gute Möglichkeit, die Logik zu kapseln, ohne alles in denselben Dienst zu packen.
Beispiel mit Rabatten
Schnittstelle DiscountStrategyInterface
{
public function supports(Order $order): bool;
public function apply(Order $order): int;
}
final class PremiumDiscountStrategy implements DiscountStrategyInterface
{
public function supports(Order $order): bool
{
return $order->customer()->isPremium();
}
public function apply(Order $order): int
{
return (int) round($order->subtotal() * 0.10);
}
}
end class CampaignDiscountStrategy implements DiscountStrategyInterface
{
public function supports(Order $order): bool
{
return $order->hasActiveCampaign();
}
public function apply(Order $order): int
{
return 500;
}
}
end class DiscountResolver
{
/**
* @param iterable<DiscountStrategyInterface> $strategies
*/
public function __construct(
private iterable $strategies
) {
}
public function resolve(Order $order): int
{
foreach ($this->strategies as $strategy) {
if ($strategy->supports($order)) {
return $strategy->apply($order);
}
}
return 0;
}
}
Wann sollte das Strategy-Muster nicht verwendet werden?
Dieser Punkt ist genauso wichtig wie die anderen.
Nicht alles erfordert ein Strategy-Muster. Und in manchen Teams besteht eine gewisse Tendenz, Muster einzuführen, noch bevor das Problem auftritt, das deren Einsatz rechtfertigen würde.
Wenn du zwei einfache, stabile Fälle hast und die Logik minimal ist, lösen wahrscheinlich ein „match“ oder sogar ein „if“ das Problem besser.
return match ($type) {
'fixed' => 100,
'percent' => 200,
default => 0,
};
Dafür braucht es keine Schnittstelle, drei Implementierungen und keinen Resolver. Man sollte etwas, das noch einfach ist, nicht unnötig komplizieren.
Strategy zahlt sich erst dann aus, wenn mehrere der folgenden Bedingungen erfüllt sind:
- Die Logik der einzelnen Fälle wird immer umfangreicher;
- jede Variante benötigt eigene Abhängigkeiten;
- die Anzahl der Fälle kann weiter steigen;
- das separate Testen jedes Verhaltens bringt einen Mehrwert;
- die Bearbeitung eines Falls erfordert immer das erneute Öffnen derselben Klasse;
- die Auswahl des Verhaltens erfolgt über einen Anwendungsdienst oder eine externe Richtlinie.
Anzeichen dafür, dass es in einem Symfony-Projekt nützlich sein könnte
Es gibt ziemlich eindeutige Anzeichen.
Der Hauptdienst fängt an, zu viele Entscheidungen zu treffen
Wenn dein CheckoutService, NotificationService, ExportService oder Ähnliches zu viele „typspezifische“ Verzweigungen ansammelt, steht in der Regel eine Abstraktion noch aus.
Der Konstruktor füllt sich zunehmend mit Abhängigkeiten, die nicht immer genutzt werden
Wenn eine einzelne Klasse zu viele Abhängigkeiten benötigt, weil sie zu viele Varianten unterstützt, lässt sich die Verteilung der Verantwortlichkeiten fast immer verbessern.
Das Hinzufügen eines neuen Falls bedeutet immer, dass man dieselbe heikle Klasse bearbeiten muss
Wenn jede neue Variante eine Änderung an einem zentralen Dienst erfordert, der bereits zu viele Entscheidungen miteinander vermischt, erzeugst du einen unnötigen Reibungspunkt.
Das Testen einer bestimmten Variante wird immer mühsamer
Wenn du zum Testen eines bestimmten Zweigs einen halben Kontext aufbauen musst, weil die Logik in einer großen Klasse eingebettet ist, ist diese Logik wahrscheinlich an der falschen Stelle platziert.
Echte Vorteile des Strategy-Musters
Die Vorteile sind nicht nur theoretischer Natur. In realen Projekten machen sie sich deutlich bemerkbar, wenn das Muster richtig angewendet wird.
Der erste Vorteil ist die Wartbarkeit. Jedes Verhalten befindet sich in einer eigenen Klasse und belastet den zentralen Dienst nicht mehr.
Der zweite Vorteil ist die Erweiterbarkeit. Das Hinzufügen einer neuen Variante besteht in der Regel darin, eine neue Implementierung zu erstellen, und nicht darin, einen besonders sensiblen Teil erneut zu öffnen.
Der dritte Vorteil ist die Testbarkeit. Das Testen isolierter Verhaltensweisen ist wesentlich komfortabler als das Testen eines Dienstes mit mehreren miteinander verflochtenen Verzweigungen.
Der vierte Vorteil ist die Übersichtlichkeit. Wenn jemand neu in das Projekt einsteigt, ist es in der Regel einfacher, das System anhand der einzelnen Varianten zu verstehen, wenn diese jeweils gekapselt sind.
Und der fünfte Vorteil in Symfony ist, dass es sehr gut zum Container und zum Servicesystem passt.
Häufige Fehler bei der Anwendung
Man sollte das Konzept auch nicht idealisieren.
Zu frühes Überdesign
Dies ist der häufigste Fehler. Die Anwendung von „Strategy“ auf etwas, das noch zu einfach ist, sorgt nur für Unübersichtlichkeit.
Die Auswahlkriterien nicht gut kontrollieren
Wenn mehrere Strategien auf denselben Fall mit „true“ antworten können und du keine klare Richtlinie hast, führst du Unklarheiten ein.
In diesem Fall solltest du entscheiden, ob es nur eine gültige Strategie geben soll, ob Prioritäten gesetzt werden müssen oder ob du tatsächlich mehrere Strategien umsetzen musst.
Fragmentierung, ohne das Design wirklich zu verbessern
Die Trennung von Verantwortlichkeiten ist gut und richtig. Wenn man jedoch, um einem einfachen Ablauf zu folgen, zwischen zu vielen kleinen Klassen hin- und herspringen muss, die kaum etwas beitragen, steigen die kognitiven Kosten und das Design verschlechtert sich.
Es nutzen, um ein schlechtes Modell zu kaschieren
Manchmal ist das Problem nicht das Fehlen eines Musters. Vielmehr ist die Domäne schlecht modelliert.
Und das Erstellen weiterer Dienste behebt das nicht.
Testen: Wo es sich meist am meisten lohnt
Einer der Bereiche, in denen dieses Muster am deutlichsten zum Tragen kommt, sind Tests.
Eine bestimmte Strategie lässt sich in der Regel leicht isoliert testen:
public function test_premium_discount_is_applied(): void
{
$order = $this->createPremiumOrderWithSubtotal(10000);
$strategy = new PremiumDiscountStrategy();
self::assertTrue($strategy->supports($order));
self::assertSame(1000, $strategy->apply($order));
}
Und auch der Resolver lässt sich ganz einfach testen:
public function test_it_uses_the_first_supported_strategy(): void
{
$strategy = $this->createMock(DiscountStrategyInterface::class);
$strategy->method('supports')->willReturn(true);
$strategy->method('apply')->willReturn(500);
$resolver = new DiscountResolver([$strategy]);
self::assertSame(500, $resolver->resolve($order));
}
Das ist in der Regel wesentlich komfortabler, als eine zentrale Klasse mit mehreren Verzweigungen und zu vielen vermischteten Abhängigkeiten zu testen.
Fazit
Das Strategy-Muster ist dann von Nutzen, wenn es verhindert, dass eine einzige Klasse zu viele Verhaltensvarianten und zu viele geschäftliche Entscheidungen in sich vereint.
In Symfony passt es besonders gut, da das Framework die Arbeit mit mehreren Implementierungen, Tags und der Injektion von Strategien erheblich erleichtert.
Aber gerade deshalb sollte man es mit Bedacht einsetzen.
Nicht jede Verhaltensvariante erfordert eine Strategie. Wenn das Verhalten zum Objekt selbst gehört, ist es sicherlich sinnvoller, zunächst auf Polymorphismus zurückzugreifen. Wenn das Problem weiterhin einfach ist, reicht vielleicht schon ein „match“ aus.
Das Strategy-Muster lohnt sich, wenn die Auswahl des Verhaltens von außen bestimmt wird, vom Kontext abhängt, einer Anwendungsrichtlinie entspricht oder wenn das System wachsen soll, ohne dass eine zentrale Klasse zu einem Engpass bei der Wartung wird.
In solchen Fällen funktioniert es in der Regel sehr gut. Und bei WATA Factory ist es ein Muster, das wir genau in solchen Szenarien einsetzen: wenn wir Verhaltensweisen entkoppeln, die Weiterentwicklung des Systems erleichtern und den Code so gestalten müssen, dass er wachsen kann, ohne an Klarheit zu verlieren.
Denn letztendlich liegt der Schlüssel nicht in der Anwendung von Mustern. Er liegt darin, Lösungen zu wählen, die auch dann noch Sinn ergeben, wenn sich das Projekt weiterentwickelt.



