My App

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.ts qui 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)
packages/api/src/modules/cardex/cardex.routes.ts
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)
packages/api/src/modules/cardex/cardex.service.ts
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 db ou tx en 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
packages/api/src/modules/cardex/cardex.repository.ts
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
packages/api/src/modules/cardex/cardex.schemas.ts
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 :

apps/pwa/src/app/(guest)/auth/fast-check-in/page.tsx
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éroclites

Si 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és

Les 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.ts

Structure 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.css

Les 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 → SSE

Le worker séparé (apps/worker) consomme queues.pmsBridge et appelle services/integrations/bridge.tsadapters/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/

On this page