Aller au contenu

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, parseCalendarTrainerCalendarConnectorParsedEvents sans enrichissement ICS) injecté dans la stratégie Google/Outlook. Les stratégies exposent supportsIcal, supportsIcalProdId (délégation au *IcalCalendarEventParser du même dossier) et parseIcalVo. La synchro ICS : IcsParserInterfaceIcalVO, puis TrainerCalendarConfiguratorSynchronizer résout la stratégie (getStrategyForIcalProdId ou getByProvider(ical) pour l’instance hub IcalTrainerCalendarConnectorStrategy / ical) puis parseIcalVo (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 providers ical et ical_file. À la racine de Connector/ : TrainerCalendarConnectorStrategyRegistry (implémente TrainerCalendarConnectorStrategyRegistryInterface du Domain, injectée côté Application/API), TrainerCalendarOAuthStateSigner, AbstractTrainerCalendarConnectorStrategy (base de toutes les *TrainerCalendarConnectorStrategy, défauts + surcharges par fournisseur). Registry tag app.trainer_calendar_connector ; PersistTrainerCalendarConnectorEventsCommand persiste ensuite (métadonnée format = TrainerCalendarConnectorParsedEvents::provider ; transport fichier : flag ical_file via paramètre dédié).
  • Parseurs d’enrichissement PRODID : implémentations de CalendarParserStrategyInterface, tag calendar.parser (glob dans config/services.yaml : Connector/**/*IcalCalendarEventParser.php). Ex. EdusignIcalCalendarEventParser, HyperplanningIcalCalendarEventParser, NetypareoIcalCalendarEventParser, GoogleIcalCalendarEventParser, OutlookIcalCalendarEventParser (tous étendent DefaultIcalCalendarEventParser : PRODID / supports() + parse() ; fin de parse() : Event::applyParserEnrichment). La déduction d’année scolaire privilégie maintenant DTSTART (date réelle de l’événement), puis bascule en fallback sur l’extraction textuelle (summary, description, location selon le parseur). Les tirets Unicode sont normalisés avant la lecture des plages d’années en texte. À la racine de Infrastructure/Calendar/Parser/ : DefaultIcalCalendarEventParser et CalendarClassDetectionTrait (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 le CalendarConfigurator (TrainerOrganizationAssignmentOrganization) pour la persistance configurateur ; OAuth distant : TrainerCalendarConnectorStrategyInterface::parseCalendar ne prend que le CalendarConfigurator (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é depuis TrainerCalendarOAuthApplicationService::listOAuthCalendars pour marquer CalendarItem::disabled si 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.label si renseigné, sinon titre puis résumé puis nom de classe du CalendarConfiguratorAssignment, 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 en Europe/Paris, chacune wrappée dans RedispatchMessage(..., 'async') : bilan mensuel 0 8 1 * * (SendMonthlyReports), récap semaine 0 17 * * 5 (SendUpcomingWeekReports), resync calendriers (sources distantes : iCal / OAuth) 0 1 * * * (ScheduledTrainerCalendarRemoteResyncDispatchScheduledTrainerCalendarRemoteResyncCommand qui appelle SyncProcessTrainerCalendarIcalCommand::process par 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-scheduler consomme le transport scheduler_default en 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.