Blog

Seeding a car workshop database, one field at a time

The Real Fake Data team

Say you are building the software that runs a car workshop in Germany. The domain is small enough to hold in your head and awkward enough to be real: a customer owns a vehicle, the vehicle comes in for repair orders, and each order carries a handful of line items. Four tables, three foreign keys, and a demo that has to look convincing when you put it in front of a workshop owner who has run one for thirty years.

The seed data is where this gets tedious. You can hand-write a hundred lines of INSERT statements, but then every plate is `AAA 111`, every customer is Max Mustermann, and the third repair order belongs to a vehicle that does not exist because you miscounted an id. Or you can wire up a faker library and get plates that no German registry would recognise. This article builds the same dataset with `/v1/compose` instead, adding one piece at a time so you can see what each part of the request does.

The docs cover the grammar formally. This is the other thing — one concrete schema, built up in five steps, with the real responses at each stage.

Step 1: one customer

Start with the smallest useful thing. A customer needs an id and a name, and the name should look German rather than generically European. `$generator.<id>.<field>` pulls one field from one generator.

POST /v1/compose
{
  "seed": 42,
  "shape": {
    "customerId": "$generator.any.uuid.value",
    "name":       "$generator.de.person.name",
    "surname":    "$generator.de.person.surname"
  }
}
Response
{
  "customerId": "9972daab-2c86-459f-9d78-3fe1be4e3280",
  "name": "Carmen Tina",
  "surname": "Rüter"
}

That works, but there is a trap hiding in it — and it is worth stopping on, because it is the single most common mistake when writing a compose shape.

Step 2: making the customer one person

Every `$generator.de.person` reference is its own separate draw. Ask for `name`, `surname` and `steuerId` that way and you get three fields from three different people — a first name from one, a surname from another, a tax number belonging to a third. Nothing errors. The record just quietly describes nobody.

Declare the person once as a local entity in `$generators` and reference it with `$alias`, and all three fields come from the same draw:

POST /v1/compose
{
  "seed": 42,
  "shape": {
    "$generators": {
      "owner": { "generator": "de.person" },
      "car":   { "generator": "de.vehicle-registration" }
    },
    "customerId": "$generator.any.uuid.value",
    "customer":   "$alias.owner.surname",
    "vehicle": {
      "plate":    "$alias.car.value",
      "district": "$alias.car.city"
    }
  }
}
Response
{
  "customerId": "87062cd7-7ccf-4173-890d-8e983ea5354d",
  "customer": "Ferber",
  "vehicle": {
    "plate": "P E8",
    "district": "Potsdam"
  }
}

Note the plate. `P E8` is a real German format — `P` is the Unterscheidungszeichen for Potsdam, drawn from the actual registry of district codes, and the `district` field reports the city that code belongs to. Your workshop is in Brandenburg, so its customers plausibly drive cars registered in Potsdam. A faker library would have produced three random letters.

The rule of thumb: `$generator.x` when you want a fresh draw each time, `$alias.x` when several fields must describe the same thing. Getting this wrong produces data that looks fine and is internally incoherent.

Step 3: repair orders, one to two per car

A vehicle has a variable number of repair orders. `$count` turns a node into an array, and a `[min, max]` pair makes the length vary — which matters, because a fixture where every customer has exactly three orders will never catch the bug in your "no orders yet" empty state.

Dates come from `$date`, expressed as an offset window rather than a literal, so the dataset stays sensible however long after you write it the test runs. `$date.-60d~+0d.day` spreads orders across the last two months.

"repairOrders": {
  "$count": [1, 2],
  "orderId":  "$generator.any.nanoid.value",
  "openedAt": "$date.-60d~+0d.day"
}

Step 4: line items with real jobs and real prices

Each order needs its work items, and this is where the catalogue does the heavy lifting. The `offering` generator draws a genuine product or service with a plausible price, and it is organised by NACE industry code — so pinning `industry` to `95.3` — repair and maintenance of motor vehicles — gets you the things a garage actually invoices for, in German, instead of a random product name.

One subtlety worth repeating from step 2: the job name and its price must come from the same draw, or you get a windscreen chip repair priced like a gearbox rebuild. Declare the offering once as a local entity and pull both fields off it.

"lineItems": {
  "$count": [1, 3],
  "$generators": {
    "work": { "generator": "de.offering", "params": { "industry": "95.3" } }
  },
  "job":        "$alias.work.offeringName",
  "priceCents": "$alias.work.price"
}

For the mechanic who took the job, there is no generator — and there should not be, because these are four specific nicknames on a whiteboard in one particular garage. `any.enum` is the right tool whenever the values are yours rather than the world's: a weighted set of choices, so the apprentice picks up jobs less often than the two senior mechanics.

"mechanic": {
  "generator": "any.enum",
  "params": { "choices": "{\"Schrauber-Ben\":3,\"Diesel-Uwe\":2,\"Turbo-Kalle\":2,\"Elektro-Micha\":1}" },
  "pick": "value"
}

Step 5: the whole workshop, one request

Put the pieces together. One more addition: the workshop itself is `shared`, drawn once for the entire request rather than per record, because every customer in this dataset is serviced by the same garage. A `shared` entity is the right tool whenever something must be constant across the whole result.

POST /v1/compose
{
  "seed": 42,
  "time": { "start": "2026-07-01T00:00:00Z", "formats": { "day": "yyyy-MM-dd" } },
  "shared": { "workshop": { "generator": "de.company" } },
  "shape": {
    "$count": 2,
    "$generators": {
      "owner": { "generator": "de.person" },
      "car":   { "generator": "de.vehicle-registration" }
    },
    "customerId": "$generator.any.uuid.value",
    "customer":   "$alias.owner.surname",
    "taxId":      "$alias.owner.steuerId",
    "servicedBy": "$shared.workshop.name",
    "vehicle": {
      "plate":    "$alias.car.value",
      "district": "$alias.car.city"
    },
    "repairOrders": {
      "$count": [1, 2],
      "orderId":  "$generator.any.nanoid.value",
      "openedAt": "$date.-60d~+0d.day",
      "mechanic": {
        "generator": "any.enum",
        "params": { "choices": "{\"Schrauber-Ben\":3,\"Diesel-Uwe\":2,\"Turbo-Kalle\":2,\"Elektro-Micha\":1}" },
        "pick": "value"
      },
      "lineItems": {
        "$count": [1, 3],
        "$generators": {
          "work": { "generator": "de.offering", "params": { "industry": "95.3" } }
        },
        "job":        "$alias.work.offeringName",
        "priceCents": "$alias.work.price"
      }
    }
  }
}
Response
[
  {
    "customerId": "ed16f16e-f123-4c04-9c7b-9ce613b8ef47",
    "customer": "Schmitz",
    "taxId": "61297314505",
    "servicedBy": "Dombrowski & Partner GmbH",
    "vehicle": { "plate": "FF B666", "district": "Frankfurt (Oder)" },
    "repairOrders": [
      {
        "orderId": "TIkmtByVU5S7ZXElGtvVD",
        "openedAt": "2026-05-20",
        "mechanic": "Schrauber-Ben",
        "lineItems": [
          { "job": "Fehlercode-Auslesen",      "priceCents": 7500 },
          { "job": "Kraftstofffilterwechsel",  "priceCents": 8000 },
          { "job": "Kühlsystemspülung",        "priceCents": 14999 }
        ]
      },
      {
        "orderId": "QG8C6L59Wv5hA-qJuZOqy",
        "openedAt": "2026-05-14",
        "mechanic": "Diesel-Uwe",
        "lineItems": [
          { "job": "Bremsflüssigkeitswechsel", "priceCents": 4400 },
          { "job": "Kratzerreparatur",         "priceCents": 9499 }
        ]
      }
    ]
  },
  {
    "customerId": "501f500a-9335-4b61-b6e9-cf7027de86d7",
    "customer": "Knecht",
    "taxId": "19187654300",
    "servicedBy": "Dombrowski & Partner GmbH",
    "vehicle": { "plate": "OHV R216", "district": "Oberhavel" },
    "repairOrders": [
      {
        "orderId": "E2Wj9ADcYmlbanTMFftkO",
        "openedAt": "2026-06-22",
        "mechanic": "Turbo-Kalle",
        "lineItems": [
          { "job": "Klimaanlagen-Diagnose", "priceCents": 7500 },
          { "job": "Dellenentfernung",      "priceCents": 12500 }
        ]
      },
      {
        "orderId": "W5l2xsdLZivRG202aPVBQ",
        "openedAt": "2026-06-19",
        "mechanic": "Turbo-Kalle",
        "lineItems": [
          { "job": "Scheibenwischermontage", "priceCents": 2600 }
        ]
      }
    ]
  }
]

Read what came back. Two customers, each with a plate from a genuine district registry — `FF` is Frankfurt (Oder), `OHV` is Oberhavel. Both are serviced by the same company, because the workshop is `shared`. Each `taxId` is a structurally valid Steuer-ID that passes the real checksum, so the form validation in your customer screen has something to actually chew on. The jobs are real garage work at prices that fit them: €75 to read a fault code, €26 to fit a wiper, €149.99 for a coolant flush. Order counts vary, line-item counts vary, and nothing needed a foreign key stitched by hand — because the nesting *is* the relationship.

The part that matters on the second run

Send that request again and you get the same two customers, the same plates, the same jobs at the same prices. `seed: 42` fixes the entire tree. That is what makes it usable as a fixture rather than a demo toy: your test asserts that `Knecht` has two repair orders and that the first totals €200.00, and it will still be true next week. Change the seed and you get a completely different workshop with the same shape — useful for a second dataset that exercises the same code paths differently.

One request, one seed, a whole nested dataset — and the same request tomorrow returns the same data. That is the difference between seed data you can write tests against and seed data you can only look at.

Swap `de` for `pl`, `fr` or `us` and the shape survives intact: the plates, the tax numbers and the names all change to that country's real formats while your schema stays exactly as written. The full grammar — every prefix, `$date` anchors, nesting rules — is documented on the seed-a-database page.