Architecture Backend Teadle
🏗️ Vue d'ensemble architecturale
Teadle utilise une architecture hexagonale (Clean Architecture) avec les principes du Domain-Driven Design (DDD) pour garantir une séparation claire des responsabilités et une maintenabilité optimale.
🎯 Principes architecturaux
1. Séparation des couches
- Indépendance : Chaque couche ne dépend que des couches internes
- Inversion de dépendance : Les dépendances pointent vers l'intérieur
- Isolation : Le domaine métier est isolé des détails techniques
2. Domain-Driven Design
- Ubiquitous Language : Langage commun entre développeurs et experts métier
- Bounded Contexts : Contextes métier bien délimités
- Aggregates : Entités avec leurs invariants métier
📐 Architecture en couches
graph TB
subgraph "Infrastructure Layer"
A[API Platform Resources]
B[Doctrine ORM]
C[Security Providers]
D[External Services]
E[Symfony Integration]
end
subgraph "Application Layer"
F[Commands]
G[Handlers]
H[Application Services]
end
subgraph "Domain Layer"
I[Entities]
J[Value Objects]
K[Domain Services]
L[Repositories Interfaces]
M[Domain Events]
end
A --> F
B --> L
C --> K
D --> H
E --> F
F --> I
G --> I
H --> K
I --> J
I --> K
K --> M
style I fill:#e1f5fe
style J fill:#e1f5fe
style K fill:#e1f5fe
style L fill:#e1f5fe
style M fill:#e1f5fe
🏛️ Couches détaillées
1. Domain Layer - Le cœur métier
Entités (Entities)
// Exemple : User.php
abstract class User implements UserInterface, PasswordAuthenticatedUserInterface
{
private ?int $id = null;
private ?string $firstname = null;
private ?string $lastname = null;
private ?string $email = null;
private array $roles = [];
private ?string $password = null;
private ?string $oauthProvider = null;
private ?string $oauthProviderId = null;
private bool $acceptCgv = false;
private bool $acceptOptIn = false;
private bool $verifiedEmail = false;
private bool $finishedOnBoarding = false;
}
Caractéristiques :
- Identité unique : Chaque entité a un ID
- Invariants métier : Règles de validation appliquées dès la construction (ex. TrainerAvailabilitySlot lance une \InvalidArgumentException si endTime <= startTime)
- Comportement : Méthodes métier
- Persistence ignorée : Pas de dépendance à l'ORM
Value Objects
// Exemple : PhoneNumber.php
class PhoneNumber
{
private string $value;
public function __construct(string $value)
{
$this->validate($value);
$this->value = $value;
}
private function validate(string $value): void
{
// Validation métier
}
}
Caractéristiques : - Immutabilité : Pas de modification après création - Validation : Règles métier intégrées - Égalité par valeur : Deux objets égaux si même valeur
Services de domaine
// Exemple : InterventionCalculator.php
class InterventionCalculator
{
public function calculateIncome(Trainer $trainer, DateTimeImmutable $startDate, DateTimeImmutable $endDate): float
{
// Logique métier de calcul des revenus
}
}
Caractéristiques : - Logique métier complexe : Opérations qui ne peuvent pas être dans les entités - Stateless : Pas d'état interne - Purement métier : Pas de dépendance technique
Repositories (Interfaces)
// Exemple : UserRepositoryInterface.php
interface UserRepositoryInterface
{
public function findOneByEmail(string $email): ?User;
public function save(User $user): void;
public function findByType(string $type): array;
}
Caractéristiques : - Interfaces uniquement : Pas d'implémentation dans le domaine - Méthodes métier : Noms basés sur le langage métier - Indépendance technique : Pas de dépendance à l'ORM
2. Application Layer - Orchestration
Commands (CQRS)
// Exemple : RequestPasswordResetCommand.php
readonly class RequestPasswordResetCommand
{
public function __construct(
private UserRepositoryInterface $userRepository,
private PasswordResetRequestRepositoryInterface $passwordResetRequestRepository,
private PasswordResetTokenGenerator $tokenGenerator,
private EmailService $emailService,
) {}
public function process(string $email): void
{
// Orchestration du processus métier (envoi email via emailService.sendPasswordResetEmail)
}
}
Caractéristiques : - Orchestration : Coordonne les services de domaine - Transactionnel : Gère les transactions métier - Stateless : Pas d'état entre les appels
Handlers
// Exemple : RetrieveUserHandler.php
class RetrieveUserHandler
{
public function __construct(
private UserRepositoryInterface $userRepository
) {}
public function handle(RetrieveUserQuery $query): ?User
{
return $this->userRepository->findOneByEmail($query->email);
}
}
Caractéristiques :
Services d'application — Envoi d'emails (EmailService)
// Application/Service/Email/EmailService.php
// Application/Service/Email/EmailTemplateRegistry.php
// EmailTemplateRegistry : expose une méthode typée par template (passwordReset(), trainerRegistered(), etc.)
// Les IDs Brevo sont centralisés dans config/packages/email_templates.yaml (clés métier → IDs templates)
// EmailService : service centralisé avec des méthodes métier explicites pour chaque type d'email :
// sendPasswordResetEmail, sendTrainerRegisteredEmail, sendAuthCodeEmail, sendStaffFinishedOnboardingEmail,
// sendStaffInvitationEmails, sendSecurityTokenEmail, sendTrainerInvitationEmail, sendReservationBatchCancelledEmail,
// sendReservationRelaunchEmail, sendActivityReportEmail, sendInvoiceEmail, sendPlanningShareLinkEmails,
// sendInviteContactToTeadleEmail, sendTrainerSummaryOrganizationInfoEmail, sendMonthlyReportEmail.
// Sous le capot : dispatch SendTemplateEmail / SendTemplateEmailWithAttachments via MessageBusInterface (async).
Services d'application — Builders de DTO (orchestration) :
- Les builders qui orchestrent plusieurs services Application (planning, conflits, créneaux libres) pour assembler un DTO appartiennent à Application, pas au Domain.
- Exemple : ReservationBatchDtoBuilder (Application/Service/Reservation/) coordonne PlanningRetrievalService, ReservationBatchFreeSlotsService, ReservationConflictEventService et ReservationAvailabilityCalculator pour construire TrainerReservationBatchDto.
- TrainerFreeSlotsService alimente AvailabilityProvider : seules les dates de jours fériés non travaillés (holidayOverrides à false) sont exclues du maillage ; un férié travaillé conserve les créneaux du jour (même logique que AvailableHoursCalculator). Utilisé notamment par la recherche de créneaux planning public (slot/search, slots/all) et TrainerSlotAvailabilityCheckerService.
- Le Domain ne doit pas dépendre de l'Application.
Builders de DTO d'orchestration :
- Les services qui assemblent des DTO en orchestrant plusieurs services applicatifs (planning, conflits, disponibilités, etc.) vivent dans Application/Service/.
- Exemple : ReservationBatchDtoBuilder (Application/Service/Reservation/) — orchestre PlanningRetrievalService, ReservationConflictEventService, ReservationBatchFreeSlotsService et délègue le calcul métier pur à ReservationAvailabilityCalculator (Domain).
- Le Domain ne dépend pas de l'Application : ces builders ne doivent pas être dans Domain/.
Pattern email :
- Centralisation : Tous les envois passent par EmailService
- Templates en config : email_templates.yaml mappe des clés métier (password_reset, trainer_registered, etc.) vers les IDs Brevo
- Méthodes métier : Les Commands/Handlers/Subscribers appellent EmailService::sendXxxEmail() au lieu de dispatcher directement SendTemplateEmail
- Async : L'EmailService dispatche toujours via le MessageBus (Symfony Messenger)
- Gestion des requêtes : Traitement des queries CQRS
- Mapping : Conversion entre couches
- Validation : Vérification des données d'entrée
Services d'application — Builders de DTO
Les builders qui orchestrent plusieurs services Application pour construire un DTO (ex. ReservationBatchDtoBuilder) vivent dans Application/Service/. Ils coordonnent des services applicatifs (planning, conflits, disponibilités) et appellent le Domain pour la logique métier pure (ex. ReservationAvailabilityCalculator). Le Domain ne doit jamais importer de classes Application.
3. Infrastructure Layer - Détails techniques
API Platform Resources
// Exemple : PasswordResetResource.php
#[ApiResource(
operations: [
new Post(
processor: PasswordResetRequestProcessor::class,
input: PasswordResetRequest::class,
uriTemplate: '/security/password-reset/request'
),
]
)]
class PasswordResetResource
{
// Configuration API Platform
}
Caractéristiques : - Configuration API : Définition des endpoints - Processors : Traitement des requêtes - Validation : Contraintes d'entrée
Processors API Platform
// Exemple : PasswordResetRequestProcessor.php
class PasswordResetRequestProcessor implements ProcessorInterface
{
public function __construct(
private ValidatorInterface $validator,
private RequestPasswordResetCommand $requestPasswordResetCommand,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
$this->validator->validate($data);
$this->requestPasswordResetCommand->process($data->email);
return null;
}
}
Caractéristiques : - Pont vers l'application : Appelle les commandes - Validation : Vérification des données - Transformation : Conversion des formats
Repositories (Implémentations)
// Exemple : UserRepository.php
class UserRepository implements UserRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager
) {}
public function findOneByEmail(string $email): ?User
{
return $this->entityManager->getRepository(User::class)
->findOneBy(['email' => $email]);
}
}
Caractéristiques : - Implémentation technique : Utilise Doctrine ORM - Mapping : Conversion entre entités et base de données - Optimisation : Requêtes optimisées
Calendrier formateur (Infrastructure)
- Connecteurs (
Infrastructure/Calendar/Connector/) : un dossier par fournisseur (Google/,Outlook/,Ical/pour le hub ICS lien + fichier,Apple/,Edusign/,Hyperplanning/,Ypareo/) avec la stratégie (*TrainerCalendarConnectorStrategy), et le cas échéant un module OAuth (TrainerCalendarOAuthEventsParserInterface, ex.GoogleOAuthCalendarEventsParser: URL d’autorisation, échange de code,parseCalendar→TrainerCalendarConnectorParsedEventssans enrichissement ICS) injecté dans la stratégie Google/Outlook. Les stratégies exposentsupportsIcal,supportsIcalProdId(délégation au*IcalCalendarEventParserdu même dossier) etparseIcalVo. La synchro ICS :IcsParserInterface→IcalVO, puisTrainerCalendarConfiguratorSynchronizerrésout la stratégie (getStrategyForIcalProdIdougetByProvider(ical) pour l’instance hubIcalTrainerCalendarConnectorStrategy/ical) puisparseIcalVo(repli générique :DefaultIcalCalendarEventParser). Deux services Symfony (trainer_calendar_connector.ical_url/trainer_calendar_connector.ical_file) partagent la même classe pour les providersicaletical_file. À la racine deConnector/:TrainerCalendarConnectorStrategyRegistry(implémenteTrainerCalendarConnectorStrategyRegistryInterfacedu Domain, injectée côté Application/API),TrainerCalendarOAuthStateSigner,AbstractTrainerCalendarConnectorStrategy(base de toutes les*TrainerCalendarConnectorStrategy, défauts + surcharges par fournisseur). Registry tagapp.trainer_calendar_connector;PersistTrainerCalendarConnectorEventsCommandpersiste ensuite (métadonnéeformat=TrainerCalendarConnectorParsedEvents::provider; transport fichier : flagical_filevia paramètre dédié). - Parseurs d’enrichissement PRODID : implémentations de
CalendarParserStrategyInterface, tagcalendar.parser(glob dansconfig/services.yaml:Connector/**/*IcalCalendarEventParser.php). Ex.EdusignIcalCalendarEventParser,HyperplanningIcalCalendarEventParser,NetypareoIcalCalendarEventParser,GoogleIcalCalendarEventParser,OutlookIcalCalendarEventParser(tous étendentDefaultIcalCalendarEventParser: PRODID /supports()+parse(); fin deparse():Event::applyParserEnrichment). La déduction d’année scolaire privilégie maintenantDTSTART(date réelle de l’événement), puis bascule en fallback sur l’extraction textuelle (summary,description,locationselon le parseur). Les tirets Unicode sont normalisés avant la lecture des plages d’années en texte. À la racine deInfrastructure/Calendar/Parser/:DefaultIcalCalendarEventParseretCalendarClassDetectionTrait(hors dossiers connecteur). - Synchro configurateur / perso : contrat commun
TrainerRemoteCalendarSynchronizerInterface(Application/Service/Trainer/Calendar/) —synchronizeFromRemote(CalendarConfigurator|TrainerAvailabilityConfiguration $configuration, Trainer $trainer, Process $process); l’organisation orga est lue sur leCalendarConfigurator(TrainerOrganizationAssignment→Organization) pour la persistance configurateur ; OAuth distant :TrainerCalendarConnectorStrategyInterface::parseCalendarne prend que leCalendarConfigurator(jetons + id d’agenda). Les handlers Messenger injectent l’interface avec#[Autowire(service: …)]. Les branches URL / fichier ICS délèguent àTrainerCalendarIcalRemoteContentParser/TrainerCalendarIcalParseResult. - Liste d’agendas OAuth (choix post-échange de code) :
TrainerOAuthRemoteCalendarListConflictMarker(Application/Service/Trainer/Calendar/) — appelé depuisTrainerCalendarOAuthApplicationService::listOAuthCalendarspour marquerCalendarItem::disabledsi l’id d’agenda distant est déjà rattaché à une autre organisation ou déjà utilisé en agenda personnel OAuth.
🔄 Flux de données
1. Requête entrante
sequenceDiagram
participant Client
participant API Resource
participant Processor
participant Command
participant Domain Service
participant Repository
participant Database
Client->>API Resource: POST /security/password-reset/request
API Resource->>Processor: process(data)
Processor->>Command: process(email)
Command->>Domain Service: generateToken()
Command->>Repository: save(resetRequest)
Repository->>Database: INSERT
Command->>EmailService: sendPasswordResetEmail()
EmailService->>MessageBus: dispatch(SendTemplateEmail)
2. Réponse sortante
sequenceDiagram
participant Database
participant Repository
participant Handler
participant API Resource
participant Client
Database->>Repository: SELECT data
Repository->>Handler: findOneByEmail(email)
Handler->>API Resource: return user
API Resource->>Client: JSON response
3. Flux Symfony Scheduler – Bilan mensuel
sequenceDiagram
participant Supervisor as Worker symfony-scheduler
participant Schedule as Schedule (AsSchedule)
participant Bus as MessageBus (async)
participant MsgHandler as SendMonthlyReportsHandler
participant Repo as TrainerRepository
participant Calc as MonthlyReportCalculator
participant Email as EmailService
Supervisor->>Schedule: consume scheduler_default
Schedule->>Bus: RecurringMessage::cron('0 8 1 * *') → RedispatchMessage(SendMonthlyReports, 'async')
Bus->>MsgHandler: __invoke(SendMonthlyReports)
MsgHandler->>Repo: findAllWithFinishedOnboarding()
loop Pour chaque Trainer avec optin MONTHLY_SUMMARY + EMAIL
MsgHandler->>Calc: calculate(trainer, previousMonth)
Calc-->>MsgHandler: MonthlyReportDTO
MsgHandler->>Email: sendMonthlyReportEmail(dto)
Email->>Bus: dispatch(SendTemplateEmail) → Brevo #23
end
🎯 Bounded Contexts
1. Identity & Access Management
- Entités : User, AuthCode, PasswordResetRequest
- Services : OAuthAuthenticator, PasswordResetTokenGenerator
- APIs :
/security/*
2. Onboarding
- Entités : User (finishedOnBoarding)
- Services : OnboardingService
- APIs :
/onboarding/*
3. Training Management
- Entités : Trainer, TrainerExperience, TrainerEducation
- Services : CvAnalyzerService
- APIs :
/trainer/*
4. Organization Management
- Entités : Organization
- Services : OrganizationService
- APIs :
/organization/*
5. Opportunity Management
- Entités : Opportunity, OpportunityProposal, Candidacy
- Services : OpportunityService
- APIs :
/opportunity/*
6. Event Management
- Entités : Event, InterventionEvent, PersonalEvent
- Services : EventService
- APIs :
/event/*
7. Billing
- Entités : Invoice
- Services : BillingService
- APIs :
/billing/*
8. Notifications & Reporting
- Value Objects :
MonthlyReportDTO,MonthlyReportSchoolDTO(Domain/ValueObject/Trainer/MonthlyReport/) - Services :
MonthlyReportCalculator(Domain/Service/MonthlyReport/) — calcule le bilan mensuel d'un formateur (interventions, revenus, répartition par école) ; heures et montants arrondis à 2 décimales pour le template email ; libellé « classe la plus vue » :InterventionEvent.labelsi renseigné, sinon titre puis résumé puis nom de classe duCalendarConfiguratorAssignment, sinon « Sans titre » - Enums :
OptinType::MONTHLY_SUMMARY— optin pour le bilan mensuel par email - Messages Messenger :
SendMonthlyReports(Domain/Messenger/) — message async déclenché par le Scheduler - Handlers :
SendMonthlyReportHandler(Application) — orchestre le calcul et l'envoi pour un formateur ;SendMonthlyReportsHandler(Infrastructure/Symfony/Messenger/) — consomme le message et itère sur les formateurs éligibles - Scheduler :
Schedule(src/Schedule.php,#[AsSchedule]) — tâches cron enEurope/Paris, chacune wrappée dansRedispatchMessage(..., 'async'): bilan mensuel0 8 1 * *(SendMonthlyReports), récap semaine0 17 * * 5(SendUpcomingWeekReports), resync calendriers (sources distantes : iCal / OAuth)0 1 * * *(ScheduledTrainerCalendarRemoteResync→DispatchScheduledTrainerCalendarRemoteResyncCommandqui appelleSyncProcessTrainerCalendarIcalCommand::processpar configurateur éligible — même chaîne que le POST/trainer/calendar/configurator/{uuid}/sync) ;stateful($cache)+processOnlyLastMissedRun(true)pour la résilience et la protection anti-doublon - Worker Supervisor :
symfony-schedulerconsomme le transportscheduler_defaulten production
🔧 Patterns utilisés
1. Repository Pattern
// Interface dans le domaine
interface UserRepositoryInterface
{
public function findOneByEmail(string $email): ?User;
}
// Implémentation dans l'infrastructure
class UserRepository implements UserRepositoryInterface
{
// Implémentation avec Doctrine
}
2. Command Pattern (CQRS)
// Commande
readonly class RequestPasswordResetCommand
{
public function process(string $email): void
{
// Logique métier
}
}
// Processor API Platform
class PasswordResetRequestProcessor
{
public function process($data, $operation)
{
$this->requestPasswordResetCommand->process($data->email);
}
}
3. Value Object Pattern
class PhoneNumber
{
private string $value;
public function __construct(string $value)
{
$this->validate($value);
$this->value = $value;
}
}
4. Domain Events + Event Subscribers
// Événements de domaine (Domain/Event/)
class OrganizationRegisteredEvent
{
public function __construct(
public readonly Organization $organization
) {}
}
// Exemple : TrainerOrganizationPricingValidatedEvent
// Dispatché quand un formateur valide ses prix dans un CalendarConfigurator.
// L'event porte le CalendarConfigurator concerné.
// Event Dispatcher (via Symfony EventDispatcherInterface)
// Les Commands dispatchent des Domain Events après les opérations métier.
// Event Subscribers (Infrastructure/Symfony/EventSubscriber/)
// Écoutent les events et orchestrent les effets de bord (envoi d'emails, notifications, etc.).
// Exemple : TrainerOrganizationPricingValidatedEventSubscriber
// - Calcule les métriques (classCount, courseCount, hoursCount, forecastedRevenue, averageHourlyRate)
// - Vérifie l'idempotence via un champ date sur l'entité (ex. sendTrainerSummaryOrganizationInfoDate)
// - Envoie l'email via EmailService
Pattern Domain Event → Subscriber → Email :
- La Command (Application) dispatche un Domain Event après l'opération métier
- L'EventSubscriber (Infrastructure) écoute l'event, calcule les données nécessaires, vérifie l'idempotence, puis appelle EmailService
- L'EmailService (Application) dispatche le message async via le MessageBus
- L'idempotence est gérée par un champ DateTimeImmutable nullable sur l'entité ; si non-null, l'email n'est pas renvoyé
🎨 Avantages de cette architecture
1. Maintenabilité
- Séparation claire : Chaque couche a une responsabilité
- Indépendance : Modification d'une couche n'affecte pas les autres
- Testabilité : Tests unitaires facilités
2. Évolutivité
- Ajout de fonctionnalités : Nouveaux bounded contexts
- Changement technologique : Remplacement d'une couche
- Scalabilité : Distribution possible des couches
3. Qualité du code
- Lisibilité : Code auto-documenté
- Réutilisabilité : Services réutilisables
- Robustesse : Validation à chaque niveau
4. Sécurité
- Validation : Multi-niveaux de validation
- Isolation : Domaine protégé des attaques
- Audit : Traçabilité des opérations
🚀 Évolutions futures
1. Microservices
- Découpage : Par bounded context
- APIs : Communication inter-services
- Base de données : Base par service
2. Event Sourcing complet
- Événements : Toutes les modifications
- Projections : Vues optimisées
- Audit : Historique complet
3. CQRS avancé
- Read Models : Modèles de lecture optimisés
- Write Models : Modèles d'écriture
- Synchronisation : Entre read et write
Cette architecture garantit une base solide pour l'évolution et la maintenance du projet Teadle.