crabster

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 :

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.

natskafka
ClientRust purlie librdkafka, une bibliothèque C
Dockerfilen'ajoute rienajoute CMake, une chaîne C++ et Perl à l'étage de construction
Courtier dans le Compose~15 Mo, prêt en une secondeKRaft, pas de ZooKeeper, prêt en quelques secondes
Un consommateur absentn'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 consommateurchacune reçoit toutles groupes se répartissent les partitions
Ordrepar sujetpar 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 RESTMonolitheSystème
Modèleun serviceun serviceun service par application
Engendré38 fichiers63 fichiers6 + 23 + 43 + 48 + 34
Déploiements111 par service, plus la passerelle
Référencespartoutpartoutà l'intérieur d'un service
Comptes—auth jwtsur le service qui les possède
Interface—ui react|vue|angularappeler la passerelle
Démarragecargo runcargo run + npm run devdocker 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🔗