crabster

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:

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.

natskafka
Clientpure Rustbinds librdkafka, a C library
Dockerfileadds nothingadds CMake, a C++ toolchain and Perl to the build stage
Broker in Compose~15 MB, ready in a secondKRaft, no ZooKeeper, ready in seconds
A consumer that was awayhears nothing it missedreads it when it returns, within the topic's retention
Several replicas of one consumereach gets every eventconsumer groups share the partitions out
Orderingper subjectper 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 APIMonolithSystem
Modelone serviceone serviceone service per application
Generated38 files63 files6 + 23 + 43 + 48 + 34
Deployments111 per service, plus the gateway
Referencesanywhereanywhereinside a service only
Accounts—auth jwton the service that owns them
Front end—ui react|vue|angularcall the gateway
Runcargo runcargo run + npm run devdocker 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🔗