Architecture back-end
1. Architecture back — vue d’ensemble
Section titled “1. Architecture back — vue d’ensemble”Le back-end est, à mon sens, la partie du produit qui en dit le plus long sur l’ingénieur qui l’a conçue. C’est ici que se jouent le gros des décisions métier, le choix des dépendances tierces et les marges de scaling. Je vais donc m’attacher à détailler non seulement comment cette couche fonctionne, mais pourquoi j’ai tranché ainsi, et ce que ces choix impliquent pour Neeko, aujourd’hui comme demain, jusque dans les dettes que j’ai sciemment acceptées.
1.1 La stack
Section titled “1.1 La stack”Neeko utilise aujourd’hui une stack relativement complexe et coordonnée (bien que cela n’ait pas toujours été le cas). Tout d’abord au centre de tous les échanges de l’application Laravel, choisi à l’origine par les premiers développeurs freelance de Neeko, je l’ai gardé pour trois raisons :
- Tout d’abord c’est un framework extrêmement efficient. Dans la perspective de restructurer entièrement un produit, il permettait un déploiement rapide des mises à jour sans se soucier des choix cosmétiques. Pour une jeune entreprise qui devait redresser vite un produit déjà en production, cette rapidité de livraison relevait moins du confort technique que d’un impératif commercial de vitesse de mise sur le marché (en l’occurrence de remise sur le marché pour la V2).
- Il est batteries included c’est-à-dire que tout est déjà là, j’ai pu rapidement connecter selon mes besoins, Reverb (websocket pour remplacer echo-server), Horizon (queue monitoré), Redis (caching), Passport (pour la gestion des utilisateurs), et naturellement MySQL. Moins de briques hétérogènes à assembler et à maintenir seul, un temps précieux de gagné pour le développement de features directement en lien avec le business.
- Il n’est pas seulement rapide, il est robuste et éprouvé. En effet le framework est activement suivi, activement utilisé, ses libs se renforcent à mesure que le temps passe, quasiment tout est compatible avec Laravel. Miser sur une technologie pérenne et massivement adoptée était aussi un choix stratégique de long terme, car une stack répandue sécurise le recrutement futur de développeurs et réduit le risque porté par l’entreprise.
- Enfin, il était déjà là… Voilà le premier choix pragmatique que j’ai dû prendre en arrivant. Étant originaire de Node.js j’ai longuement réfléchi à faire une migration vers Adonis.js ou express.js, mais quand un produit d’ampleur en production tourne déjà sur certaines technos et qu’il faut le redresser complètement car mal pensé à l’origine, il est inconcevable de changer un back-end en big bang et je n’ose même pas aborder l’idée d’un strangler fig.
1.2 L’ossature asynchrone
Section titled “1.2 L’ossature asynchrone”Comme évoqué plus haut, le back-end est, dans une certaine mesure, relativement complexe. On est loin du simple « requête → action → réponse ». Pour des raisons évidentes de performance, de confort utilisateur et de passage à l’échelle, j’ai ajouté et coordonné Redis, Reverb et Horizon — ce qui donne naissance à des parcours de données bien plus riches. Ce n’est pas un raffinement technique gratuit, le temps réel est au cœur de la proposition de valeur de Neeko, une marketplace géographique dont la promesse est de mettre en relation les bonnes personnes au bon endroit sans délai perceptible “in app” et en moins de 2h.
- Quand un utilisateur télécharge son profil, rien d’exotique : une requête GET, un SELECT sur MySQL, et le serveur renvoie une apiResource normée à l’appelant. Requête, réponse, terminé.
- Une action peut aussi, en plus de sa réponse, déclencher un événement Reverb poussé spontanément à un autre utilisateur abonné — sans qu’il ait rien demandé. Le cas le plus simple : User 1 envoie un POST, le serveur lui répond « FAIT » de façon synchrone, et diffuse dans la foulée un événement que User 2 reçoit spontanément.
- Cependant tous les broadcasts ne se valent pas. Un événement léger et attendu sur-le-champ (une mission qui change d’état, un message de chat) est émis en synchrone : il part dans la foulée de la requête, latence minimale. Mais certains sont trop coûteux pour faire attendre l’appelant ou simplement pas urgents (recalcul de la carte de tension, agrégats du dashboard admin). Ceux-là, je les fais transiter par Horizon, la requête rend la main immédiatement, un worker de queue prend le relais, et l’événement n’est diffusé vers Reverb qu’une fois prêt. Le curseur se règle event par event, selon sa lourdeur. ShouldBroadcastNow pour l’immédiat, ShouldBroadcast (mis en file) pour le reste.
- Et on y vient naturellement, certaines actions sont delayed dans une queue parce qu’elles demandent un traitement lourd ou ne sont pas prioritaires. Pourquoi ? Parce qu’une requête ne doit pas durer trop longtemps, sous peine de saturer une capacité de connexions limitée. La queue Horizon résout le problème en faisant partir le travail ailleurs et la requête se termine dès réception, sans attendre la fin de la tâche. Par exemple, créer une mission ou passer un travailleur en ligne déclenche un recalcul global ; impensable de laisser l’utilisateur devant un chargement le temps qu’il s’achève, on envoie donc ça dans la queue !
1.3 La carte des domaines
Section titled “1.3 La carte des domaines”Le back se lit à trois étages. Au centre, six domaines métier : l’Identité et l’accès, les Utilisateurs et profils, la Mission, le Matching, le Paiement ainsi que la Communication. Sous eux, une couche transverse Géo et cache tient l’état temps réel en Redis que Mission et Matching consomment tous les deux. Au-dessus, le Webadmin lit à travers ces domaines pour le pilotage et la modération. Il reste enfin un certain nombre de services utilitaires sans métier commun : réglages, images, santé, support.
| Nature | Domaine | Responsabilité | Détail |
|---|---|---|---|
| Métier | Identité & accès | Authentifie les utilisateurs (email, Google, Apple) et gère les jetons d’API | auth.md |
| Métier | Utilisateurs & profils | Porte les acteurs (particulier, worker, entreprise) et leur progression (rang, XP) | users.md |
| Métier | Mission | Cycle de vie complet d’une mission, de la création à la clôture, facturation comprise | mission.md |
| Métier | Matching | Détermine en temps réel les workers et missions éligibles entre eux | matching.md |
| Métier | Paiement & premium | Abonnements et transactions via Stripe | paiement.md |
| Métier | Communication | Chat, notifications push et internes, emails et events WebSocket | communication.md |
| Transverse | Géo & cache chaud | État chaud en Redis (positions, tension) et indexation géographique H3 | redis-geocache.md |
| Surface | Webadmin | Pilotage, statistiques et modération, en lecture à travers les domaines | webadmin.md |
| Utilitaires | Utilitaires | Réglages, images, SIRET, santé, actions en attente, support | pas de page |
1.4 Le modèle de données pivot
Section titled “1.4 Le modèle de données pivot”C’est ici un diagramme SQL simplifié et tronqué de la plupart des tables utilitaires et des champs inutiles à la compréhension globale de la structure.
1.5 Les conventions d’architecture
Section titled “1.5 Les conventions d’architecture”À mesure que je redressais le produit, j’ai tâché de lui donner de la structure, de la réplicabilité et une norme globale. Il me semblait important qu’en vue d’une augmentation de l’équipe, de l’arrivée de collaborateurs plus ou moins expérimentés, ou bien encore d’un audit externe, le code dans son ensemble soit lisible, compréhensible, testable et testé. D’autant plus que c’était un gros point noir de sa version initiale. J’ai donc dû faire des choix, accepter certaines mauvaises pratiques à l’incidence maîtrisée, et revoir complètement beaucoup d’autres à l’impact long terme désastreux, ce qui nous porte à ceci aujourd’hui :
- Un découplage des métiers étrangers, ainsi que des services / controllers. Aujourd’hui il est obligatoire d’avoir un service spécifique à chaque métier, avec sa batterie de tests unitaires conçue dans l’esprit DRY (la logique vit à un seul endroit, réutilisée plutôt que dupliquée, donc testée une seule fois plutôt qu’un exponentiel d’edge cases), puis à leur tour les utiliser dans des controllers spécifiques qui eux ne doivent jamais gérer le métier et simplement contrôler le flux réseau (permission, faisabilité d’une requête, retour d’erreur, formatage en ApiResource, etc).
- Dans cette même logique l’emploi du service locator est prohibé, il n’existe quasiment plus aucun App() dans le back-end au profit d’une injection propre de dépendance par constructeur permettant justement le test de chaque service, chaque feature.
- Les models ne portent jamais de logique métier, uniquement les relations et certains accessors de lecture dérivés type
isPremiumou bien encorehasGoogle. C’est dans la droite lignée du découplage strict, chaque partie sa responsabilité et elle ne doit pas porter par moment celle d’un autre. - Enfin très important à mon sens, un phpDoc fourni qui détaille réellement chaque fonction avec la précision des types spéciaux et des throw. Cela permet une compréhension immédiate du code au survol pour l’ensemble du projet et donc naturellement une efficacité et une réduction du taux d’erreur de l’équipe au global tout en étant plus accueillant pour un nouvel arrivant.
Un exemple réel, tiré de TensionMapService, où le PHPDoc porte la description fonctionnelle, les étapes clés, un @return au type non-inférable et le @throws :
/** * Retourne la tension autour d'un point (vue worker), strategy mission-based : * 1. GEORADIUS pour trouver les missions ouvertes dans le rayon * 2. Pipeline SMEMBERS pour recuperer les hexes contribues par ces missions * 3. HMGET sur l'union pour les compteurs * Bornee par le nombre de missions dans le viewport, pas par la resolution H3. * * @return array<string, int> [hexId => count] des zones tendues autour * * @throws Throwable */public function getTensionAround(float $lat, float $lng, float $radiusKm = 55.0): arrayLe type de retour array<string, int> est invisible depuis la seule signature PHP (: array), d’où l’intérêt du @return : l’appelant sait qu’il reçoit [hexId => count] sans ouvrir le corps. Même logique pour les entrées complexes, documentées en @param array<int, int> $userIds là où le type seul ne suffit pas.