Appliquer le Domain-Driven Design à PHP et Symfony : Un Guide Pratique

Le Domain-Driven Design (DDD) s'applique à Symfony grâce à des Value Objects, des dépôts et des contextes bornés. Dans cet article, découvrez les étapes concrètes pour construire des applications PHP évolutives.
Introduction
Le Domain-Driven Design (DDD) aide les développeurs à structurer leurs applications autour de la logique métier concrète tout en gardant une séparation claire des responsabilités. Dans mon précédent article, j’ai expliqué les fondements théoriques du DDD. Aujourd’hui, nous allons plonger dans un exemple concret en construisant un client API météo utilisant OpenWeatherMap et Symfony.
Qu'allez-vous apprendre ?
Comment structurer un client API selon les principes du Domain-Driven Design
Utiliser des Value Objects pour encapsuler la logique des requêtes et réponses
Implémenter un repository pour gérer les interactions avec l'API
Séparer la logique API de la logique métier
Utiliser des clients HTTP scénarisés dans Symfony pour une meilleure configuration
À la fin de cet article, vous serez en mesure d'intégrer des APIs tierces dans Symfony tout en maintenant une architecture propre.
Cas d’utilisation : récupération de données météo via OpenWeatherMap
Nous allons construire un client API conforme au DDD pour récupérer les données météo en temps réel depuis OpenWeatherMap.
Détails de l’API
URL de base :
https://api.openweathermap.org/data/2.5/weather
Paramètres de requête :
q
(Nom de la ville, par ex."Paris"
)units
(Unités :"metric"
,"imperial"
,"standard"
)
Exemple de requête :
GET https://api.openweathermap.org/data/2.5/weather?q=Paris&units=metric&appid=YOUR_API_KEY
Exemple de réponse :
{
"coord": { "lon": 2.3488, "lat": 48.8534 },
"weather": [{ "main": "Clouds", "description": "few clouds" }],
"main": { "temp": 18.3, "pressure": 1015, "humidity": 70 },
"wind": { "speed": 3.5, "deg": 150 },
"name": "Paris"
}
Étape 1 : Définir l’interface de l’API
Pour découpler la logique de l'API du reste de l'application, nous définissons une interface qui décrit comment doit fonctionner le client API.
// ...
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;
}
Pourquoi utiliser une interface ?
Elle abstrait les dépendances externes (par ex. OpenWeatherMap)
Elle assure la flexibilité (facile à changer de fournisseur API)
Elle permet l’injection de dépendance pour les tests ou les mocks
Notre application reste ainsi faiblement couplée, extensible et testable.
Étape 2 : Implémenter les Value Objects
Region (Value Object)
Elle encapsule et valide les noms de ville.
// ...
use Webmozart\Assert\Assert;
final readonly class Region
{
public function __construct(public string $value)
{
Assert::stringNotEmpty($value);
Assert::notWhitespaceOnly($value);
}
}
Unit (Enum)
Elle représente les unités disponibles avec une sécurité de type stricte.
enum Unit: string
{
case METRIC = 'metric';
case IMPERIAL = 'imperial';
case STANDARD = 'standard';
}
WeatherRequest (Value Object)
Elle représente les options de requête de l’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)
Elle valide et structure les données retournées par l’API.
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
}
}
Pourquoi utiliser des Value Objects ?
Assurer l’intégrité des données
Encapsuler la logique métier
Prévenir les données invalides
Faciliter les tests et la lecture du code
Les enums comme Unit
améliorent aussi l’expérience développeur en imposant des valeurs valides.
Étape 3 : Implémenter le client API météo
Voici une implémentation du client avec Symfony HttpClient :
// ...
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());
}
}
Une alternative : le client HTTP scénarisé
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)%'
Modifiez le client comme suit :
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());
}
}
Étape 4 : Organisation du répertoire
En Domain-Driven Design, une structure claire est essentielle. Nous utilisons le Bridge Pattern pour isoler la logique externe de l’API.
src/
├── Bridge/
│ ├── WeatherApi/
│ │ ├── Domain/
│ │ │ ├── Region.php
│ │ │ ├── Unit.php
│ │ │ ├── WeatherRequest.php
│ │ │ ├── WeatherResponse.php
│ │ ├── WeatherApi.php
│ │ ├── WeatherApiInterface.php
Pourquoi cette structure ?
Isoler l’intégration API sous Bridge\WeatherApi
Garder la logique métier indépendante de l’API
Faciliter la maintenance et les tests
Il est possible de changer de fournisseur d’API sans impacter la logique métier.
Conclusion
Cet exemple montre comment intégrer une API externe dans Symfony avec le Domain-Driven Design. En résumé, il faut :
Encapsuler la logique API dans un repository
Utiliser des Value Objects pour les requêtes/réponses
Masquer l’intégration API derrière une interface
(Optionnel) Utiliser des clients HTTP scénarisés Symfony
Avec cette architecture, votre client API Symfony sera maintenable, testable et découplé.