# Comment nous construisons des applications web : les trois premières semaines, et l'année qui suit

Cette année, nous avons livré deux plateformes web très différentes avec la même méthode. La première est une plateforme de vitrine multi-tenant pour un groupe de distribution roumain : Laravel, une base de données par tenant, trois panneaux d'administration, une boutique publique par marque. La seconde est une plateforme de partenaires et de paiements pour un client américain : trois applications Next.js sur AWS App Runner, une API TypeScript, DynamoDB et PostgreSQL côte à côte, avec de l'argent qui y transite chaque mois.

Des stacks différentes, des pays différents, des budgets différents. Notre façon de les construire est identique, et c'est le sujet de cet article. Pas « nous sommes agiles », ni une liste d'outils, mais ce qui se passe réellement pendant les trois premières semaines et ce que le client peut attendre l'année suivante.

## Semaine 0 : la découverte se termine par un dépôt

La plupart des briefs que nous recevons tiennent en un paragraphe et une date limite. La découverte, ce sont une ou deux semaines facturées qui transforment ce paragraphe en trois éléments : un modèle de données, une liste d'environnements, et un premier jalon assez petit pour être réel.

Le modèle de données est la partie que l'on sous-estime le plus. Dans la plateforme de vitrine, la décision que chaque tenant aurait sa propre base de données a été prise pendant la découverte, sur un tableau blanc, après une seule question : un tenant devra-t-il un jour repartir avec ses données ? La réponse était oui, donc l'isolation devait être physique, pas une simple colonne `tenant_id`. Cette seule décision a façonné la configuration de l'ORM, les panneaux d'administration, la stratégie de sauvegarde et la page tarifaire.

Dans la plateforme de partenaires, la question équivalente était : quels schémas d'accès sont figés pour toujours ? Les recherches de commandes par partenaire et par mois ne changent jamais, elles sont donc allées dans DynamoDB avec une conception single-table. Tout ce qui relève du reporting, où les questions changent chaque trimestre, est allé dans PostgreSQL. Nous avons détaillé ce choix dans [DynamoDB single table ou Postgres : comment nous avons choisi](/fr/blog/dynamodb-single-table-or-postgres-how-we-chose).

La découverte se termine quand il existe un dépôt avec un README, une stack CDK vide (ou un squelette Laravel), et un jalon que le client peut décrire en une phrase. Si elle se termine par une présentation, c'était un appel commercial.

<div class="article-figure">
<svg viewBox="0 0 900 260" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Chronologie des trois premières semaines : découverte, squelette en production, première fonctionnalité, incréments hebdomadaires">
<g font-family="Inter,system-ui,sans-serif">
<text x="40" y="34" fill="#f1f3ff" font-size="15" font-weight="700">Les trois premières semaines</text>
<line x1="40" y1="130" x2="860" y2="130" stroke="#2a3150" stroke-width="3"/>
<g>
<rect x="40" y="70" width="180" height="44" rx="8" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/>
<text x="130" y="97" text-anchor="middle" fill="#f1f3ff" font-size="13" font-weight="600">Semaine 0 · Découverte</text>
<text x="130" y="160" text-anchor="middle" fill="#9aa3c7" font-size="12">modèle de données · env.</text>
<text x="130" y="178" text-anchor="middle" fill="#9aa3c7" font-size="12">1er jalon · dépôt</text>
</g>
<g>
<rect x="250" y="70" width="180" height="44" rx="8" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/>
<text x="340" y="97" text-anchor="middle" fill="#f1f3ff" font-size="13" font-weight="600">Semaine 1 · Socle en prod</text>
<text x="340" y="160" text-anchor="middle" fill="#9aa3c7" font-size="12">auth · CI/CD · dev/staging/prod</text>
<text x="340" y="178" text-anchor="middle" fill="#9aa3c7" font-size="12">preview par pull request</text>
</g>
<g>
<rect x="460" y="70" width="180" height="44" rx="8" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/>
<text x="550" y="97" text-anchor="middle" fill="#f1f3ff" font-size="13" font-weight="600">Semaine 2-3 · 1re feature</text>
<text x="550" y="160" text-anchor="middle" fill="#9aa3c7" font-size="12">modèle de domaine central</text>
<text x="550" y="178" text-anchor="middle" fill="#9aa3c7" font-size="12">vrais utilisateurs, staging</text>
</g>
<g>
<rect x="670" y="70" width="190" height="44" rx="8" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/>
<text x="765" y="97" text-anchor="middle" fill="#f1f3ff" font-size="13" font-weight="600">Ensuite · Incréments hebdo</text>
<text x="765" y="160" text-anchor="middle" fill="#9aa3c7" font-size="12">démo chaque vendredi</text>
<text x="765" y="178" text-anchor="middle" fill="#9aa3c7" font-size="12">rapport d'heures · reprioriser</text>
</g>
<circle cx="130" cy="130" r="6" fill="#7b8cff"/>
<circle cx="340" cy="130" r="6" fill="#4fffb0"/>
<circle cx="550" cy="130" r="6" fill="#ffd166"/>
<circle cx="765" cy="130" r="6" fill="#ff6b8a"/>
<text x="450" y="230" text-anchor="middle" fill="#9aa3c7" font-size="12">Rien sur cette ligne n'est un document. Chaque case est quelque chose qui tourne.</text>
</g>
</svg>
</div>

## Semaine 1 : le squelette est en production avant la première fonctionnalité

La première chose que nous construisons n'est pas une fonctionnalité. C'est le cadre, sans éclat, auquel chaque fonctionnalité va s'accrocher : authentification, rôles, les trois environnements, un pipeline CI qui déploie à chaque merge, et un environnement de preview par pull request. À la fin de la première semaine, il existe une URL sur laquelle le client peut se connecter. Elle ne fait encore rien d'utile, et c'est précisément le but : chaque rouage qui pourrait retarder un lancement a déjà été testé.

Pour la plateforme de partenaires, cela signifiait une base de code CDK avec trois comptes AWS et GitHub Actions qui déploie via des rôles OIDC, sans aucune clé longue durée nulle part. Nous avons décrit cette configuration dans [trois comptes AWS, une seule base CDK](/fr/blog/aws-three-accounts-one-cdk-codebase). Pour la plateforme de vitrine, cela signifiait un squelette Laravel avec le package de tenancy branché, un panneau super-admin, un panneau d'espace de travail par tenant, et un script de déploiement qui crée une base de données de tenant en une seule commande.

La raison pour laquelle nous insistons sur cet ordre est simple : c'est dans le squelette que se cachent les surprises. Configuration au build versus au runtime dans Next.js, domaines de cookies entre sous-domaines, un health check qui se déclare en bonne santé alors que la base de données est injoignable. Nous préférons rencontrer ces problèmes en semaine un, sur une page qui dit « bonjour », plutôt que la semaine du lancement.

## Choisir la stack : TypeScript par défaut, Laravel quand il le mérite

Nous sommes une équipe TypeScript avant tout. Une API Node.js, un frontend React ou Next.js et PostgreSQL partagent un seul langage, un seul linter, un seul test runner et un seul relecteur. Pour une équipe de deux ou trois personnes, c'est un vrai avantage de vitesse, et des contrats typés entre le frontend et le backend éliminent toute une classe de bugs avant même qu'ils existent.

Nous utilisons Laravel quand le projet s'y prête mieux, et la plateforme de vitrine s'y prêtait. Elle avait besoin de trois panneaux d'administration avec des dizaines d'écrans CRUD, des bases de données par tenant, des queues, des tâches planifiées et une couche de contenu façon CMS. Laravel avec Filament nous a donné les panneaux d'administration en jours plutôt qu'en semaines, et le package de tenancy a géré le changement de base de données, les migrations par tenant et l'isolation des assets sans que nous ayons à écrire un framework. Choisir Next.js ici aurait signifié construire un framework d'administration avant de construire le produit.

La règle que nous appliquons : choisir la stack qui rend la deuxième année bon marché, pas celle qui rend la première démo rapide. Puis écrire la raison, pour que personne ne la remette en cause au sixième mois.

## Les données : sobres par défaut, exotiques par choix

PostgreSQL est le choix par défaut. C'est la base de données que comprendra encore quiconque hérite du système, et elle gère les questions de reporting que personne ne pouvait prévoir pendant la découverte. Nous ne recourons à DynamoDB que lorsque les schémas d'accès sont figés, que l'échelle le justifie, et qu'une personne de l'équipe est prête à porter la conception single-table.

Deux leçons de cette année qui valent la peine d'être partagées. D'abord, quand de l'argent est en jeu, verrouillez chaque taux au moment de la transaction. La plateforme de partenaires convertit des commandes dans plusieurs devises ; une première version appliquait le taux de change du jour au moment du paiement au lieu du taux à la date de la commande, et un partenaire a remarqué un écart sur son relevé. Nous avons corrigé le calcul, recalculé l'historique, et le taux est désormais stocké sur la ligne de commande et n'est plus jamais recalculé. Ensuite, quand vous utilisez une base de données via une API de données HTTP, attendez-vous à ce que les colonnes JSON arrivent sous forme de chaînes. Un helper de cinq lignes nous a évité une dizaine d'appels `JSON.parse` éparpillés et les bugs qui vont avec.

## Semaines 2 à 12 : des incréments que l'on peut voir

Dès la semaine deux, le rythme est fixé. Chaque pull request obtient un environnement de preview, si bien que la revue se fait sur une application qui tourne plutôt que sur un diff. Chaque vendredi, il y a une démo sur staging de ce qui a changé dans la semaine. Chaque lundi, le client reçoit un rapport des heures passées, par tâche, avec le plan de la semaine suivante. Le backlog est à lui, il peut le réordonner à tout moment.

C'est là que le modèle horaire, en régie, prouve sa valeur. Il n'y a pas de périmètre figé à défendre, donc lorsque le client de la plateforme de partenaires a demandé en cours de projet un flux de données que ses analystes pourraient exploiter dans leur outil de BI, nous n'avons pas ouvert de demande de changement. Nous l'avons estimé, ils l'ont priorisé, et c'était livré deux semaines plus tard.

<div class="article-figure">
<svg viewBox="0 0 900 300" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Parcours d'une requête dans une application type que nous construisons : navigateur, CDN et WAF, conteneur App Runner ou ECS, API, PostgreSQL et DynamoDB, queue et workers">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="40" y="34" fill="#f1f3ff" font-size="15" font-weight="700">Ce que traverse une requête</text>
<rect x="40" y="110" width="120" height="50" rx="8" fill="#151b2e" stroke="#9aa3c7"/>
<text x="100" y="140" text-anchor="middle" fill="#f1f3ff">Navigateur</text>
<rect x="200" y="110" width="140" height="50" rx="8" fill="#151b2e" stroke="#7b8cff"/>
<text x="270" y="132" text-anchor="middle" fill="#f1f3ff">CloudFront + WAF</text>
<text x="270" y="150" text-anchor="middle" fill="#9aa3c7">règles managées</text>
<rect x="380" y="110" width="150" height="50" rx="8" fill="#151b2e" stroke="#4fffb0"/>
<text x="455" y="132" text-anchor="middle" fill="#f1f3ff">Conteneur</text>
<text x="455" y="150" text-anchor="middle" fill="#9aa3c7">App Runner ou ECS</text>
<rect x="570" y="110" width="130" height="50" rx="8" fill="#151b2e" stroke="#ffd166"/>
<text x="635" y="132" text-anchor="middle" fill="#f1f3ff">API typée</text>
<text x="635" y="150" text-anchor="middle" fill="#9aa3c7">Node.js ou Laravel</text>
<rect x="740" y="60" width="120" height="44" rx="8" fill="#151b2e" stroke="#ff6b8a"/>
<text x="800" y="87" text-anchor="middle" fill="#f1f3ff">PostgreSQL</text>
<rect x="740" y="118" width="120" height="44" rx="8" fill="#151b2e" stroke="#ff6b8a"/>
<text x="800" y="145" text-anchor="middle" fill="#f1f3ff">DynamoDB</text>
<rect x="740" y="176" width="120" height="44" rx="8" fill="#151b2e" stroke="#ff6b8a"/>
<text x="800" y="203" text-anchor="middle" fill="#f1f3ff">Queue + workers</text>
<g stroke="#2a3150" stroke-width="2" fill="none">
<line x1="160" y1="135" x2="200" y2="135"/><line x1="340" y1="135" x2="380" y2="135"/><line x1="530" y1="135" x2="570" y2="135"/>
<line x1="700" y1="135" x2="740" y2="82"/><line x1="700" y1="135" x2="740" y2="140"/><line x1="700" y1="135" x2="740" y2="198"/>
</g>
<text x="455" y="200" text-anchor="middle" fill="#9aa3c7">exports longs streamés ligne par ligne : le proxy en amont ferme les réponses inactives après 120 s</text>
<text x="455" y="250" text-anchor="middle" fill="#9aa3c7">chaque case est définie en CDK ; dev, staging et prod sont le même code avec des paramètres différents</text>
</g>
</svg>
</div>

## Ce qui mord, et comment nous nous en protégeons

**Les requêtes longues.** La plateforme de partenaires exporte des relevés en CSV et XLSX. La première version mettait tout le fichier en buffer, et le proxy devant App Runner fermait la connexion au bout de 120 secondes sur les gros mois. Désormais le CSV est streamé ligne par ligne pour que la connexion ne reste jamais inactive, et le XLSX est généré dans un worker puis livré par lien. Nous avons couvert les autres bizarreries de la plateforme dans [App Runner après six mois](/fr/blog/aws-app-runner-review-six-months).

**Configuration au build vs au runtime.** Next.js inline les variables publiques au moment du build. Un déploiement qui ne changeait qu'une variable d'environnement ne faisait rien, et il nous a fallu un après-midi pour comprendre pourquoi. Le build prend désormais sa configuration explicitement par environnement, et le script de déploiement refuse de réutiliser un tag d'image pour une configuration différente. Détails dans [Next.js : config au build vs au runtime sur des conteneurs](/fr/blog/nextjs-build-time-vs-runtime-config-on-containers).

**Les monorepos qui reconstruisent tout.** Trois applications et un package partagé dans un seul dépôt signifiaient que chaque commit reconstruisait trois images. Nous avons changé le pipeline pour ne construire que ce qui avait changé, ce qui a réduit le temps de CI de plus de moitié. Voir [monorepo : trois apps, ne construire que ce qui a changé](/fr/blog/monorepo-three-apps-build-only-what-changed).

**Le développement assisté par IA.** Nous utilisons Claude Code quotidiennement, et il écrit une part réelle du code de routine, des tests et de l'infrastructure. Il ne décide ni du modèle de données, ni des limites de sécurité, ni de ce qui est déployé ; un ingénieur senior relit chaque ligne. Nous avons été précis sur cette répartition dans [ce que Claude Code a écrit de notre infrastructure, et ce que nous avons refusé](/fr/blog/what-claude-code-wrote-of-our-infrastructure-and-what-we-refused).

## L'année qui suit

Une application web est un système qui tourne, pas une livraison. Quand la phase de construction se termine, deux choses se passent. Nous transmettons une documentation dont un nouvel ingénieur peut partir : notes d'architecture, décisions documentées, le runbook, et la procédure de déploiement. Et le client choisit entre une passation propre à sa propre équipe, ou nous garder pour quelques heures par mois pour opérer et faire évoluer le système.

Les deux plateformes de cette année ont choisi la seconde option. La plateforme de vitrine reçoit de nouveaux modules chaque mois, plus récemment un module de taxes produit et des thèmes d'administration dynamiques. La plateforme de partenaires reçoit un flux hebdomadaire de petites corrections et fonctionnalités, triées depuis le ticket board du client, chacune vérifiée en production avant d'être clôturée. Les ingénieurs sont les mêmes personnes qui ont fait la découverte en semaine zéro, et c'est bien tout l'intérêt.

Si vous avez un produit à construire et que vous voulez que ce soit fait de cette façon, [parlez-nous](/contact). La première conversation est gratuite ; c'est la découverte qui déclenche le compteur.
