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-sdkinté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
createTRPCReactmagique) - 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é client —
react-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 quetrpc.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)