Exemples complets
{% raw %} Trois formes, chacune déroulée du modèle jusqu'à une requête qui répond : une API REST, un monolithe qui lui ajoute des comptes et une interface, et un système de services derrière une passerelle.
Chaque commande de cette page a été exécutée, et chaque réponse est recopiée de sa sortie. Là où quelque chose ne marche pas comme on le lit, la page le dit — c'est en général la partie qui vaut d'être connue.
Les trois utilisent SQLite : aucune n'exige d'installer quoi que ce soit.
Remplacez database par postgres dans le modèle et le fichier Compose engendré
pose un serveur à côté de l'application sans un mot de plus.
1. Une API REST🔗
La plus petite forme utile : un domaine, une base, et HTTP par-dessus.
Le modèle🔗
// api.cdl
service catalog_api {
database sqlite
port 8420
}
enum Availability { IN_STOCK, BACKORDER, DISCONTINUED }
@filterable
@audited
record Product {
reference: Text @unique @length(2..40)
label: Text @length(2..200)
price: Decimal
stock: Availability
summary: LongText?
}
@filterable
record Review {
rating: Int @range(1..5)
comment: LongText?
product: ref Product
}
Engendrer🔗
crabster import-cdl api.cdl --path catalog
cd catalog
cp .env.example .env
cargo run
38 fichiers. Ceux qu'il faut connaître :
src/domain/ les entités SeaORM
src/dto/ ce que l'API accepte et renvoie, qui n'est pas l'entité
src/api/ un fichier de handlers par enregistrement — à vous de les éditer
src/migration/ une migration par enregistrement, appliquée au démarrage
src/error.rs les problèmes au format RFC 9457
config/ default.toml, dev.toml, prod.toml
Appeler🔗
curl -X POST localhost:8420/api/products -H 'content-type: application/json' \
-d '{"reference":"CR-001","label":"Clavier mécanique",
"price":"129.90","stock":"IN_STOCK","summary":null}'
{"id":1,"reference":"CR-001","label":"Clavier mécanique","price":"129.9",
"stock":"IN_STOCK","summary":null,
"createdAt":"2026-10-06T21:10:24.315718Z","updatedAt":"2026-10-06T21:10:24.315741Z"}
createdAt et updatedAt sont là parce que l'enregistrement porte @audited.
Rien ne les a demandés dans le corps.
Une liste est toujours une page :
curl 'localhost:8420/api/products?reference=CR-001'
{"items":[…],"page":0,"size":20,"totalItems":1,"totalPages":1}
?reference= est un filtre parce que Product porte @filterable. Sans cet
attribut, l'endpoint existe et le paramètre est refusé — un filtre jeté en
silence est pire qu'un filtre qui n'existe pas.
Ce que les attributs font à une requête🔗
Chacun de ceux-ci, c'est le modèle appliqué :
curl -X POST localhost:8420/api/reviews -H 'content-type: application/json' \
-d '{"rating":9,"comment":null,"productId":1}'
{"type":"/problems/validation","title":"The submitted data is invalid","status":422,
"detail":"the submitted data is invalid",
"errors":{"rating":[{"code":"range","message":null,"params":{"max":5,"min":1,"value":9}}]}}
L'API se décrit elle-même sur /swagger-ui et
/api-docs/openapi.json, engendrés du même modèle.
2. Un monolithe : la même API, avec comptes et interface🔗
Une application, un déploiement : l'API ci-dessus, plus qui a le droit de l'appeler et de quoi l'appeler.
Le modèle🔗
Trois réglages de plus, sur les mêmes enregistrements :
// monolith.cdl
service shop {
database sqlite
port 8430
auth jwt
ui react
client typescript
}
// … la même énumération et les deux mêmes enregistrements
crabster import-cdl monolith.cdl --path shop
63 fichiers au lieu de 38. Ce que les trois réglages ont ajouté :
src/auth/ les comptes, le hachage du mot de passe, /auth/register,
/auth/login, et l'extracteur qui rend une route protégée
clients/typescript/ un client typé engendré depuis l'API
ui/ un front React : une page par enregistrement, plus la connexion
ui react tire le client TypeScript que vous l'ayez demandé ou non — le front
est écrit contre lui. vue et angular donnent le même front dans ces deux
cadres.
Les comptes🔗
cp .env.example .env
cargo run
curl -X POST localhost:8430/auth/register -H 'content-type: application/json' \
-d '{"email":"ada@example.com","password":"correct-horse-battery-staple"}'
{"id":1,"email":"ada@example.com","roles":["USER"]}
curl -X POST localhost:8430/auth/login -H 'content-type: application/json' \
-d '{"email":"ada@example.com","password":"correct-horse-battery-staple"}'
Un corps JSON avec un accessToken — un JWT signé. Un mauvais mot de passe
répond 401 /problems/wrong-credentials, avec le même texte pour une adresse
inconnue que pour un mot de passe faux : les distinguer apprendrait à un
attaquant quelles adresses ont un compte.
auth jwt ne protège rien tout seul🔗
C'est la partie qui surprend, alors essayez :
curl localhost:8430/api/products # → 200, sans le moindre jeton
Le module vous donne les comptes, les jetons et un extracteur. Une route est protégée quand son handler réclame une session, et nulle part ailleurs — il n'y a pas de seconde liste de chemins protégés qui pourrait contredire le code.
Ouvrez donc src/api/product.rs et ajoutez un argument :
pub(crate) async fn list(
_session: crate::auth::Authenticated,
State(state): State<AppState>,
// … le reste inchangé
Recompilez, et la même requête répond :
{"type":"/problems/not-authenticated","title":"This request carries no usable session",
"status":401,"detail":"no `Authorization: Bearer` header"}
Avec le jeton, elle rend la page. Prenez Authenticated(claims) plutôt que
_session quand le handler a besoin de savoir qui appelle —
claims.require_role("ADMIN") est un contrôle de rôle d'une ligne.
Les fichiers de src/api/ sont à vous. Crabster les empreinte et vous signale
qu'ils ont changé plutôt que de les écraser.
L'interface🔗
cd ui
npm install
npm run dev
Son serveur de développement relaie /api et /auth vers 127.0.0.1:8430 — le
port du modèle, inscrit dans vite.config.ts à la génération. Connectez-vous
sur la page qui s'ouvre : les pages d'enregistrements listent, créent et
suppriment à travers le client typé.
3. Un système de services derrière une passerelle🔗
Plusieurs applications, déployées séparément, avec une seule adresse devant.
Le modèle🔗
Un seul fichier, toujours. Chaque bloc service devient un projet à part, et
@service(nom) dit qui possède chaque enregistrement.
// system.cdl
service edge {
kind gateway // ne persiste rien : il relaie
discovery consul
port 8100
}
service identity {
database sqlite
auth jwt // ce service possède les comptes
discovery consul
port 8101
}
service orders {
database sqlite
auth jwt // il vérifie les jetons ; il n'en émet pas
discovery consul
port 8102
}
service billing {
database sqlite
discovery consul
port 8103
}
enum OrderStatus { PENDING, PAID, SHIPPED, CANCELLED }
@service(identity)
record Profile {
displayName: Text @length(2..120)
locale: Text?
}
@service(orders)
@versioned @audited @filterable
record Order {
placedAt: Timestamp
status: OrderStatus
total: Decimal
customer: Text @length(2..120)
}
@service(orders)
record OrderLine {
quantity: Int @range(1..)
unitPrice: Decimal
order: ref Order // à l'intérieur d'un service : une clé étrangère ordinaire
}
@service(billing)
@audited @filterable
record Invoice {
issuedAt: Timestamp
total: Decimal
paid: Bool
orderId: Long // pas `ref Order` — voir plus bas
}
Une référence ne traverse pas une frontière de service🔗
Écrivez order: ref Order sur Invoice et la génération s'arrête :
Error: line 60, column 5: `Invoice.order` points at `Order`, which belongs to
`orders` while the record holding it belongs to `billing`. A reference becomes a
foreign key inside a `CREATE TABLE`, and two services are two databases — there
is no key that reaches across and no migration order that spans both. Hold the
identifier instead, as a plain field, and let the two services agree on what it
means
C'est la règle qui sépare un système d'une application découpée en morceaux. Ce qui remplace la clé étrangère, c'est un accord entre deux équipes sur ce que signifie un identifiant de commande — une décision sur un système distribué, donc la vôtre plutôt qu'une déduction du générateur.
Engendrer🔗
crabster import-cdl system.cdl --path shop-system
shop-system/
├── docker-compose.yml consul, edge, identity, orders, billing
├── README.md
├── edge/ 23 fichiers — pas de modèle, pas de base
├── identity/ 43 fichiers
├── orders/ 48 fichiers
└── billing/ 34 fichiers
Chaque service est un projet Crabster ordinaire : crabster record, apply et
upgrade fonctionnent à l'intérieur de l'un d'eux, et un service engendré ici
est octet pour octet le même projet que ce service engendré seul.
Démarrer🔗
cd shop-system
docker compose up --build -d --wait
Les adresses engendrées sont les noms de service Compose — http://orders:8102 —
donc c'est le chemin qui marche sans rien éditer.
Hors Docker, ces noms ne résolvent rien et la passerelle répond
502 /problems/upstream-unreachable en nommant le service qu'elle n'a pas
joint. Redonnez la liste dans edge/config/dev.toml :
[[gateway.upstreams]]
name = "identity"
address = "http://127.0.0.1:8101"
paths = ["profiles"]
[[gateway.upstreams]]
name = "orders"
address = "http://127.0.0.1:8102"
paths = ["orders", "order-lines"]
[[gateway.upstreams]]
name = "billing"
address = "http://127.0.0.1:8103"
paths = ["invoices"]
Un profil remplace la liste, il ne la complète pas. Et il faut que ce soit un profil : une variable d'environnement ne sait pas atteindre une liste, et la tentative est refusée au démarrage plutôt qu'ignorée —
Error: failed to load configuration
Caused by:
invalid type: map, expected a sequence for key `gateway.upstreams`
Renseignez plutôt consul_address et aucune de ces adresses n'a besoin d'être
juste : quels chemins un service possède est la réponse du modèle et ne change
pas, tandis que l'endroit où ce service se trouve change à chaque déploiement.
Se connecter, dans un système🔗
La passerelle relaie /api/ et rien d'autre. /auth/register et
/auth/login ne sont pas joignables à travers elle — ils y répondent 404. Un
client se connecte directement contre le service qui possède les comptes :
curl -X POST localhost:8101/auth/login -H 'content-type: application/json' \
-d '{"email":"ada@example.com","password":"correct-horse-battery-staple"}'
puis présente ce jeton à travers la passerelle, qui transmet authorization
sans y toucher :
curl localhost:8100/api/orders # 401
curl localhost:8100/api/orders -H "authorization: Bearer $TOKEN" # 200
Un jeton émis par identity est accepté par orders parce que l'extracteur
vérifie une signature et ne lit pas la base. Deux conditions en découlent, et
les deux sont à votre charge :
- Tout service qui vérifie des jetons a besoin de la même
APP__AUTH__SECRET. Le.env.exampleengendré porte partout le même marqueur de développement, donc cela marche d'emblée et ne survivra pas à un vrai déploiement sans que vous posiez la même clé engendrée sur chacun. auth jwtsur un second service y engendre aussi une table de comptes à lui. Elle reste inutilisée tant que ce service ne fait que vérifier. Ne mettezauth jwtque sur les services qui doivent vérifier, et retenez que s'inscrire suridentityne crée rien surorders.
Créer à travers la passerelle, avec ce jeton :
{"id":1,"placedAt":"2026-03-01T09:00:00Z","status":"PENDING","total":"49.9",
"customer":"Ada Lovelace","version":0,
"createdAt":"2026-10-06T23:14:56.875080Z","updatedAt":"2026-10-06T23:14:56.875081Z"}
version, c'est @versioned : deux clients qui modifient la même commande ne
peuvent plus s'écraser en silence.
Ce que la passerelle répond pour elle-même🔗
Le routage se fait par le premier segment sous /api/, et par rien d'autre.
Un chemin que personne ne revendique est un 404 de la passerelle plutôt que le
404 de quelqu'un d'autre plus loin :
{"type":"/problems/no-such-service","title":"Nothing here answers for that path",
"status":404,"detail":"no service claims `/api/widgets`"}
Elle sert aussi /health, /health/live, /health/ready, /metrics, et un
contrat OpenAPI fusionné couvrant tous les services derrière elle. La limitation
de débit est active par défaut — vingt requêtes par seconde, quarante d'un coup
— parce qu'une passerelle sans limite relaie un déluge aussi fidèlement qu'elle
relaie le reste.
Partir des options plutôt que d'un fichier🔗
Le même système, sans écrire le modèle d'abord :
crabster new shop-system --architecture microservices \
--gateway edge:8100 \
--service identity:sqlite:8101 \
--service orders:sqlite:8102 \
--service billing:sqlite:8103 \
--discovery consul
La commande écrit le .cdl et engendre depuis lui : les deux chemins mènent au
même endroit. Une différence compte : un système créé ainsi n'a aucun
enregistrement. Ajoutez-les avec crabster record dans un service, puis
lancez crabster upgrade à la racine — il réassemble le modèle et reconstruit
la table de routage de la passerelle à partir de lui. Sans cette étape la
passerelle ne route rien, puisque ce qu'elle route est ce que le modèle dit
qu'un service possède, et que personne ne possédait rien à la génération.
4. Des événements entre services🔗
Le système ci-dessus ne parle que dans un sens : un appelant atteint la
passerelle, la passerelle atteint un service. Rien ne permet à orders de dire
à billing qu'une commande a été payée — et Invoice qui porte orderId comme
champ ordinaire est précisément la promesse que quelqu'un le fera.
messaging nats est ce canal.
service orders {
database sqlite
auth jwt
discovery consul
messaging nats // ajoutez ceci — ou `kafka`
port 8102
}
Quel courtier🔗
Les deux engendrent la même chose : les mêmes sujets tirés du modèle, le même
publish à chaque écriture, le même subscribe générique. Ce qui diffère,
c'est ce que vous payez et ce que vous obtenez.
nats | kafka | |
|---|---|---|
| Client | Rust pur | lie librdkafka, une bibliothèque C |
| Dockerfile | n'ajoute rien | ajoute CMake, une chaîne C++ et Perl à l'étage de construction |
| Courtier dans le Compose | ~15 Mo, prêt en une seconde | KRaft, pas de ZooKeeper, prêt en quelques secondes |
| Un consommateur absent | n'entend rien de ce qu'il a manqué | le lit à son retour, dans la limite de la rétention du topic |
| Plusieurs répliques d'un consommateur | chacune reçoit tout | les groupes se répartissent les partitions |
| Ordre | par sujet | par partition, et la clé est l'identifiant, donc l'historique d'un enregistrement est ordonné |
Prenez nats, sauf si vous avez déjà une grappe Kafka, s'il vous faut des
groupes de consommateurs, ou qu'un événement doive survivre à l'absence de son
consommateur. Ce sont de vrais besoins, et c'est à quoi sert la seconde colonne.
Chaque enregistrement de ce service publie désormais ses changements. Démarrez la pile et écoutez :
curl -X POST localhost:8100/api/orders -H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"placedAt":"2026-04-01T11:00:00Z","status":"PENDING",
"total":"99.00","customer":"Katherine Johnson"}'
orders.order.created
{"id":3,"placedAt":"2026-04-01T11:00:00Z","status":"PENDING","total":"99",
"customer":"Katherine Johnson","createdAt":"…","updatedAt":"…"}
orders.order.updated
{"id":3,…,"status":"PAID",…}
orders.order.deleted
{"id":3}
Trois sujets par enregistrement, <service>.<table>.<événement>, nommés en
constantes dans src/messaging/subjects.rs. Une suppression porte
l'identifiant, puisqu'il ne reste aucun document à envoyer.
Consommer, dans billing🔗
// La forme attendue. `orders` publie son document OpenAPI ; voici ce que rend
// `GET /api/orders/{id}`, réduit aux champs qui vous intéressent.
#[derive(serde::Deserialize)]
struct OrderPaid {
id: i64,
total: String,
status: String,
}
let mut events = state
.messaging
.subscribe::<OrderPaid>("orders.order.updated".to_owned())
.await?;
while let Some(event) = events.next().await {
if event.payload.status == "PAID" {
// … marquer la facture réglée
}
}
Le type est à vous de le déclarer, et c'est voulu : un service est engendré depuis un modèle restreint à ses propres enregistrements, pour qu'un service engendré dans un système soit octet pour octet le même projet que ce service engendré seul.
Ce que vous échangez🔗
Une écriture n'échoue jamais parce que le courtier est tombé. Vérifié :
courtier arrêté, POST /api/orders répond encore 201 et l'enregistrement est
relisible. Le prix, c'est qu'un événement peut être perdu — NATS cœur ne
garde rien, donc un abonné qui n'était pas connecté ne reçoit pas ce qu'il a
manqué, et une publication faite pendant que le courtier est injoignable part à
la reconnexion, ou jamais si le processus s'arrête avant. Activez JetStream pour
des événements qui doivent y survivre.
Le courtier n'est pas une sonde de disponibilité. Un service dont le courtier a disparu répond correctement à tout ; le signaler ferait sortir du répartiteur une instance saine à cause d'un canal annexe.
Choisir une forme🔗
| API REST | Monolithe | Système | |
|---|---|---|---|
| Modèle | un service | un service | un service par application |
| Engendré | 38 fichiers | 63 fichiers | 6 + 23 + 43 + 48 + 34 |
| Déploiements | 1 | 1 | 1 par service, plus la passerelle |
| Références | partout | partout | à l'intérieur d'un service |
| Comptes | — | auth jwt | sur le service qui les possède |
| Interface | — | ui react|vue|angular | appeler la passerelle |
| Démarrage | cargo run | cargo run + npm run dev | docker compose up |
Commencez à gauche. Aller vers la droite, c'est ajouter des réglages à un modèle que vous avez déjà — les enregistrements ne changent pas, et les handlers que vous avez écrits non plus. Le système est le seul pas qui ne se fasse pas gratuitement en sens inverse : répartir des enregistrements entre services, c'est là que les références cessent de traverser.
Pour aller plus loin🔗
- Guide du développeur — une session de bout en bout
- Langage CDL — tous les types, attributs et réglages
- Architecture technique — de quoi le projet engendré est fait
examples/dans le dépôt —shop.cdletsystem.cdl, engendrés et exercés par les tests à chaque changement {% endraw %}