Anwendung von Domain-Driven Design in PHP und Symfony: Ein praktischer Leitfaden

Erfahre anhand praktischer Beispiele, wie du die Prinzipien des Domain-Driven Design (DDD) in Symfony anwendest. Entdecke die Leistungsfähigkeit von Value Objects, Repositories und Bounded Contexts.
Einführung
Domain-Driven Design (DDD) hilft Entwickler:innen dabei, ihre Anwendungen rund um echte Geschäftslogik zu strukturieren und dabei die Zuständigkeiten klar zu trennen. In meinem vorherigen Beitrag habe ich die theoretischen Grundlagen von DDD erklärt, aber heute tauchen wir in ein praxisnahes Beispiel ein: Wir bauen einen Wetter-API-Client mit OpenWeatherMap und Symfony.
Was wirst du lernen?
Wie man einen API-Client nach DDD-Prinzipien strukturiert
Wie man Value Objects nutzt, um Anfrage- und Antwortlogik zu kapseln
Wie man ein Repository implementiert, um API-Interaktionen zu steuern
Wie man API-Logik von der Geschäftslogik trennt
Wie man scoped HTTP-Clients in Symfony verwendet – für bessere Konfiguration
Am Ende dieses Beitrags wirst du in der Lage sein, Drittanbieter-APIs in Symfony zu integrieren, ohne deine Architektur zu gefährden.
Use Case: Wetterdaten von OpenWeatherMap abrufen
Wir bauen einen DDD-konformen API-Client, der aktuelle Wetterdaten von OpenWeatherMap abruft.
API-Details
Basis-URL:
https://api.openweathermap.org/data/2.5/weather
Anfrageparameter:
q
(Stadtname, z. B."Berlin"
)units (Maßeinheit:
"metric"
,"imperial"
,"standard"
)
Beispiel-Anfrage:
GET https://api.openweathermap.org/data/2.5/weather?q=Berlin&units=metric&appid=YOUR_API_KEY
Beispiel-Antwort:
{ "coord": { "lon": 13.4105, "lat": 52.5244 }, "weather": [{ "main": "Clouds", "description": "scattered clouds" }], "main": { "temp": 21.5, "pressure": 1012, "humidity": 60 }, "wind": { "speed": 5.1, "deg": 220 }, "name": "Berlin" }
Schritt 1: API-Interface definieren
Um unsere API-Logik vom Rest der Anwendung zu entkoppeln, definieren wir ein Interface, das beschreibt, wie der API-Client funktionieren soll.
// ...
namespace App\Weather\Api;
use App\Weather\Domain\WeatherRequest;
use App\Weather\Domain\WeatherResponse;
use App\Weather\Domain\Region;
interface WeatherApiInterface
{
public function forRegion(Region $region, WeatherRequest $request = new WeatherRequest()): WeatherResponse;
}
Warum ein Interface verwenden?
Abstrahiert externe Abhängigkeiten (z. B. OpenWeatherMap)
Sichert Flexibilität (einfacher API-Anbieter-Wechsel)
Ermöglicht Dependency Injection für Tests und Mocks
Durch diese Trennung bleibt unsere Anwendung lose gekoppelt und lässt sich einfach erweitern und testen.
Schritt 2: Value Objects implementieren
Region (Value Object)
Kapselt und validiert Städtenamen.
// ...
use Webmozart\Assert\Assert;
final readonly class Region
{
public function __construct(public string $value)
{
Assert::stringNotEmpty($value);
Assert::notWhitespaceOnly($value);
}
}
Unit (Enum Value Object)
Repräsentiert vordefinierte Maßeinheiten und sorgt für strikte Typsicherheit.
enum Unit: string
{
case METRIC = 'metric';
case IMPERIAL = 'imperial';
case STANDARD = 'standard';
}
WeatherRequest (Value Object)
Repräsentiert alle möglichen Filter oder Abfrageoptionen der API.
// ...
final readonly class WeatherRequest implements \JsonSerializable
{
public function __construct(
public Unit $unit = Unit::METRIC,
) {
}
public function jsonSerialize(): array
{
return [
'unit' => $this->unit->value,
];
}
}
WeatherResponse (Value Object)
Validiert und strukturiert die Wetterdaten der API-Antwort.
use Webmozart\Assert\Assert;
final readonly class WeatherResponse
{
public function __construct(array $values)
{
Assert::keyExists($values, 'name');
$this->city = $values['name'];
Assert::keyExists($values, 'main');
Assert::keyExists($values['main'], 'temp');
$this->temperature = $values['main']['temp'];
Assert::keyExists($values, 'weather');
Assert::isList($values['weather']);
Assert::count($values['weather'], 1);
Assert::keyExists($values['weather'][0], 'description');
$this->description = $values['weather'][0]['description'];
// of course, this is a very simplified example
}
}
Warum Value Objects?
Stellen Datenintegrität sicher
Kapseln Logik innerhalb der Domäne
Verhindern ungültige Daten
Erleichtern das Testen und das Verständnis
Die Verwendung von Enums wie Unit
verbessert zusätzlich die Developer Experience (DX) durch klare Einschränkungen und bessere Autovervollständigung.
Schritt 3: Den Weather API Client implementieren
Nun erstellen wir eine Symfony HttpClient-basierte Implementierung unseres API-Clients.
// ...
final class WeatherApi implements WeatherApiInterface
{
public function __construct(
private HttpClientInterface $client,
#[\SensitiveParameter]
#[Autowire(env: 'OPENWEATHER_API_KEY')]
private string $apiKey
) {}
public function forRegion(Region $region, WeatherRequest $request = new WeatherRequest()): WeatherResponse
{
$response = $this->client->request('GET', 'https://api.openweathermap.org/data/2.5/weather', [
'query' => [...$request->jsonSerialize(), 'q' => $region->name, 'appid' => $this->apiKey],
]);
return new WeatherResponse($response->toArray());
}
}
Alternative: Scoped HTTP Client nutzen
Symfony erlaubt es, einen spezifisch konfigurierten HTTP-Client für OpenWeatherMap zu definieren:
framework:
http_client:
scoped_clients:
openweather.client:
base_uri: 'https://api.openweathermap.org/data/2.5/weather'
headers:
Accept: 'application/json'
query:
appid: '%env(OPENWEATHER_API_KEY)%'
Und nun passen wir unseren Client entsprechend an:
final class WeatherApi implements WeatherApiInterface
{
public function __construct(
+ private HttpClientInterface $openweatherClient,
- private HttpClientInterface $client,
- #[\SensitiveParameter]
- #[Autowire(env: 'OPENWEATHER_API_KEY')]
- private string $apiKey
) {}
public function forRegion(Region $region, WeatherRequest $request = new WeatherRequest()): WeatherResponse
{
- $response = $this->client->request('GET', 'https://api.openweathermap.org/data/2.5/weather', [
- 'query' => [...$request->jsonSerialize(), 'q' => $region->name, 'appid' => $this->apiKey],
+ $response = $this->openweatherClient->request('GET', 'https://api.openweathermap.org/data/2.5/weather', [
+ 'query' => [...$request->jsonSerialize(), 'q' => $region->name],
]);
return new WeatherResponse($response->toArray());
}
}
Schritt 4: Verzeichnisstruktur verstehen
Bei der Arbeit mit DDD ist eine klare Strukturierung entscheidend. Wir nutzen das Bridge-Pattern, um externe API-Logik von der Domäne zu trennen.
src/
├── Bridge/
│ ├── WeatherApi/
│ │ ├── Domain/
│ │ │ ├── Region.php
│ │ │ ├── Unit.php
│ │ │ ├── WeatherRequest.php
│ │ │ ├── WeatherResponse.php
│ │ ├── WeatherApi.php
│ │ ├── WeatherApiInterface.php
Warum diese Struktur?
Kapselt die API-Integration unter
Bridge\WeatherApi
Hält Domänenlogik unabhängig von API-Details
Erleichtert Wartung und Tests, da externe Abhängigkeiten isoliert sind
Durch diesen Aufbau kann der API-Anbieter oder die konkrete Implementierung ausgetauscht werden, ohne Auswirkungen auf die Kernlogik der Anwendung.
Fazit
Dieses Beispiel zeigt, wie man eine externe API in Symfony nach DDD-Prinzipien integriert:
API-Logik in ein Repository kapseln
Value Objects zur Validierung von Anfragen und Antworten verwenden
API-Interaktionen hinter einem Interface abstrahieren
(Optional) Scoped HTTP Clients für saubere Konfiguration verwenden
Wenn du deinen Symfony-API-Client auf diese Weise strukturierst, erreichst du eine hohe Wartbarkeit, Entkopplung und Testbarkeit.