Plan de migration complet Lesstime vers modular monolith DDD (archi Starseed) : roadmap en 14 tickets ordonnés par dépendances + design technique détaillé du socle (Shared/, contrats, endpoints modules/sidebar, plan strangler).
16 KiB
LST-56 — Socle modular monolith DDD + pilote « Projets/Tâches »
Ticket Lesstime #56 (1/5 — groupe « Refonte / Alignement Starseed »). Design validé le 2026-06-19. Référence vivante : repo Starseed (
.claude/rules/*.md+ implémentation réelle), etStarseed/doc/architecture-modulaire-malio.md(vision cible théorique — non contraignante là où elle diverge du code réel).
1. Objectif & contraintes
Poser dans Lesstime l'infrastructure d'un modular monolith DDD calquée sur Starseed, et migrer un premier module pilote (Projets/Tâches) de bout en bout comme preuve que la mécanique tient sur le cœur métier.
Contraintes non négociables :
- Ne rien casser de l'existant. Migration strangler progressive : le code legacy (
src/Entity/…) et les modules (src/Module/…) coexistent ; l'application reste fonctionnelle etmake testvert à chaque étape. - Prod = Docker, BDD peuplée → uniquement des migrations additives et nullable (aucun
DROP, aucunNOT NULLrétroactif, aucun déplacement de données). - Profondeur DDD : pragmatique, alignée sur le Starseed réel (pas la doc théorique) : ORM attributs conservés dans les entités Domain, Repository = interface (Domain) + impl Doctrine (Infrastructure), Provider/Processor API Platform, contrats
Shared/Domain/Contractpour le cross-module. Pas de CQRS bus systématique, pas de multi-tenant.
Décisions de cadrage (figées)
| Sujet | Décision |
|---|---|
| Périmètre #56 | Socle complet + 1 module pilote migré de bout en bout |
| Stratégie | Strangler progressif (legacy + modules en parallèle) |
| Profondeur DDD | Pragmatique (= Starseed réel) |
| Module pilote | Projets/Tâches (cœur métier) |
| Dépendances du pilote (User/Client/Notification) | Restent legacy, câblées via contrats Shared/Domain/Contract + resolve_target_entities |
| Infra d'audit Starseed | Différée → ticket Lesstime dédié (créé séparément) |
| Périmètre front #56 | Câblage shell/shared/middlewares + migration du pilote en layer, sans relooking (le relooking Malio reste #60) |
| Exposition API du pilote | Garder les #[ApiResource] actuels (étendre seulement les chemins de scan) — zéro régression API |
| Tâche → Notification | Contrat NotifierInterface (impl legacy crée la Notification) |
| Nom/ID du module | back ProjectManagement / front project-management / ID project_management |
2. Garde-fous Starseed retenus pour #56
Repris : declare(strict_types=1), src/Module/<X>/{Domain,Application,Infrastructure}, Shared/Domain/Contract + resolve_target_entities (zéro import inter-modules), config/modules.php + config/sidebar.php, endpoints /api/modules + /api/sidebar + /api/version, TimestampableBlamableTrait + subscriber, pagination obligatoire, COMMENT ON COLUMN (helper ColumnCommentsCatalog), front layers auto-détectés + useSidebar/useModules + auth.global.ts/modules.global.ts.
Reportés (hors #56) : infra d'audit (#[Auditable]/#[AuditIgnore], table audit_log, listener, resource) → ticket dédié. RBAC fin (module.resource.action) → #57 ; en #56 la sidebar filtre par module actif (au plus un gate ROLE_ADMIN).
3. Backend — arborescence cible
src/Shared/
├── Domain/
│ ├── Contract/ UserInterface, UserResolverInterface, ClientInterface, NotifierInterface
│ ├── Event/ DomainEventInterface
│ └── Trait/ TimestampableBlamableTrait
├── Infrastructure/
│ ├── Doctrine/ TimestampableBlamableSubscriber
│ ├── Database/ ColumnCommentsCatalog (helper COMMENT ON COLUMN + 4 colonnes std)
│ └── ApiPlatform/
│ ├── Resource/ ModulesResource, SidebarResource
│ └── State/ ModulesProvider, SidebarProvider
│
src/Module/ProjectManagement/
├── ProjectManagementModule.php ID='project_management', LABEL='Projets', REQUIRED=false, permissions()=[] (stub, RBAC réel #57)
├── Domain/
│ ├── Entity/ Project, Task, Workflow, TaskStatus, TaskGroup, TaskEffort,
│ │ TaskPriority, TaskTag, TaskRecurrence, TaskDocument
│ └── Repository/ *RepositoryInterface (une interface par agrégat consommé)
├── Application/ RecurrenceCalculator/RecurrenceHandler + services task-centric déplacés
└── Infrastructure/
├── Doctrine/ Doctrine*Repository + Migrations/ (additif Timestampable)
├── ApiPlatform/ State/Provider + State/Processor déplacés (TaskNumber, TaskCalendar,
│ TaskDocument*, SwitchProjectWorkflow, WorkflowDelete, ActiveTimeEntry resté legacy…)
└── Mcp/Tool/ MCP tools Project/, Task/, TaskMeta/, Workflow/ déplacés
src/Entity/ conserve intacts : User, Client, Notification, TimeEntry, AbsenceRequest/AbsencePolicy/AbsenceBalance, Mail*, Gitea*/BookStack*/Zimbra*/Share*Configuration. Ces domaines seront modularisés dans des tickets ultérieurs.
Note de découpage :
TimeEntryreste legacy en #56 (domaine Time tracking séparé). Le lienTask ↔ TimeEntryest porté côtéTimeEntry(FK nullable vers la tabletask) ; aucune contrainte ne casse car la tabletaskne change pas de nom.
4. Câblage des dépendances (zéro import inter-modules)
- Interfaces dans
src/Shared/Domain/Contract/:UserInterface(id + identifiants nécessaires aux entités du module : assignee, collaborators, createdBy/updatedBy),ClientInterface(id + nom, pourProject.client),UserResolverInterface(résoudre un user par id, pour les State/MCP du module),NotifierInterface(créer une notification — impl legacy).
- Les entités du module type-hintent les interfaces, jamais
App\Entity\*. config/packages/doctrine.yaml → orm.resolve_target_entities:resolve_target_entities: App\Shared\Domain\Contract\UserInterface: App\Entity\User App\Shared\Domain\Contract\ClientInterface: App\Entity\ClientApp\Entity\Userimplements UserInterface,App\Entity\Clientimplements ClientInterface(legacy modifié à minima, additif).- Notifications :
App\Module\ProjectManagement\…appelleNotifierInterface; implApp\…\LegacyNotifier(wrappe leNotificationServiceactuel). LeTaskNotificationListenerest déplacé/adapté pour passer par le contrat.
5. Config backend (toutes additives)
doctrine.yaml— ajouter un mapping module (garderApp → src/Entity) :Les entités déplacées gardent leurmappings: App: { type: attribute, is_bundle: false, dir: '%kernel.project_dir%/src/Entity', prefix: 'App\Entity', alias: App } ProjectManagement: type: attribute is_bundle: false dir: '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity' prefix: 'App\Module\ProjectManagement\Domain\Entity'#[ORM\Table(name: '…')]actuel (table inchangée → aucune donnée déplacée).#[ORM\Entity(repositoryClass: DoctrineXxxRepository::class)]mis à jour vers la nouvelle classe.doctrine_migrations.yaml— ajouter le namespace module (garderDoctrineMigrations) :migrations_paths: DoctrineMigrations: '%kernel.project_dir%/migrations' 'App\Module\ProjectManagement\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/ProjectManagement/Infrastructure/Doctrine/Migrations'⚠️ Doctrine Migrations trie par FQCN entre namespaces : le legacy
DoctrineMigrations(setup initial) passe avant les migrations modulaires sur base vide. Sur la prod déjà migrée, seules les nouvelles migrations additives s'appliquent → pas d'impact d'ordre.api_platform.yaml— déclarer les chemins de mapping (entités + resources legacy et module) pour que les#[ApiResource]du pilote restent découverts :mapping: paths: - '%kernel.project_dir%/src/Entity' - '%kernel.project_dir%/src/ApiResource' - '%kernel.project_dir%/src/Shared/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity'services.yaml— mettre à jour les FQCN explicites déplacés :App\EventListener\TaskDocumentListener,App\State\TaskDocumentProcessor,App\Controller\TaskDocumentDownloadController,App\Mcp\Tool\Task\AddTaskDocumentTool,App\Mcp\Tool\Task\UpdateTaskDocumentTool→ nouveaux namespaces module. Le globApp\: '../src/'continue d'autowire les classes déplacées.
6. Garde-fous portés dans #56
- TimestampableBlamable : trait
Shared/Domain/Trait/TimestampableBlamableTrait(4 colonnescreated_at,updated_at,created_by,updated_by— toutes nullable), rempli parTimestampableBlamableSubscriber(prePersist/preUpdate). Appliqué aux entités du pilote → 1 migration additive par table concernée, avecCOMMENT ON COLUMNviaColumnCommentsCatalog::addStandardTimestampableBlamableComments(). - Pagination : conserver le standard API Platform actuel (les collections du pilote restent paginées comme aujourd'hui).
COMMENT ON COLUMN: appliqué sur les colonnes ajoutées par #56 (pas de rétro-commentaire forcé sur le legacy).
7. Endpoints modules / sidebar / version
GET /api/modules(public) —ModulesResource+ModulesProviderlisantconfig/modules.php(renvoie{ modules: ["project_management", …] }).GET /api/sidebar(auth) —SidebarResource+SidebarProviderlisantconfig/sidebar.php; filtrage par module actif (itemmoduleabsent de la liste active → masqué + route ajoutée àdisabledRoutes) ; gate de section optionnelROLE_ADMIN. Le filtrage par permissions fines est explicitement reporté à #57.GET /api/version— déjà présent (AppVersion) ; vérifier le format{ version }, ré-aligner si besoin (déplacement optionnel versShared/).config/modules.php:return [ ProjectManagementModule::class ];(Core viendra plus tard ; pas de module REQUIRED bloquant en #56).config/sidebar.php: sections « Projets » / « Mes tâches » avecmodule => 'project_management'; les entrées des domaines encore legacy (Time tracking, Absences, Mail, Admin…) listées sans clémodule(donc toujours visibles) pour ne rien masquer.
8. Frontend — câblage + pilote en layer (sans relooking)
frontend/app/
├── layouts/default.vue shell : sidebar (depuis /api/sidebar) + main
├── middleware/auth.global.ts protège routes, charge sidebar+modules après login
└── middleware/modules.global.ts redirige si route ∈ disabledRoutes
frontend/shared/
├── composables/ useApi (déplacé), useSidebar, useModules, + existants réutilisés
├── stores/ auth, ui, timer (timer reste partagé : Time tracking encore legacy)
├── utils/ api.ts (extractHydraMembers/fetchAllHydra), …
└── types/
frontend/modules/project-management/
├── nuxt.config.ts defineNuxtConfig({})
├── pages/ my-tasks.vue, projects/index.vue, projects/[id]/* (déplacés tels quels)
├── components/ task/*, project/* (déplacés)
├── services/ tasks.ts, projects.ts, task-*.ts, workflows.ts (déplacés)
└── stores/ (si spécifiques au domaine)
nuxt.config.ts: auto-détection des layersmodules/*/(scanreaddirSynccomme Starseed) ajoutés àextends, + dirs d'auto-import des composables/stores par layer.extends: ['@malio/layer-ui']conservé en tête.useSidebar/useModules: état singleton,loadSidebar()/loadModules()appelés dansauth.global.ts,reset*()au logout.modules.global.ts:isRouteDisabled(to.path)→navigateTo('/').- Migration des pages : déplacement sans réécriture visuelle ; les pages des autres domaines (time-tracking, absences, mail, admin, profile…) restent dans
frontend/pages/(legacy) tant que leurs modules ne sont pas migrés. Nuxt fusionne les routes du shell + des layers → cohabitation transparente.
Point de vigilance front : vérifier que la cohabitation
frontend/pages/(legacy) +frontend/modules/*/pages/(layer) ne crée pas de collision de routes ;my-tasks/projectssont déplacés et retirés defrontend/pages/pour éviter le doublon.
9. Plan strangler (ordre d'exécution — app verte à chaque palier)
- Shared/ + garde-fous : trait, subscriber,
ColumnCommentsCatalog. Neutre (rien ne les consomme encore). - Endpoints modules/sidebar +
config/modules.php+config/sidebar.php(toutes entrées legacy sansmodule→ rien masqué). Additif. - Contrats
Shared/Domain/Contract+resolve_target_entities+User/Clientimplements …Interface. Neutre. - Déplacement back du module ProjectManagement (entités → Domain/Entity, repos → Infra/Doctrine + interfaces Domain, State, MCP) + mises à jour
doctrine.yaml/api_platform.yaml/doctrine_migrations.yaml/services.yaml.make testvert. - Migration additive Timestampable sur les tables du pilote (+
COMMENT ON COLUMN). - Front shell :
app/+shared/+ middlewares + auto-détectionnuxt.config.ts. App encore en pages plates. - Déplacement front du pilote vers
modules/project-management/(pages/components/services), retrait des doublons defrontend/pages/. - Vérification bout-en-bout : commenter
ProjectManagementModule::classdansconfig/modules.php→/api/modulesne le liste plus,/api/sidebarmasque ses entrées + peupledisabledRoutes, le front redirige/my-tasks→/. Décommenter → tout revient. Documenter le test.
10. Critères d'acceptation (repris du ticket, raffinés)
src/Shared/+src/Module/ProjectManagement/{Domain,Application,Infrastructure}en place./api/modules,/api/sidebarfonctionnels ;/api/versionaligné.- Aucun import direct
App\Entity\User/Clientdepuis le module (contrats +resolve_target_entities). - Front : layers
frontend/modules/*/auto-détectés ;useSidebar/useModules+auth.global.ts/modules.global.tsopérationnels ; pilote migré sans régression visuelle. - Garde-fous : TimestampableBlamable (migration additive +
COMMENT ON COLUMN) ; pagination conservée. Audit explicitement hors périmètre (ticket dédié). make testvert ; activation/désactivation du module validée de bout en bout.- Aucune migration destructive ; prod déployable sans perte.
11. Risques & points de vigilance
- Prod peuplée : seules migrations additives nullable.
created_by/updated_bynon backfillés (historique) — conforme Starseed. - Changement de namespace des entités : sans impact DB (Doctrine mappe par table). Vérifier qu'aucun code legacy ne référence en dur
App\Entity\Tasketc. → grep + remplacement (le pilote tire Task/Project, consommés par TimeEntry/Mail/BookStack links restés legacy : ces liens passeront par les contrats ou un type-hint relâché). - Collision de routes front legacy vs layer (cf. §8).
- MCP tools (spécificité Lesstime) : déplacés sous
Module/*/Infrastructure/Mcp/; confirmer queMcpSchemaGeneratorPassles redécouvre (scansrc/). auto_mapping: true: valider que l'ajout d'un mapping explicite ne perturbe pas la résolution (sinon désactiverauto_mappinget lister explicitement).
12. Suite
- Ticket audit dédié à créer (infra
#[Auditable]+audit_log+ listener + resource), prérequis souple de #57. - #57 RBAC fin (permissions
module.resource.action, sidebar filtrée par permission). - #58 Répertoire (Clients/Prospects), #59 Reporting, #60 Refonte front Malio.