Architecture modulaire
Un dossier par feature métier — routes, service, repository, schemas, tests colocalisés
Un dossier = une feature métier. Pas de "utils" fourre-tout, pas de services géants, pas de logique éparpillée sur 5 fichiers.
Le principe
Chaque feature métier (cardex, rooms, booking, chat, payment, integrations...) vit dans un dossier dédié avec une structure standardisée. Les fichiers à l'intérieur sont tous nécessaires, pas un de plus, pas un de moins.
Cette convention est l'antidote à :
- Les
services/qui grossissent à 900 lignes - Les
utils.tsqui hébergent 40 fonctions sans lien - Les tests séparés du code dans un dossier
__tests__/loin du fichier testé - Les schémas Drizzle, Zod, TypeBox éparpillés dans des packages différents
Structure type d'un module (backend)
packages/api/src/modules/cardex/
├── cardex.routes.ts # Elysia routes (HTTP layer)
├── cardex.service.ts # Logique métier pure
├── cardex.repository.ts # Accès DB (Drizzle queries)
├── cardex.schemas.ts # TypeBox schemas (inputs/outputs Elysia)
├── cardex.service.test.ts # Tests unitaires du service
├── cardex.repository.test.ts # Tests intégration DB
└── cardex.routes.test.ts # Tests intégration Elysia (via app.handle)Jamais de dossier __tests__/ séparé. Les tests sont colocalisés.
Responsabilité de chaque fichier
*.routes.ts — Couche HTTP
- Déclare les endpoints Elysia
- Valide les inputs avec TypeBox
- Appelle le service, retourne la réponse
- Ne contient AUCUNE logique métier
- Peut gérer les erreurs HTTP (mapping exception → status)
import { Elysia } from "elysia";
import { staffAuth } from "../../plugins/auth";
import * as service from "./cardex.service";
import { confirmArrivalSchema } from "./cardex.schemas";
export const cardexModule = new Elysia({ prefix: "/cardex" })
.use(staffAuth)
.get("/guests", ({ auth }) =>
service.getAllGuests({ organizationId: auth.organizationId })
)
.post("/confirm-arrival", ({ body, auth }) =>
service.confirmGuestArrival({
organizationId: auth.organizationId,
guestId: body.guestId,
}),
{ body: confirmArrivalSchema }
);*.service.ts — Logique métier
- Orchestre le domaine
- Appelle le repository pour la DB
- Appelle les state machines pour valider les transitions
- Publie les événements Redis pour les SSE
- Enqueue les jobs BullMQ
- Ne touche JAMAIS directement à la DB
- Ne dépend PAS d'Elysia (importable depuis un worker)
import { db } from "@bell/db";
import { assertTransitionCheckIn } from "../../domain/check-in.machine";
import { queues } from "../../jobs/queues";
import { pubsub } from "../../plugins/pubsub";
import * as repo from "./cardex.repository";
export async function confirmGuestArrival(opts: {
organizationId: string;
guestId: string;
}) {
return await db.transaction(async (tx) => {
const current = await repo.getGuestById(tx, opts);
if (!current) throw new NotFoundError("guest");
assertTransitionCheckIn(current.checkInStatus, "arrived");
await repo.markArrived(tx, opts.guestId);
if (current.roomId) await repo.setRoomOccupied(tx, current.roomId);
await queues.pmsBridge.add("check-in", { orgId: opts.organizationId, guestId: opts.guestId });
await pubsub.publish(`guest:${opts.guestId}:status`, { status: "arrived" });
return { success: true, alreadyArrived: false };
});
}*.repository.ts — Accès DB
- Toutes les queries Drizzle vers les tables de ce domaine
- Prend
dboutxen paramètre (pas d'import direct, pour les transactions) - Retourne des types Drizzle bruts, pas des DTOs
- Ne contient AUCUNE logique métier
- Ne dépend pas d'Elysia ni du service
import { and, eq } from "drizzle-orm";
import type { DB } from "@bell/db";
import { guest, room } from "@bell/db/schema";
export async function getGuestById(tx: DB, opts: { organizationId: string; guestId: string }) {
const [row] = await tx
.select()
.from(guest)
.where(and(eq(guest.id, opts.guestId), eq(guest.organizationId, opts.organizationId)))
.limit(1);
return row ?? null;
}
export async function markArrived(tx: DB, guestId: string) {
await tx
.update(guest)
.set({ checkInStatus: "arrived", checkInArrivedAt: new Date() })
.where(eq(guest.id, guestId));
}*.schemas.ts — TypeBox schemas
- Types d'input et d'output des routes
- Partagés avec le client via Eden Treaty (types inférés)
- Pas d'import côté client — seulement les types
import { t } from "elysia";
export const confirmArrivalSchema = t.Object({
guestId: t.String({ format: "uuid" }),
});
export const guestSummarySchema = t.Object({
id: t.String(),
firstName: t.String(),
lastName: t.String(),
email: t.String({ format: "email" }),
checkInStatus: t.Union([
t.Literal("pending"),
t.Literal("invited"),
t.Literal("completed"),
t.Literal("arrived"),
t.Literal("checked_out"),
]),
roomNumber: t.Nullable(t.String()),
});*.test.ts — Tests colocalisés
Un fichier de test par couche testée (service, repository, routes). Voir conventions/testing.
Modules frontend
Côté apps/pwa et apps/dashboard, on utilise une structure feature-based similaire :
apps/pwa/src/features/check-in/
├── check-in-page.tsx # page container (orchestrateur)
├── use-check-in-status.ts # hook TanStack Query + SSE
├── use-complete-check-in.ts # hook mutation
├── check-in-form.tsx # composant formulaire
├── check-in-upsells-grid.tsx # composant grid produits
├── check-in-payment.tsx # composant paiement Stripe
├── check-in-state.ts # types partagés
└── check-in-form.test.tsx # test (si logique non triviale)Et dans apps/pwa/src/app/ on a juste les fichiers imposés par Next.js (page.tsx, layout.tsx, loading.tsx, error.tsx) qui importent les features :
import { CheckInPage } from "~/features/check-in/check-in-page";
export default CheckInPage;Principe : le /app de Next est un routeur, pas un lieu de code métier.
Ce qui ne va pas dans un module
Pas de "utils"
# ❌ À proscrire
packages/api/src/modules/cardex/
└── cardex-utils.ts # 250 lignes de fonctions hétéroclitesSi une fonction a du sens partout, elle va dans packages/api/src/lib/ avec un nom précis (format-currency.ts, slugify.ts). Si elle est spécifique à un module, elle va dans le fichier qui l'utilise, pas dans un "utils" fourre-tout.
Pas de "types.ts" partagé
# ❌ À proscrire dans un module
packages/api/src/modules/cardex/
└── types.ts # types mélangésLes types côté API sont dans *.schemas.ts (TypeBox). Les types internes privés sont dans le fichier qui les utilise.
Pas d'import entre modules
# ❌
packages/api/src/modules/booking/booking.service.ts
import { getGuestById } from "../cardex/cardex.repository"; // ❌Si deux modules ont besoin de la même logique, on l'extrait dans :
packages/api/src/domain/(state machines, règles métier pures)packages/api/src/services/(integrations, mail, AI, stripe — cross-cutting)packages/api/src/lib/(helpers techniques)
Structure globale packages/api/src/
packages/api/src/
├── index.ts # export de l'app Elysia composée
├── plugins/ # plugins Elysia (auth, cors, openapi, pubsub)
│ ├── auth.ts
│ ├── openapi.ts
│ └── pubsub.ts
├── modules/ # un dossier par feature métier
│ ├── cardex/
│ ├── rooms/
│ ├── booking/
│ ├── chat/
│ ├── ai/
│ ├── payment/
│ ├── integrations/
│ ├── tickets/
│ ├── today/
│ ├── menu/
│ ├── calendar/
│ ├── users/
│ └── analytics/
├── domain/ # state machines + règles métier pures
│ ├── check-in.machine.ts
│ ├── room-service.machine.ts
│ ├── ticket.machine.ts
│ └── ...
├── services/ # services cross-cutting
│ ├── integrations/ # hub PMS
│ │ ├── types.ts
│ │ ├── index.ts
│ │ ├── sync.ts
│ │ ├── bridge.ts
│ │ └── adapters/
│ │ ├── mews.ts
│ │ ├── opera.ts # à venir
│ │ └── cloudbeds.ts # à venir
│ ├── mail/
│ ├── ai/
│ ├── stripe/
│ └── email-validator.ts # Reacher wrapper
├── jobs/ # BullMQ job definitions
│ ├── queues.ts
│ ├── pms-bridge.job.ts
│ ├── mews-sync.job.ts
│ ├── email.job.ts
│ └── stripe-webhook.job.ts
├── cron/ # @elysiajs/cron
│ └── index.ts
└── lib/ # helpers techniques (pure, générique)
├── hash-email.ts
├── format-currency.ts
└── slugify.tsStructure globale apps/pwa/src/ et apps/dashboard/src/
apps/pwa/src/
├── app/ # Next.js App Router (fichiers imposés uniquement)
│ ├── layout.tsx
│ ├── middleware.ts
│ ├── (guest)/ # route group
│ │ ├── layout.tsx
│ │ ├── page.tsx # home
│ │ ├── room-service/page.tsx
│ │ ├── spa/page.tsx
│ │ └── chat/page.tsx
│ └── auth/
│ ├── layout.tsx
│ ├── waiting/page.tsx
│ ├── fast-check-in/page.tsx
│ └── check-in-payment/page.tsx
├── features/ # features métier
│ ├── check-in/
│ ├── room-service/
│ ├── chat/
│ ├── auth/
│ └── drawer/
├── components/ # composants UI spécifiques à l'app (non partagés)
├── lib/ # helpers, eden client, utils
│ ├── eden.ts
│ └── cn.ts
└── styles/
└── globals.cssLes composants partagés avec apps/dashboard vont dans @bell/ui. Les composants spécifiques à la PWA restent dans apps/pwa/src/components/.
Flux d'une request type
Prenons "staff confirme l'arrivée d'un guest" :
Dashboard UI
│
└─► eden.cardex["confirm-arrival"].post({ guestId })
│
└─► [HTTP] POST /cardex/confirm-arrival
│
└─► cardex.routes.ts
│
├─► validate body (TypeBox)
└─► cardex.service.confirmGuestArrival()
│
├─► db.transaction()
│ ├─► cardex.repository.getGuestById()
│ ├─► domain.check-in.machine.assertTransition()
│ ├─► cardex.repository.markArrived()
│ └─► cardex.repository.setRoomOccupied()
│
├─► queues.pmsBridge.add("check-in", ...) # BullMQ
└─► pubsub.publish("guest:...:status", ...) # Redis → SSELe worker séparé (apps/worker) consomme queues.pmsBridge et appelle services/integrations/bridge.ts → adapters/mews.ts → startReservation().
Bénéfices
- Nouveau dev orienté en 5 minutes : "c'est un module cardex → je sais où regarder"
- Tests colocalisés = navigation 0 effort
- Split naturel quand un module grossit : on extrait un sous-module, on garde l'interface
- Pas de couplage entre modules : si on vire
tickets, le reste continue - L'IA ne se perd pas : à chaque action, la zone de code est scopée
Lien avec les autres conventions
- Naming — kebab-case des fichiers, suffixes de rôle
- Seuils de fichiers — quand un fichier de module dépasse, on split
- Tests — tests colocalisés
- Factorisation — quand extraire vers
domain/,services/,lib/