My App

ADR-07 — Eden Treaty + TypeBox

On exploite Elysia à fond, pas de tRPC

Statut : Accepté Date : 2026-04 Sujet : Choix de la couche API entre serveur Elysia et clients Next.js

Contexte

L'ancien projet Bell utilisait tRPC v11 par-dessus Elysia. Ça marchait mais ajoutait une couche d'abstraction gratuite :

  • Elysia expose ses routes typées nativement
  • tRPC ré-encapsule ces routes dans son propre protocole
  • Deux systèmes de validation coexistent (Zod côté tRPC, TypeBox côté Elysia natif)
  • Swagger/OpenAPI impossible à exposer proprement (tRPC n'a pas de concept REST)

Le nouveau projet est Better-T-Stack avec Elysia côté serveur. On peut soit garder tRPC, soit utiliser Eden Treaty (le client type-safe natif d'Elysia) qui inféré les types directement depuis l'app Elysia.

Alternatives considérées

Option 1 — Garder tRPC v11 par-dessus Elysia

Pour :

  • Écosystème React Query bindings bien rodé
  • TanStack Query optimistic updates faciles
  • Community large

Contre :

  • Double couche d'abstraction : Elysia + tRPC
  • Swagger/OpenAPI impossible — or on en a besoin pour exposer l'API aux futurs intégrateurs (Mews, partenaires, outils internes staff)
  • Validation double (TypeBox côté Elysia routes, Zod côté tRPC inputs)
  • Subscriptions tRPC v11 fonctionnent via SSE mais nécessitent config supplémentaire
  • Overhead bundle client (@trpc/client + @trpc/react-query ~ 40 KB)

Option 2 — Eden Treaty (retenu)

Le client natif d'Elysia. Le type de l'app Elysia est exporté, le client infère toutes les routes et leurs types TypeBox.

Pour :

  • Aucune double couche — on appelle directement les routes Elysia
  • Types end-to-end sans ceremonie : eden.cardex.guests.get() renvoie le type exact inféré de la route
  • OpenAPI gratos via @elysiajs/openapi — Swagger UI automatique pour debug, partenaires, intégration Mews
  • TypeBox natif — plus rapide que Zod au runtime (3-5×) sur les payloads lourds type sync Mews
  • @elysiajs/ai-sdk intégré → streaming AI sans plomberie
  • Pattern de test Elysia via app.handle(new Request(...)) — pas de mock tRPC compliqué
  • Bundle client plus petit (@elysiajs/eden ~ 8 KB)

Contre :

  • Moins de tutoriels Internet que tRPC
  • TanStack Query s'intègre mais on doit l'orchestrer nous-mêmes (pas de createTRPCReact magique)
  • Moins mature que tRPC v11

Option 3 — REST classique + Zod + TanStack Query

Plus de magie, écriture manuelle de chaque client.

Contre : beaucoup plus de boilerplate. Rejeté d'emblée.

Décision

Eden Treaty côté client + TypeBox côté serveur pour la validation des payloads API. Zod reste pour la validation env vars (@bell/env) et la validation formulaires front (react-hook-form + zodResolver).

Structure packages/api

packages/api/src/
├── index.ts                      ← export de l'app Elysia composée
├── plugins/                      ← cors, auth, openapi, cron
├── modules/
│   ├── cardex/
│   │   ├── cardex.routes.ts      ← Elysia routes
│   │   ├── cardex.service.ts     ← logique métier
│   │   ├── cardex.repository.ts  ← accès DB
│   │   └── cardex.schemas.ts     ← TypeBox schemas
│   ├── rooms/
│   ├── booking/
│   └── ...
├── domain/                       ← state machines (cf. ADR-05)
├── services/                     ← integrations/, mail/, ai/, stripe/
└── jobs/                         ← definitions BullMQ workers (cf. ADR-04)

Exemple d'un module complet

// 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()),
});
// 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, guestSummarySchema } from "./cardex.schemas";

export const cardexModule = new Elysia({ prefix: "/cardex" })
  .use(staffAuth)
  .get("/guests", ({ auth }) => service.getAllGuests(auth.organizationId), {
    response: t.Array(guestSummarySchema),
    detail: { summary: "List all guests of the organization" },
  })
  .post("/confirm-arrival", ({ body, auth }) =>
    service.confirmGuestArrival(auth.organizationId, body.guestId), {
    body: confirmArrivalSchema,
    response: t.Object({ success: t.Boolean(), alreadyArrived: t.Boolean() }),
    detail: { summary: "Mark guest as physically arrived, trigger PMS bridge" },
  });

Composition de l'app globale

// packages/api/src/index.ts
import { Elysia } from "elysia";
import { openapi } from "@elysiajs/openapi";
import { cardexModule } from "./modules/cardex/cardex.routes";
import { roomsModule } from "./modules/rooms/rooms.routes";
// ...

export const app = new Elysia()
  .use(openapi({ path: "/openapi", documentation: { info: { title: "Bell API", version: "1.0.0" } } }))
  .use(cardexModule)
  .use(roomsModule);
  // ...

export type App = typeof app;

Client Eden Treaty

// apps/pwa/src/lib/eden.ts
import { treaty } from "@elysiajs/eden";
import type { App } from "@bell/api";

export const eden = treaty<App>(
  typeof window === "undefined" ? process.env.SERVER_URL! : "/api/eden",
  { fetch: { credentials: "include" } },
);

Usage côté composant

// apps/dashboard/src/features/cardex/use-cardex.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { eden } from "~/lib/eden";

export function useGuests() {
  return useQuery({
    queryKey: ["cardex", "guests"],
    queryFn: async () => {
      const { data, error } = await eden.cardex.guests.get();
      if (error) throw error;
      return data;
    },
  });
}

export function useConfirmArrival() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async (guestId: string) => {
      const { data, error } = await eden.cardex["confirm-arrival"].post({ guestId });
      if (error) throw error;
      return data;
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ["cardex", "guests"] }),
  });
}

Tout est typé end-to-end sans générer de types, sans builder un client.

OpenAPI / Swagger

@elysiajs/openapi génère automatiquement une spec OpenAPI 3.1 accessible sur http://bell-api.hoaiy.com/openapi. On l'expose :

  • En dev : ouvert, pratique pour debug
  • En prod : protégé par auth staff admin + feature flag OPENAPI_ENABLED

Cas d'usage :

  • Debug des payloads exacts lors d'incidents
  • Documentation pour les futurs partenaires (API key system à venir)
  • Documentation pour les équipes staff qui scriptent des extractions data
  • Génération d'un SDK tiers si un hôtel veut consommer notre API

AI SDK via @elysiajs/ai-sdk

Pour le concierge AI, on utilise le plugin officiel qui wrap Vercel AI SDK :

// packages/api/src/modules/ai/ai.routes.ts
import { ai } from "@elysiajs/ai-sdk";
import { openai } from "@ai-sdk/openai";

export const aiModule = new Elysia({ prefix: "/ai" })
  .use(ai(openai("gpt-4-turbo")))
  .post("/chat", async ({ ai, body }) => {
    return ai.streamText({
      messages: body.messages,
      tools: bellConciergeTools,
    });
  }, { body: chatRequestSchema });

Le streaming fonctionne nativement côté Eden Treaty via async iterables.

Ce qui reste en Zod

  • packages/env — validation des env vars au boot (déjà en place dans le template BTS)
  • Validation formulaires côté clientreact-hook-form + zodResolver, standard communauté, bien outillé
  • Règles business complexes côté service — parfois plus lisible en Zod

Pas de duplication stressante : les schemas TypeBox sont dans packages/api/src/modules/*/schemas.ts (côté routes), les Zod sont dans les composants front (côté formulaires). Zéro overlap.

Conséquences

Positives :

  • Stack cohérente : Elysia de bout en bout
  • OpenAPI gratos = démo, debug, partenaires, future API key system facilité
  • Validation runtime 3–5× plus rapide sur les payloads lourds (sync Mews)
  • Bundle client plus léger
  • Pattern de test simple via app.handle()
  • Streaming AI nativement supporté

Négatives :

  • Communauté Eden Treaty plus petite que tRPC — on investit sur un bet
  • Écrire eden.cardex["confirm-arrival"].post() est légèrement plus verbeux que trpc.cardex.confirmArrival.useMutation()
  • TanStack Query intégration manuelle (mais triviale avec un hook helper)

Métriques à surveiller

  • Temps de compilation TypeScript avec l'app complète (cible : < 10 s en incremental)
  • Taille des payloads validés par TypeBox vs Zod sur la sync Mews (gain attendu : 60–80 %)
  • Taux d'utilisation de /openapi (log les hits, signale si l'outil est utile)
  • Bugs de drift de types client/serveur (cible : 0, impossible en théorie avec Eden)

On this page