Complete examples
{% raw %} Three shapes, each one written out from the model to a request that answers: a REST API, a monolith that adds accounts and a front end to it, and a system of services behind a gateway.
Every command on this page was run, and every response was copied from its output. Where something does not work the way it reads, the page says so — that is usually the part worth knowing.
The three use SQLite, so none of them needs anything installed. Change
database to postgres in the model and the generated Compose file brings a
server up beside the application without another word.
1. A REST API🔗
The smallest useful shape: a domain, a database, and HTTP over it.
The model🔗
// 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
}
Generate it🔗
crabster import-cdl api.cdl --path catalog
cd catalog
cp .env.example .env
cargo run
38 files. The ones to know about:
src/domain/ the SeaORM entities
src/dto/ what the API accepts and returns, which is not the entity
src/api/ one handler file per record — yours to edit
src/migration/ one migration per record, applied at startup
src/error.rs RFC 9457 problem details
config/ default.toml, dev.toml, prod.toml
Call it🔗
curl -X POST localhost:8420/api/products -H 'content-type: application/json' \
-d '{"reference":"CR-001","label":"Mechanical keyboard",
"price":"129.90","stock":"IN_STOCK","summary":null}'
{"id":1,"reference":"CR-001","label":"Mechanical keyboard","price":"129.9",
"stock":"IN_STOCK","summary":null,
"createdAt":"2026-10-06T21:10:24.315718Z","updatedAt":"2026-10-06T21:10:24.315741Z"}
createdAt and updatedAt are there because the record carries @audited.
Nothing asked for them in the body.
A list is a page, always:
curl 'localhost:8420/api/products?reference=CR-001'
{"items":[…],"page":0,"size":20,"totalItems":1,"totalPages":1}
?reference= is a filter because Product carries @filterable. Without it
the endpoint exists and the parameter is refused — a filter thrown away in
silence is worse than one that does not exist.
What the attributes do to a request🔗
Each of these is the model, enforced:
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}}]}}
The API describes itself at /swagger-ui and
/api-docs/openapi.json, generated from the same model.
2. A monolith: the same API, with accounts and a front end🔗
One application, one deployment: the API above, plus who may call it and something to call it from.
The model🔗
Three settings more, on the same records:
// monolith.cdl
service shop {
database sqlite
port 8430
auth jwt
ui react
client typescript
}
// … the same enum and the same two records
crabster import-cdl monolith.cdl --path shop
63 files rather than 38. What the three settings added:
src/auth/ accounts, password hashing, /auth/register, /auth/login,
and the extractor that makes a route require a session
clients/typescript/ a typed client generated from the API
ui/ a React front end: one page per record, plus a sign-in page
ui react pulls the TypeScript client in whether or not you asked for one — the
front end is written against it. vue and angular are the same front end in
those two frameworks.
Accounts🔗
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"}'
A JSON body with an accessToken — a signed JWT. A wrong password answers
401 /problems/wrong-credentials, with the same text for an unknown address
as for a bad password: telling them apart tells an attacker which addresses have
accounts.
auth jwt does not protect anything on its own🔗
This is the part that surprises people, so try it:
curl localhost:8430/api/products # → 200, with no token at all
The module gives you accounts, tokens and an extractor. A route is protected when its handler asks for a session, and nowhere else — there is no second list of protected paths that could disagree with the code.
So open src/api/product.rs and add one argument:
pub(crate) async fn list(
_session: crate::auth::Authenticated,
State(state): State<AppState>,
// … the rest unchanged
Rebuild, and the same request answers:
{"type":"/problems/not-authenticated","title":"This request carries no usable session",
"status":401,"detail":"no `Authorization: Bearer` header"}
With the token, it answers the page. Take Authenticated(claims) instead of
_session when the handler needs who is calling — claims.require_role("ADMIN")
is a one-line role check.
Files under src/api/ are yours. Crabster stamps them and tells you they
changed rather than overwriting them.
The front end🔗
cd ui
npm install
npm run dev
Its dev server proxies /api and /auth to 127.0.0.1:8430 — the port from
the model, written into vite.config.ts at generation. Sign in on the page it
opens, and the record pages list, create and delete through the typed client.
3. A system of services behind a gateway🔗
Several applications, deployed separately, with one address in front.
The model🔗
One file still. Each service block becomes a project of its own, and
@service(name) says who owns each record.
// system.cdl
service edge {
kind gateway // persists nothing: it forwards
discovery consul
port 8100
}
service identity {
database sqlite
auth jwt // this service owns the accounts
discovery consul
port 8101
}
service orders {
database sqlite
auth jwt // it verifies tokens; it does not mint them
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 // inside one service: an ordinary foreign key
}
@service(billing)
@audited @filterable
record Invoice {
issuedAt: Timestamp
total: Decimal
paid: Bool
orderId: Long // not `ref Order` — see below
}
A reference does not cross a service boundary🔗
Write order: ref Order on Invoice and generation stops:
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
This is the rule that separates a system from one application cut into pieces. What replaces the foreign key is an agreement between two teams about what an order id means — a decision about a distributed system, and so yours rather than the generator's to infer.
Generate it🔗
crabster import-cdl system.cdl --path shop-system
shop-system/
├── docker-compose.yml consul, edge, identity, orders, billing
├── README.md
├── edge/ 23 files — no model, no database
├── identity/ 43 files
├── orders/ 48 files
└── billing/ 34 files
Each service is an ordinary Crabster project: crabster record, apply and
upgrade all work inside one of them, and a service generated here is byte for
byte the same project as that service generated alone.
Run it🔗
cd shop-system
docker compose up --build -d --wait
The generated addresses are the Compose service names — http://orders:8102 —
so this is the path that works with no editing.
Outside Docker, those names resolve to nothing and the gateway answers
502 /problems/upstream-unreachable naming the service it could not reach.
Restate the list in 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"]
A profile replaces the list; it does not add to it. And it has to be a profile: an environment variable cannot reach into a list, and the attempt is refused at startup rather than ignored —
Error: failed to load configuration
Caused by:
invalid type: map, expected a sequence for key `gateway.upstreams`
Set consul_address instead and none of these addresses has to be right: which
paths a service owns is the model's answer and does not change, while where a
service is changes every deployment.
Signing in, in a system🔗
The gateway forwards /api/ and nothing else. /auth/register and
/auth/login are not reachable through it — they answer 404 there. A client
signs in against the service that owns the accounts, directly:
curl -X POST localhost:8101/auth/login -H 'content-type: application/json' \
-d '{"email":"ada@example.com","password":"correct-horse-battery-staple"}'
and then presents that token through the gateway, which passes authorization
along untouched:
curl localhost:8100/api/orders # 401
curl localhost:8100/api/orders -H "authorization: Bearer $TOKEN" # 200
A token minted by identity is accepted by orders because the extractor
verifies a signature and does not read the database. Two conditions follow,
and both are yours to hold:
- Every service that verifies tokens needs the same
APP__AUTH__SECRET. The generated.env.examplecarries the same development placeholder everywhere, so this works out of the box and will not survive a real deployment unless you set the same generated key on each. auth jwton a second service also generates an accounts table of its own there. It goes unused while that service only verifies tokens. Sayauth jwtonly on the services that must verify, and remember that registering onidentitycreates nothing onorders.
Creating through the gateway, with that token:
{"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 is @versioned: two clients changing one order cannot overwrite each
other in silence.
What the gateway answers for itself🔗
Routing is by the first segment under /api/, and by nothing else. A path
nobody claims is a 404 from the gateway rather than somebody else's 404 further
in:
{"type":"/problems/no-such-service","title":"Nothing here answers for that path",
"status":404,"detail":"no service claims `/api/widgets`"}
It also serves /health, /health/live, /health/ready, /metrics, and a
merged OpenAPI contract covering every service behind it. Rate limiting is on by
default — twenty requests a second, forty at once — because a gateway with no
limit forwards a flood as faithfully as it forwards anything else.
Starting from flags rather than a file🔗
The same system, without writing the model first:
crabster new shop-system --architecture microservices \
--gateway edge:8100 \
--service identity:sqlite:8101 \
--service orders:sqlite:8102 \
--service billing:sqlite:8103 \
--discovery consul
It writes the .cdl and generates from it, so both paths end at the same place.
One difference matters: a system created this way has no records yet. Add
them with crabster record inside a service, then run crabster upgrade at the
root — it reassembles the model and rebuilds the gateway's routing table from
it. Without that step the gateway routes nothing, because what it routes is what
the model says a service owns, and nothing owned anything when it was generated.
4. Events between services🔗
The system above talks one way: a caller reaches the gateway, the gateway
reaches a service. Nothing lets orders tell billing that an order was paid —
and Invoice holding orderId as a plain field is precisely a promise that
somebody will. messaging nats is that channel.
service orders {
database sqlite
auth jwt
discovery consul
messaging nats // add this — or `kafka`
port 8102
}
Which broker🔗
The two generate the same thing: the same subjects from the model, the same
publish on every write, the same generic subscribe. What differs is what you
pay and what you get.
nats | kafka | |
|---|---|---|
| Client | pure Rust | binds librdkafka, a C library |
| Dockerfile | adds nothing | adds CMake, a C++ toolchain and Perl to the build stage |
| Broker in Compose | ~15 MB, ready in a second | KRaft, no ZooKeeper, ready in seconds |
| A consumer that was away | hears nothing it missed | reads it when it returns, within the topic's retention |
| Several replicas of one consumer | each gets every event | consumer groups share the partitions out |
| Ordering | per subject | per partition, and the record's id is the key, so one record's history is in order |
Take nats unless you already have a Kafka cluster, need consumer groups, or
need an event to survive a consumer being down. Those are real needs, and they
are what the second column is for.
Every record of that service now publishes its changes. Run the stack and listen:
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}
Three subjects per record, <service>.<table>.<event>, named as constants in
src/messaging/subjects.rs. A deletion carries the identifier, because there is
no document left to send.
Consuming, in billing🔗
// The shape you expect. `orders` published its OpenAPI document; this is what
// `GET /api/orders/{id}` answers, narrowed to the fields you care about.
#[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" {
// … mark the invoice settled
}
}
The type is yours to declare, and that is deliberate: a service is generated from a model narrowed to its own records, so that a service generated inside a system is byte for byte the same project as that service generated alone.
What you are trading🔗
A write never fails because the broker is down. Verified: stop the broker,
POST /api/orders still answers 201 and the record is readable. The cost is
that an event can be lost — core NATS stores nothing, so a subscriber that
was not connected does not get what it missed, and a publish made while the
broker is unreachable goes when the client reconnects, or never if the process
ends first. Turn on JetStream for events that must outlive that.
The broker is not a readiness check. A service whose broker is gone answers every request correctly; reporting it as not ready would pull a healthy instance out of rotation for a side channel.
Choosing a shape🔗
| REST API | Monolith | System | |
|---|---|---|---|
| Model | one service | one service | one service per application |
| Generated | 38 files | 63 files | 6 + 23 + 43 + 48 + 34 |
| Deployments | 1 | 1 | 1 per service, plus the gateway |
| References | anywhere | anywhere | inside a service only |
| Accounts | — | auth jwt | on the service that owns them |
| Front end | — | ui react|vue|angular | call the gateway |
| Run | cargo run | cargo run + npm run dev | docker compose up |
Start at the left. Moving right is adding settings to a model you already have — the records do not change, and neither do the handlers you wrote. A system is the one step that is not reversible for free: splitting records across services is where references stop crossing.
Further🔗
- Developer guide — one session end to end
- CDL language — every type, attribute and setting
- Technical architecture — what the generated project is made of
examples/in the repository —shop.cdlandsystem.cdl, generated and exercised by the test suite on every change {% endraw %}