Refatoring : Working state

This commit is contained in:
julien
2026-03-16 16:02:01 +01:00
parent a5ca0df375
commit 0453697cd3
25 changed files with 111 additions and 97 deletions

View File

@@ -167,7 +167,7 @@ $id = 42;
?int $authorId = null;
// Signature typée complète — paramètres et valeur de retour
// Extrait de PostService
// Extrait de PostApplicationService
public function createPost(string $title, string $content,
int $authorId, ?int $categoryId = null): int
```
@@ -206,7 +206,7 @@ foreach ($scores as $nom => $points) {
}
```
La boucle `while` fonctionne comme dans tous les langages. Le projet l'utilise notamment dans `PostService::generateUniqueSlug()` pour tester des variantes jusqu'à trouver un slug libre :
La boucle `while` fonctionne comme dans tous les langages. Le projet l'utilise notamment dans `PostApplicationService::generateUniqueSlug()` pour tester des variantes jusqu'à trouver un slug libre :
```php
$slug = $baseSlug;
@@ -310,7 +310,7 @@ final class NotFoundException extends \RuntimeException
}
}
// Utilisation dans PostService
// Utilisation dans PostApplicationService
$post = $this->postRepository->findById($id);
if ($post === null) {
throw new NotFoundException('Article', $id); // → HTTP 404
@@ -398,26 +398,26 @@ final class PostRepository implements PostRepositoryInterface
Dans Slim Blog, chaque repository a son interface. Cela apporte deux bénéfices concrets :
- **Tests unitaires** — dans les tests, on remplace le repository réel par un mock qui ne touche pas la base de données. Le service est testé en isolation complète.
- **Interchangeabilité** — passer de SQLite à PostgreSQL ne nécessite qu'une nouvelle implémentation de l'interface. `PostService` ne change pas d'une ligne.
- **Interchangeabilité** — passer de SQLite à PostgreSQL ne nécessite qu'une nouvelle implémentation de l'interface. `PostApplicationService` ne change pas d'une ligne.
`PostService` déclare `PostRepositoryInterface $postRepository` dans son constructeur, jamais `PostRepository` directement. Dépendre des abstractions plutôt que des implémentations rend le code testable et évolutif.
`PostApplicationService` déclare `PostRepositoryInterface $postRepository` dans son constructeur, jamais `PostRepository` directement. Dépendre des abstractions plutôt que des implémentations rend le code testable et évolutif.
#### 2.2.3 Injection de dépendances
Plutôt que de créer ses dépendances soi-même, une classe les reçoit via son constructeur. C'est l'injection de dépendances (Dependency Injection).
```php
// ❌ Couplage fort — PostService crée lui-même ses dépendances
// ❌ Couplage fort — un service applicatif crée lui-même ses dépendances
// Impossible à tester : on ne peut pas substituer un faux repository.
class PostService {
class PostApplicationService {
public function __construct() {
$this->repo = new PostRepository();
}
}
// ✅ Injection — les dépendances sont fournies de l'extérieur
// Extrait réel de src/Post/PostService.php
final class PostService
// Extrait réel de src/Post/Application/PostApplicationService.php
final class PostApplicationService
{
public function __construct(
private readonly PostRepositoryInterface $postRepository,
@@ -431,7 +431,7 @@ Qui assemble les dépendances ? PHP-DI dans `config/container.php`. Il résout a
```php
// Extrait de config/container.php
// Binding interface → implémentation : PHP-DI injecte PostRepository partout où
// PostRepositoryInterface est demandé. PostService lui-même est résolu par autowiring.
// PostRepositoryInterface est demandé. PostApplicationService lui-même est résolu par autowiring.
PostRepositoryInterface::class => autowire(PdoPostRepository::class),
PostServiceInterface::class => autowire(PostApplicationService::class),
```
@@ -579,7 +579,7 @@ database/ ← migrations SQL, une par table
├── 003_create_posts.php
├── 004_create_media.php
├── 005_create_password_resets.php
├── 006_create_posts_fts.php
├── 002_create_posts_search.php
└── 007_create_login_attempts.php
```
@@ -778,12 +778,12 @@ Le domaine `Post/` illustre l'anatomie complète.
| Post.php | Modèle immuable. Représente un article avec ses données. |
| PostRepositoryInterface.php | Contrat : liste les méthodes de persistance sans les implémenter. |
| PostRepository.php | Implémentation PDO : requêtes SQL réelles. |
| PostService.php | Logique métier : création d'un slug, validation, appel du repository. |
| PostApplicationService.php | Logique métier : création d'un slug, validation, appel du repository. |
| PostController.php | Actions HTTP : reçoit une requête, appelle le service, renvoie une réponse. |
| PostExtension.php | Extension Twig du domaine Post. Expose `post_excerpt`, `post_url`, `post_thumbnail`, `post_initials` dans les templates. |
| RssController.php | Contrôleur dédié au flux RSS 2.0 (/rss.xml), distinct du PostController. |
Ce qui ne se voit pas dans ce tableau, c'est la direction des dépendances : `PostController` connaît `PostService`, mais `PostService` ne connaît que `PostRepositoryInterface` — jamais `PostRepository` directement. `PostRepository`, lui, ne connaît rien d'autre que PDO.
Ce qui ne se voit pas dans ce tableau, c'est la direction des dépendances : `PostController` connaît `PostApplicationService` via `PostServiceInterface`, et le service ne connaît que `PostRepositoryInterface` — jamais `PdoPostRepository` directement. Le repository, lui, ne connaît rien d'autre que PDO.
```
PostController
@@ -795,7 +795,7 @@ PostRepositoryInterface ← implémente — PostRepository
PDO (SQLite)
```
> 💡 Règle : chaque couche ne connaît que la couche immédiatement en dessous, via son interface. `PostController` dépend de `PostServiceInterface` ; `PostService` dépend de `PostRepositoryInterface` : c'est à ce niveau que l'isolation est garantie, ce qui rend les tests unitaires possibles sans base de données.
> 💡 Règle : chaque couche ne connaît que la couche immédiatement en dessous, via son interface. `PostController` dépend de `PostServiceInterface` ; `PostApplicationService` dépend de `PostRepositoryInterface` : c'est à ce niveau que l'isolation est garantie, ce qui rend les tests unitaires possibles sans base de données.
### 5.4 Le flux d'une requête
@@ -818,7 +818,7 @@ Routeur Slim
PostController::create()
│ extrait et valide les données POST
PostService::createPost()
PostApplicationService::createPost()
│ sanitise le HTML (HTMLPurifier)
│ génère un slug unique
@@ -901,9 +901,9 @@ Il contient treize fichiers PHP dans `src/Auth/` :
```
-- Services --
AuthService.php — connexion, sessions, vérification des rôles
AuthApplicationService.php — connexion, sessions, vérification des rôles
AuthServiceInterface.php — contrat du service d'authentification
PasswordResetService.php — génération du token, envoi e-mail, validation
PasswordResetApplicationService.php — génération du token, envoi e-mail, validation
PasswordResetRepositoryInterface.php
PasswordResetRepository.php — persistance des tokens de réinitialisation
LoginAttemptRepositoryInterface.php
@@ -948,7 +948,7 @@ $this->authService->resetRateLimit($ip);
$this->authService->login($user); // écrit userId/username/role en session
```
> 💡 `AuthService::authenticate()` ne gère pas le rate limiting — c'est `AuthController` qui en est responsable. Cette séparation permet de tester chaque comportement indépendamment.
> 💡 `AuthApplicationService::authenticate()` ne gère pas le rate limiting — c'est `AuthController` qui en est responsable. Cette séparation permet de tester chaque comportement indépendamment.
>
> ⚠️ L'IP lue depuis `REMOTE_ADDR` derrière un proxy retourne l'IP interne du proxy — le rate-limit se verrouillerait alors pour tous les utilisateurs simultanément. Le projet centralise désormais cette logique dans `ClientIpResolver` / `RequestContext` et ne fait confiance aux en-têtes `X-Forwarded-*` que pour les proxies explicitement approuvés via `TRUSTED_PROXIES`.
@@ -962,11 +962,11 @@ Chaque tentative est enregistrée systématiquement, qu'un email existe ou non.
`Migrator::run()` appelle `syncFtsIndex()` à chaque démarrage. Cette méthode insère dans `posts_fts` les articles dont le `rowid` est absent de l'index — sans toucher aux entrées existantes (idempotent).
Ce mécanisme est nécessaire car les triggers FTS5 ne couvrent que les opérations effectuées **après** leur création. Les articles présents en base au moment de la migration 006 ne sont pas indexés rétroactivement par les triggers. Sans cette synchronisation, la recherche retourne zéro résultat sur une base existante.
Ce mécanisme est nécessaire car les triggers FTS5 ne couvrent que les opérations effectuées **après** leur création. Les articles présents en base au moment de la migration 002 ne sont pas indexés rétroactivement par les triggers. Sans cette synchronisation, la recherche retourne zéro résultat sur une base existante.
`strip_tags()` est disponible dans ce contexte car `sqliteCreateFunction()` est appelé dans `container.php` avant `Migrator::run()` dans la séquence de démarrage.
`PasswordResetService` gère le cycle complet en trois étapes :
`PasswordResetApplicationService` gère le cycle complet en trois étapes :
- `requestReset($email)` — cherche l'utilisateur, génère un token aléatoire, stocke son hash SHA-256 en base (jamais le token brut), envoie le lien par e-mail. Si l'e-mail est inconnu, retour silencieux pour ne pas révéler l'existence du compte.
- `validateToken($tokenRaw)` — calcule le hash du token reçu, vérifie qu'il existe en base et n'est pas expiré (1 heure). Retourne l'utilisateur associé ou `null`.
@@ -1118,9 +1118,9 @@ Table de protection anti-brute-force : stocke les tentatives de connexion échou
### 6.2 Migrations
Les migrations sont des fichiers PHP dans `database/migrations/`. Elles s'exécutent automatiquement au démarrage via `Migrator::run()`. Chaque migration ne s'exécute qu'une fois (une table `migrations` trace l'historique).
Les migrations sont des fichiers PHP dans `database/migrations/`. Elles sont exécutées explicitement par `php bin/provision.php` via `Provisioner::run()`, qui appelle `Migrator::run()` puis `Seeder::seed()`. Chaque migration ne s'exécute qu'une fois (une table `migrations` trace l'historique).
Le provisionnement des données initiales (compte admin) est géré séparément par `Seeder::seed()`, appelé après `Migrator::run()` dans la séquence de démarrage. Cette séparation garantit que `Migrator` ne contient que du DDL, et `Seeder` que des données.
Le provisionnement des données initiales (compte admin) est géré séparément par `Seeder::seed()`, appelé après `Migrator::run()` dans `Provisioner::run()`. Cette séparation garantit que `Migrator` ne contient que du DDL, et `Seeder` que des données.
> 💡 Pour ajouter une colonne ou une table, créer un nouveau fichier de migration. Ne jamais modifier une migration déjà exécutée en production.
@@ -1311,7 +1311,7 @@ Les logs Monolog de l'application sont dans `data/var/logs/`.
#### Une migration ne s'exécute pas
Le Migrator trace les migrations déjà appliquées dans une table `migrations`. Si une migration a été modifiée après avoir été exécutée, le Migrator ne la réexécute pas. Pour forcer une réexécution en développement, supprimer le fichier SQLite (`data/database/app.sqlite`) : les migrations repartent de zéro au prochain démarrage.
Le Migrator trace les migrations déjà appliquées dans une table `migrations`. Si une migration a été modifiée après avoir été exécutée, le Migrator ne la réexécute pas. Pour repartir d'un schéma propre en développement, supprimer le fichier SQLite (`database/app.sqlite` en local, `data/database/app.sqlite` en Docker) puis relancer `php bin/provision.php`.
> ⚠️ Ne jamais supprimer la base en production. Créer une nouvelle migration à la place.