refactor(core) : isole Core du module Sites via SiteProviderInterface + resolve_target_entities

Supprime les imports directs de App\Module\Sites\* depuis le module Core :

- SiteProviderInterface (Shared/Contract) : contrat minimal findByName(),
  etendu par SiteRepositoryInterface pour reutilisation DI.
- AppFixtures : injecte SiteProviderInterface au lieu de SiteRepositoryInterface.
- User.php : targetEntity des mappings ORM pointe desormais sur SiteInterface,
  resolu a la classe concrete via doctrine.orm.resolve_target_entities
  (pattern officiel Doctrine pour les bounded contexts DDD).
- JoinColumn/InverseJoinColumn explicites sur la ManyToMany user_site pour
  forcer les noms de colonnes (sinon Doctrine derive site_interface_id).

Respecte la regle CLAUDE.md "jamais d'import direct entre modules" — il
reste l'exception SitesFixtures::class dans getDependencies() (contrainte
d'API DependentFixtureInterface, meme registre que resolve_target_entities).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 19:32:20 +02:00
parent 48f314f09e
commit 18e79a643b
6 changed files with 51 additions and 17 deletions

View File

@@ -26,6 +26,13 @@ doctrine:
identity_generation_preferences: identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true auto_mapping: true
# Mapping contrat DDD → classe concrete. Permet au module Core de
# referencer `SiteInterface` dans ses ORM mappings (User) sans importer
# la classe concrete du module Sites. Pattern officiel Doctrine pour
# les bounded contexts, remplace l'ancien import direct
# `App\Module\Sites\Domain\Entity\Site` dans User.php.
resolve_target_entities:
App\Shared\Domain\Contract\SiteInterface: App\Module\Sites\Domain\Entity\Site
mappings: mappings:
Core: Core:
type: attribute type: attribute

View File

@@ -28,5 +28,8 @@ services:
App\Module\Sites\Domain\Repository\SiteRepositoryInterface: App\Module\Sites\Domain\Repository\SiteRepositoryInterface:
alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
App\Shared\Domain\Contract\SiteProviderInterface:
alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
App\Module\Sites\Application\Service\CurrentSiteProviderInterface: App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
alias: App\Module\Sites\Application\Service\CurrentSiteProvider alias: App\Module\Sites\Application\Service\CurrentSiteProvider

View File

@@ -15,12 +15,11 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor; use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider; use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
// Note architecture : User.php utilise SiteInterface (Shared) pour les // Note architecture : User.php n'importe plus rien depuis le module Sites.
// type-hints afin de ne pas coupler le module Core au module Sites. // Les type-hints utilisent SiteInterface (Shared/Contract) et le mapping ORM
// La seule reference concrete a Site subsiste dans les metadonnees ORM // pointe vers la meme interface, resolue vers la classe concrete Site au boot
// (targetEntity) via FQCN string, ce qui est obligatoire pour Doctrine. // via `doctrine.orm.resolve_target_entities` (cf. config/packages/doctrine.yaml).
// SiteNotAuthorizedException est importee depuis Shared (sa location canonique). // C'est le pattern officiel Doctrine pour les bounded contexts DDD.
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore; use App\Shared\Domain\Attribute\AuditIgnore;
use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\SiteInterface;
@@ -139,10 +138,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* Le groupe `user:list` a ete retire deliberement (securite : evite * Le groupe `user:list` a ete retire deliberement (securite : evite
* de fuiter la liste des sites de chaque user via GET /api/users). * de fuiter la liste des sites de chaque user via GET /api/users).
* *
* @var Collection<int, Site> * @var Collection<int, SiteInterface>
*/ */
#[ORM\ManyToMany(targetEntity: 'App\Module\Sites\Domain\Entity\Site', inversedBy: 'users', fetch: 'LAZY')] #[ORM\ManyToMany(targetEntity: SiteInterface::class, inversedBy: 'users', fetch: 'LAZY')]
#[ORM\JoinTable(name: 'user_site')] #[ORM\JoinTable(name: 'user_site')]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[Groups(['me:read', 'user:rbac:read', 'user:rbac:write'])] #[Groups(['me:read', 'user:rbac:read', 'user:rbac:write'])]
private Collection $sites; private Collection $sites;
@@ -162,7 +163,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* le prechargement pour /api/me. Le groupe `user:list` a ete retire * le prechargement pour /api/me. Le groupe `user:list` a ete retire
* deliberement (securite : evite de fuiter le site actif via /api/users). * deliberement (securite : evite de fuiter le site actif via /api/users).
*/ */
#[ORM\ManyToOne(targetEntity: 'App\Module\Sites\Domain\Entity\Site', fetch: 'LAZY')] #[ORM\ManyToOne(targetEntity: SiteInterface::class, fetch: 'LAZY')]
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] #[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read'])] #[Groups(['me:read'])]
private ?SiteInterface $currentSite = null; private ?SiteInterface $currentSite = null;
@@ -378,7 +379,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
} }
/** /**
* @return Collection<int, Site> * @return Collection<int, SiteInterface>
*/ */
public function getSites(): Collection public function getSites(): Collection
{ {
@@ -392,7 +393,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* session Doctrine (cf. ticket 2 review point #1). * session Doctrine (cf. ticket 2 review point #1).
* *
* Le parametre est type SiteInterface pour eviter le couplage Core → Sites. * Le parametre est type SiteInterface pour eviter le couplage Core → Sites.
* En pratique seule App\Module\Sites\Domain\Entity\Site est passee ici. * La classe concrete injectee au runtime est resolue par Doctrine via
* `resolve_target_entities` (cf. note architecture en tete de fichier).
*/ */
public function addSite(SiteInterface $site): static public function addSite(SiteInterface $site): static
{ {

View File

@@ -8,9 +8,9 @@ use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User; use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface; use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles; use App\Module\Core\Domain\Security\SystemRoles;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Domain\Repository\SiteRepositoryInterface;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures; use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SiteProviderInterface;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
@@ -39,7 +39,7 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
public function __construct( public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher, private readonly UserPasswordHasherInterface $passwordHasher,
private readonly RoleRepositoryInterface $roleRepository, private readonly RoleRepositoryInterface $roleRepository,
private readonly SiteRepositoryInterface $siteRepository, private readonly SiteProviderInterface $siteProvider,
) {} ) {}
/** /**
@@ -135,9 +135,9 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
return $role; return $role;
} }
private function requireSite(string $name): Site private function requireSite(string $name): SiteInterface
{ {
$site = $this->siteRepository->findByName($name); $site = $this->siteProvider->findByName($name);
if (null === $site) { if (null === $site) {
throw new RuntimeException(sprintf( throw new RuntimeException(sprintf(

View File

@@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Module\Sites\Domain\Repository; namespace App\Module\Sites\Domain\Repository;
use App\Module\Sites\Domain\Entity\Site; use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\SiteProviderInterface;
interface SiteRepositoryInterface interface SiteRepositoryInterface extends SiteProviderInterface
{ {
public function findById(int $id): ?Site; public function findById(int $id): ?Site;

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat minimal pour acceder a un site depuis un module qui n'est pas Sites.
*
* Permet a du code Core/Shared (commandes de seed, fixtures, etc.) de
* recuperer un Site par son nom sans importer directement depuis le module
* Sites — ce qui violerait la regle "jamais d'import direct entre modules"
* (cf. CLAUDE.md section "Regles d'architecture").
*
* Implementation concrete : App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
* (via SiteRepositoryInterface qui etend ce contrat).
*/
interface SiteProviderInterface
{
public function findByName(string $name): ?SiteInterface;
}