Baklava: Generate API Documentation and Type-Safe Clients from Scala Routing Tests

0 20 min read Developer Productivity
Katarzyna Kozłowska

Katarzyna Kozłowska

Sales & Marketing Specialist

Two problems show up in every project that ships APIs. Documentation drift: the spec describes what the API was supposed to do, not what it does now, and the gap stays invisible until an integration partner discovers it in their environment. Contract staleness: one service changes a field, another breaks, and the failure surfaces at runtime because the contract was a separately maintained artifact with no structural connection to the code.

Integration tests are one of the most overlooked types of tests. “Units pass, I’m ok!” – until they aren’t and it isn’t. The amount of work to get them right was the usual excuse; now AI can do the heavy lifting, which removes it. But integration tests are more than a good-night-sleep pill. They’re the ultimate spec for how the outside world interacts with your system. With baklava, we turn integration tests into integration artifacts, for many kinds of consumers.

Łukasz Sowa
Łukasz Sowa

This series has covered both as design problems – what good API documentation requires, how service contracts should be structured for reliable integrations. This article covers both as a structural problem. Good design doesn’t prevent drift. A mechanism that makes drift impossible does.

Every team that has shipped a public or internal API has hit the same moment: an integration partner reports that the spec and the actual behavior don’t match. Not because anyone was careless. Because the spec and the code are two separate artifacts with no mechanism enforcing that they stay in sync.

We’ve been in that situation on real client projects. Integration partners are discovering discrepancies between the OpenAPI spec we’d published and the actual API behavior in their QA environments. Not in ours. In theirs, after they’d already built against the spec. The field had been renamed three weeks earlier, the spec hadn’t been updated, and nobody caught it because tests still passed and the app still worked. The spec was wrong, but nothing in the build complained.

That’s a structural problem. You can add checklists, you can add PR review requirements, you can add CI diffs on spec files. But the mechanism that generates the docs and the mechanism that verifies the behavior are still separate, and the gap between them is where drift lives. Eventually, discipline breaks.

The fix we built is baklava: generate the documentation from the tests that verify the actual behavior. If the test passes, the docs it produces are accurate. If the behavior changes and the test breaks, the docs won’t regenerate until the test is fixed. The coupling is structural, not procedural.

This is a precise account of how that works, what you get out of it, where it fits against the alternatives, and how to start using it.

The Core Mechanism

API Docs: Procedural Drift vs. Structural Guarantee

1. Code is Changed

A developer renames a response field or adds a new status code.

2. Tests Pass

The app works perfectly. CI/CD pipeline is green.

3. Spec Remains Outdated

The OpenAPI file is a separate artifact with no structural link to the code. Nobody remembers to update it manually.

4. Silent Drift

The build system cannot detect that the documentation is lying. The gap is published.

5. Production Failure

An integration partner discovers the discrepancy at runtime in their QA environment (or worse, production).

1. Annotate Test Scenarios

Developer adds metadata (path, method, types) directly to the routing test using Baklava DSL.

2. Run `sbt test`

Baklava intercepts the exact Request/Response pairs actually generated during the test execution.

If Test Fails

Generation is blocked. You cannot publish a spec that doesn’t match the current code.

If Test Passes

100% accurate documentation is projected from the verified execution trace.

Simple HTML
OpenAPI + Swagger UI
TS-REST Contracts
oRPC Contracts
TS Fetch Client
Postman Collection
Scala sttp Client

Routing tests already describe the API contract. When you write a routing test, you’re asserting the path, the HTTP method, the request parameter shapes, and the possible responses. A test that passes is a verified claim about what the API does. A verified claim is what documentation is supposed to be.

The problem with standard routing tests is they don’t capture the metadata needed to generate documentation: the path pattern as a string, the parameter names and types, the description of what each response means, the shape of the response body as a schema. Tests verify behavior, but they don’t capture it in a form that’s useful outside the test.

Baklava’s DSL adds that metadata to the test without changing what the test asserts. You annotate each scenario with the path, method, parameter types, and response descriptions. The test executes normally. As a side effect, each test scenario contributes to the generated documentation.

Here’s what that looks like:

class UserApiSpec extends AnyFunSpec
    with BaklavaPekkoHttp[Unit, Unit, ScalatestAsExecution]
    with BaklavaScalatest[Route, ToEntityMarshaller, FromEntityUnmarshaller] {

  path("/users/{userId}")(
    supports(
      GET,
      pathParameters = p[Long]("userId"),
      summary = "Get user by ID"
    )(
      onRequest(pathParameters = 1L)
        .respondsWith[User](OK, description = "User found")
        .assert { ctx =>
          ctx.performRequest(routes).body.id shouldBe 1L
        },
      onRequest(pathParameters = 999L)
        .respondsWith[ErrorResponse](NotFound, description = "User not found")
        .assert { ctx => ctx.performRequest(routes) }
    )
  )
}

What each piece does:

  • path("/users/{userId}"): declares the endpoint path pattern. Documentation metadata.
  • supports(GET, pathParameters = p[Long]("userId"), summary = "..."): HTTP method, parameter types, description.
  • onRequest(pathParameters = 1L): a test scenario with specific input. This drives the actual request.
  • .respondsWith[User](OK, description = "User found"): expected response type and status code. The type parameter is what gets turned into a schema.
  • .assert { ctx => ... }: the actual test assertion. Identical to what you’d write in a standard routing test.

The assert block is unchanged from standard ScalaTest routing tests. You’re not learning a new assertion model. You’re wrapping the existing assertion in a structure that also produces documentation.

The DSL isn’t limited to path parameters. supports() also takes queryParameters and headers, using the same typed builder (p[Long]("userId"), q[String]("status"), and so on), and a single path block can declare supports() for more than one HTTP method. A list-with-filter endpoint looks like this:

path("/orders")(
  supports(
    GET,
    queryParameters = q[Option[String]]("status"),
    headers = h[String]("Authorization"),
    summary = "List orders, optionally filtered by status"
  )(
    onRequest(queryParameters = Some("pending"), headers = "Bearer test-token")
      .respondsWith[List[Order]](OK, description = "Matching orders")
      .assert { ctx => ctx.performRequest(routes) }
  )
)

Same pattern: the declared shape (queryParameters, headers) is metadata, onRequest supplies concrete values for one scenario, and assert runs the same check you’d write without baklava in the picture.

When sbt test runs, baklava observes each request and response and writes the documentation to target/baklava/. No separate build step, no separate script. The docs are current because they were generated by the test run that just verified the behavior.

The guarantee is this: if the test passes, the documentation accurately describes what the API does. If someone renames a field in the response and the test breaks, the docs won’t regenerate until the test is fixed. You can’t have passing tests and wrong documentation, because the same execution produces both.

How the Observation Actually Works

It’s worth being precise about what “generated from tests” means mechanically, because it’s the part that distinguishes baklava from every endpoint-definition library.

supports() declares static metadata for a path and method – it exists once, regardless of how many scenarios you run against it. onRequest() is different: each one drives a real request through your real, unmodified routing code and produces a real response. The BaklavaPekkoHttp (or BaklavaHttp4s) and BaklavaScalatest (or the equivalent for your test framework) traits hook into that execution. When ctx.performRequest(routes) runs inside assert, baklava captures the concrete request it sent and the concrete response it got back, matches that pair against the respondsWith() declaration for the scenario that produced it, and folds the result into the OpenAPI operation being built for that path and method.

The consequence: an operation’s documented response set is the union of whatever respondsWith() declarations you’ve actually exercised through a passing onRequest scenario. Nothing is inferred from what your handler is theoretically capable of returning. If your handler can return a 409 Conflict but no scenario exercises that branch, 409 doesn’t appear in the docs – not because baklava failed to notice it, but because nothing verified it. That’s the same principle stated earlier from a different angle: the documentation is a projection of what the tests actually cover, not of what the code could possibly do.

This is the structural difference from tapir, endpoints4s, and smithy4s. Those libraries build the OpenAPI operation from a static value – an Endpoint description, or a Smithy schema – that exists independent of any test run. Their docs interpreter reads that value directly; it never sends a request and never needs a passing test to produce output. baklava has no equivalent static description to interpret. The only source of truth is the request/response pair a test actually produced. That’s a real tradeoff, not a limitation to route around: it’s what makes the “if the test passes, the docs are accurate” guarantee possible in the first place. A static description can be correct or stale independent of whether anything runs it. An execution-derived one can’t be stale, because it doesn’t exist until the thing it describes has already happened.

What Gets Generated

One test run produces seven documentation formats, each its own formatter module, auto-discovered via reflection – add the dependency and generation runs without further configuration. For the UserApiSpec example above, the OpenAPI operation baklava assembles for GET /users/{userId} looks roughly like this:

paths:
  /users/{userId}:
    get:
      summary: Get user by ID
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: integer
            format: int64
      responses:
        '200':
          description: User found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '404':
          description: User not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

Both response cases are there because both were exercised: the 1L scenario produced the 200, the 999L scenario produced the 404. Nothing about that YAML was handwritten.

Simple HTML. Self-contained, browsable documentation with no external dependencies. Useful for internal teams who need a readable reference without setting up a documentation portal.

OpenAPI with Swagger UI. An OpenAPI 3.0.1 spec, with optional Swagger UI integration for Pekko HTTP. The standard contract format. Importable by integration partners, readable by non-developers through the SwaggerUI interface, usable in procurement due diligence. This is what most teams need for external consumers.

TS-REST. A complete TypeScript npm package built on ts-rest and Zod, with a nested router structure matching your API. Frontend teams get type-safe contracts that match the actual API. When the API changes and the routing test breaks, the TypeScript types won’t regenerate until the test is fixed.

oRPC contracts. A TypeScript package with oRPC contracts and Zod validators, including a ready-made client factory with error handling – for teams standardized on oRPC rather than TS-REST.

TypeScript fetch client. A plain-TypeScript client using the browser/Node fetch API, with typed async functions and no external runtime dependency – for teams that don’t want ts-rest or oRPC as a dependency at all.

Postman collection. A Postman Collection v2.1 document, importable into Postman and Insomnia, for API testing and manual exploration workflows.

Scala sttp client. sttp-client4 request builders for every endpoint, with typed request/response handling. For Scala services consuming the API. This is particularly relevant for service-to-service communication, which we’ll come back to.

All seven formats come from the same test run. You’re not maintaining seven separate artifacts or running seven separate generation steps. The information is captured once, at test time, and projected into whatever formats your workflow requires.

A few limitations worth knowing before you rely on these outputs for anything customer-facing:

  • TS-REST and oRPC. When a single operation has scenarios that produce different response schemas, the generated type is a real z.union([...]) of everything actually exercised, not an approximation – calling code has to narrow it, the same way it would with any discriminated union.
  • Postman. The Postman collection format only supports one auth block per request. If an endpoint declares multiple SecuritySchemes, only the first is reflected in the exported collection. The other schemes still work at runtime; they just don’t show up as pre-filled auth in Postman.
  • Scala sttp client. The same single-scheme limitation applies to the generated request builders. An endpoint secured by more than one scheme needs the extra headers supplied manually by the caller.
  • TypeScript fetch client. When an endpoint declares multiple 2xx responses with different body shapes, the function’s return type is a union of all of them – callers narrow at the call site, same as with TS-REST and oRPC.

None of these are bugs. They’re the same principle as everything else in this article: the output describes exactly what the tests exercised, not a smoothed-over approximation of what the endpoint could theoretically do.

Framework Coverage

Baklava is organized as separable modules – you add the ones your stack needs, nothing more:

HTTP frameworks: baklava-pekko-http (Pekko HTTP), baklava-http4s (http4s)

Test frameworks: baklava-scalatest (ScalaTest), baklava-specs2 (Specs2), baklava-munit (MUnit)

Output formatters: baklava-simple (self-contained HTML), baklava-openapi (OpenAPI + SwaggerUI), baklava-tsrest (TS-REST + Zod), baklava-orpc (oRPC contracts + Zod), baklava-tsfetch (plain fetch client), baklava-postman (Postman collection), baklava-sttpclient (Scala sttp client)

Scala 2.13 and Scala 3 LTS, JDK 11+. A service on http4s + MUnit picks a different pair of dependencies than a service on Pekko HTTP + ScalaTest, but the DSL – path, supports, onRequest, respondsWith, assert – is the same across all of them. Switching HTTP frameworks or test frameworks doesn’t mean relearning the documentation model.

It also integrates with kebs. If your project uses kebs for domain type derivation, baklava picks up schema definitions automatically. Your wrapper types – UserId, Email, OrderId – appear correctly in the generated OpenAPI schema without manual schema definitions. If you’re using both libraries, this is the seam where they meet: kebs eliminates the typeclass boilerplate for your domain types, baklava turns the resulting instances into schema entries in the generated spec.

Where It Fits vs. The Alternatives

There are three main approaches to API documentation for Scala services. They make different tradeoffs.

Endpoint-definition tools: tapir, endpoints4s, smithy4s. You define endpoints as typed values using the library’s DSL. Documentation is derived from those definitions automatically. This is the most popular approach in modern Scala services, and it works well for greenfield projects.

The limitation is that adoption requires migrating routing code. Existing services have to rewrite their route handlers into the library’s model. For a service with dozens of endpoints that’s been in production for two years, “migrate all your routes to tapir” is a significant project, not a one-afternoon task. The documentation is also only as accurate as the endpoint definition, which is still a separate artifact from the actual HTTP handler. The definition can drift from the handler. Less likely than with hand-maintained OpenAPI, but structurally possible.

Spec-first, hand-maintained OpenAPI. You maintain an OpenAPI YAML or JSON file directly, or with a spec editor. Full control, no library dependency. If you need to produce a spec before writing any code, this is the only option.

The cost is sustained discipline. Every PR that changes an endpoint has to update the spec. The only mechanism that catches divergence is human review. Over time, on a busy team, the spec and the implementation drift. That’s not a prediction, it’s a pattern. We’ve seen it on projects where the team genuinely cared about keeping the spec current.

The Mastering API Documentation article covers what comprehensive documentation requires – lifecycle maintenance, versioning, the components that keep a spec useful to integration partners. All of it assumes the spec reflects the implementation. Baklava is what enforces that assumption structurally.

baklava. No migration of routing code. The application routing handlers are untouched. The integration point is the test suite. You rewrite routing tests into the baklava DSL, one endpoint at a time. The application routing logic doesn’t change.

The practical difference comes down to where the consistency burden falls. With endpoint-definition tools, the burden is at the definition layer: keep the endpoint definition aligned with the handler. With hand-maintained specs, the burden is at every PR. With baklava, the burden is at the test layer, and the tests are already the mechanism you use to verify correctness. Documentation accuracy becomes a property of the test run, not a separate task.

The incremental adoption point matters for existing codebases. You can migrate one endpoint to the baklava DSL, get documentation for that endpoint, and leave the rest unchanged. There’s no threshold you have to cross before the library is useful. On a project with 40 endpoints, you can start documenting the 5 that external partners actually depend on, see how it works, and decide whether to continue.

One thing baklava doesn’t do: it doesn’t help you design your API before writing any code. If your workflow starts from the spec and generates stubs, this isn’t the tool. Baklava documents existing, tested behavior. The spec is an output, not an input.

Why This Isn’t Already Solved by tapir or endpoints4s

It’s worth going one level deeper than “migration cost,” because the difference isn’t just adoption friction – it’s a difference in what the description is derived from.

tapir. tapir describes an endpoint as a typed Endpoint value: inputs, outputs, error outputs, all as data, interpretable in multiple directions – as a server route, as an OpenAPI fragment, as a client. Write the Endpoint once, and routing, docs, and client generation all derive from the same definition. Because the description is data rather than an execution trace, tapir’s docs interpreter reads it directly and never has to run a request to produce output.

The adoption cost was already covered above: getting that symmetry means the route has to exist as an Endpoint value in the first place, not as a function matched against Pekko HTTP or http4s combinators. Baklava doesn’t carry that requirement, because it isn’t deriving anything from a description of the endpoint at all. It’s deriving from the verified fact of a request going through your actual, unmodified routing code and producing an actual response. path() and supports() are metadata attached to a test, not a value your routes are built from.

endpoints4s. endpoints4s keeps tapir’s core idea – describe the endpoint once, interpret it multiple ways – but makes the description language extensible rather than a sealed algebra. An interpreter that doesn’t support a given combination fails at compile time instead of at runtime with a MatchError, which is a real correctness improvement over an approach where an interpreter can silently mishandle an input it doesn’t recognize. It shares tapir’s adoption cost, though: your routes have to be expressed in endpoints4s’s algebra to get any of the benefit.

smithy4s. Same category again, this time schema-first: a Smithy IDL definition (or a code-first equivalent) that multiple interpreters read. Strong guarantees, and the same migration cost for a codebase that doesn’t already speak Smithy.

The actual axis. None of these are the wrong choice – they’re optimized for a different starting condition. Writing routes as tapir, endpoints4s, or smithy4s values from day one costs nothing extra on a new service, because you’re defining the routing logic for the first time anyway. If you have two years of Pekko HTTP routes and a passing test suite, the relevant question isn’t “which description language is best” – it’s “what’s the smallest change that gets me accurate documentation without touching working, tested routing code.” That’s the gap baklava fills. It isn’t a replacement for tapir on a greenfield project. It’s the option for the other case.

Service-to-Service Contracts

The documentation drift problem has a more acute version in service-oriented architectures: keeping contracts between internal services in sync.

When service A calls service B, there’s an implicit contract: A expects specific request and response shapes from B. When B renames a field, drops a response case, or changes a parameter type, A breaks. Without a shared contract artifact, that failure surfaces at runtime, usually in staging if you’re lucky, production if you’re not.

The standard fix is a shared contract file: service B maintains an OpenAPI spec, service A generates its HTTP client from that spec. This helps, but it brings the same problem: the spec is a separate artifact that can drift from the implementation. B changes the handler and forgets to update the spec. A’s generated client is still based on the old spec. The divergence isn’t caught until runtime.

With baklava, service B generates an sttp client and TypeScript contracts from its own routing tests. Those contracts are current because they’re generated by the tests that verify B’s implementation. Service A depends on the generated contract, not on an independently maintained file. When B’s tests break, the contract isn’t regenerated until the tests are fixed. If B’s tests are passing, the contract is accurate.

The Managing Service Contracts: Strategies for Reliable System Integrations article covers the design decisions in this space – communication protocols, schema validation, error handling, versioning strategies. Those decisions are worth making carefully. Baklava is what ensures the contract you designed is also the contract currently being enforced.

The update cycle becomes: change B’s implementation, fix B’s tests to match the new behavior, regenerate the contract, publish it, update A to use the new contract. Each step is forced by the previous one. You can’t publish a contract that doesn’t reflect B’s actual behavior, because the contract is generated from the tests that just verified that behavior.

For services in separate repositories, the generated client can be published as a library artifact. The dependency version in A’s build.sbt tells you exactly which version of B’s contract A is compiled against.

Worked Scenario: Adding a Response Case

Here’s a concrete situation that illustrates where things go wrong without structural coupling.

A POST /orders endpoint currently returns two cases:

  • 201 Created with the order object
  • 400 Bad Request for invalid input

The team adds a new business rule: orders above a threshold require manual approval. The endpoint now returns three cases:

  • 201 Created: order placed automatically
  • 202 Accepted: order queued for approval
  • 400 Bad Request: invalid input

Without baklava, the developer changes the handler and adds a test for the 202 case. The OpenAPI spec isn’t updated. The tests pass. CI is green. The TypeScript client still doesn’t declare the 202 case. Two weeks later, an integration partner processing orders starts seeing 202 responses with no documentation explaining what they mean or what to do next. Their implementation was built against the spec that showed only two possible responses.

With baklava, the developer changes the handler and updates the routing test to add an onRequest scenario for the 202 case. When they run sbt test, the OpenAPI spec and TypeScript client are regenerated with the new response case included. If they forget to add the scenario, the test coverage gap is visible, and the contract they publish still won’t include the 202 case until they do.

The point isn’t that developers become more careful. They’re already careful. The point is that the mechanism for updating documentation is the same as the mechanism for updating tests. You’re not introducing a separate step that someone has to remember.

A Real Migration

Here’s what migrating an existing endpoint actually looks like. A project has an OrdersApiSpec written as a standard ScalaTest routing spec, with no baklava in the picture:

// Before: standard ScalaTest + Pekko HTTP routing spec
class OrdersApiSpec extends AnyFunSpec with ScalatestRouteTest with Matchers {

  describe("GET /orders/{orderId}") {
    it("returns the order when it exists") {
      Get("/orders/1") ~> routes ~> check {
        status shouldBe StatusCodes.OK
        responseAs[Order].id shouldBe 1L
      }
    }

    it("returns 404 when the order doesn't exist") {
      Get("/orders/999") ~> routes ~> check {
        status shouldBe StatusCodes.NotFound
      }
    }
  }
}
// After: same assertions, restructured into the baklava DSL
class OrdersApiSpec extends AnyFunSpec
    with BaklavaPekkoHttp[Unit, Unit, ScalatestAsExecution]
    with BaklavaScalatest[Route, ToEntityMarshaller, FromEntityUnmarshaller] {

  path("/orders/{orderId}")(
    supports(
      GET,
      pathParameters = p[Long]("orderId"),
      summary = "Get order by ID"
    )(
      onRequest(pathParameters = 1L)
        .respondsWith[Order](OK, description = "Order found")
        .assert { ctx =>
          ctx.performRequest(routes).body.id shouldBe 1L
        },
      onRequest(pathParameters = 999L)
        .respondsWith[ErrorResponse](NotFound, description = "Order not found")
        .assert { ctx => ctx.performRequest(routes) }
    )
  )
}

What changed: the describe/it structure became path/supports/onRequest, the expected status code moved into respondsWith, and the assertion body is the same claim it was before. What didn’t change: routes – the application’s actual routing logic – is untouched. The migration is mechanical, endpoint by endpoint: no handler code moves, no service wiring changes.

A few things worth knowing before doing this across a real codebase:

One scenario per documented response. If an existing test asserted on both the 200 and 404 case inside a single it block using conditionals, that has to split into two onRequest scenarios. Each documented response variant needs its own scenario, because – as covered above – the documentation is built from the specific request/response pairs that were actually exercised, not from a description of what the handler could theoretically return.

Shared response types stay shared. ErrorResponse in the example above is the same type used across every endpoint’s error cases. Migrating one endpoint doesn’t mean redefining its error schema; the type is declared once and referenced through respondsWith wherever it applies.

Auth headers and query parameters migrate the same way. An existing test that sets an Authorization header or a query string filter carries that value into onRequest‘s headers or queryParameters argument, the same way path parameters do.

Most projects migrate the handful of endpoints external partners actually depend on first, confirm the generated output looks right, then continue endpoint by endpoint as time allows. There’s no partial-migration penalty: unmigrated endpoints simply don’t appear in baklava’s output yet, and nothing about them breaks.

Getting Started

To get started, add baklava to your test dependencies – pick the HTTP framework, test framework, and formatter modules for your stack from the list above:

// build.sbt (for Pekko HTTP + ScalaTest)
libraryDependencies ++= Seq(
  "pl.iterators" %% "baklava-pekko-http" % "1.4.0" % Test,
  "pl.iterators" %% "baklava-scalatest"  % "1.4.0" % Test
)

// for http4s + MUnit
libraryDependencies ++= Seq(
  "pl.iterators" %% "baklava-http4s" % "1.4.0" % Test,
  "pl.iterators" %% "baklava-munit"  % "1.4.0" % Test
)

Add whichever output formatter modules you need – baklava-simple, baklava-openapi, baklava-tsrest, baklava-orpc, baklava-tsfetch, baklava-postman, baklava-sttpclient – alongside the HTTP and test framework dependencies. Mix BaklavaPekkoHttp (or BaklavaHttp4s) and BaklavaScalatest (or the equivalent for your test framework) into your routing spec. Replace standard assertion blocks with path() / supports() / onRequest() scenarios. Run sbt test. The generated files appear under target/baklava/, one subdirectory per formatter.

Start with one endpoint. Pick the one your integration partners depend on most. Get documentation for that endpoint first. If you don’t understand what the generated output looks like, or if the schema for a custom type isn’t rendering correctly, that’s the moment to dig into the configuration. Don’t migrate all 40 endpoints on the first pass.

What baklava Doesn’t Do

Baklava doesn’t solve the problem of missing tests. If an endpoint isn’t covered by a routing test, it won’t appear in the documentation. That’s not a bug; it’s the design. The documentation is a projection of the tests. Gaps in the tests are gaps in the documentation, which is accurate: an untested endpoint is one whose behavior you haven’t verified.

The side effect is that adopting baklava makes incomplete test coverage visible in the documentation. For teams where “we have tests for the happy path but not the error cases” is common, the generated documentation will reflect that. That’s useful information, but it’s worth knowing the dynamic before you share the generated docs with an integration partner and they notice that several error responses aren’t documented.

It doesn’t verify anything beyond what the assert block checks. Baklava documents that a given request produced a given response; it doesn’t judge whether the assertion was thorough. A loose assertion produces a documented example that’s technically accurate and substantively unhelpful. The documentation is only as rigorous as the test behind it – baklava removes the drift problem, not the need to write a good test.

It doesn’t replace consumer-driven contract testing. Tools like Pact verify that a provider’s behavior matches what a specific consumer expects, negotiated from the consumer’s side. Baklava documents the provider’s behavior as verified by the provider’s own tests. Those are complementary concerns – an accurate provider contract still doesn’t tell you whether a particular consumer’s assumptions match it – and pairing baklava with consumer-driven contract tests covers more ground than either alone.

It doesn’t help you design your API before writing any code, as covered earlier, and its DSL is built around request/response pairs – which means it documents synchronous HTTP endpoints. If part of your API surface is a message queue or a persistent connection, that’s outside the scenario model this DSL is built around.

These aren’t gaps so much as scope boundaries. The structural coupling between tests and docs is exactly what makes baklava useful, and exactly what surfaces the gaps in your test suite. If your tests are solid, this is a non-issue. If they’re not, the documentation problem and the testing problem are now visibly the same problem – which is also, not coincidentally, the harder one worth fixing first.

GitHub: https://github.com/theiterators/baklava, Apache 2.0 license, actively maintained.

Where the Idea Came From

The anecdote earlier in this article – an integration partner discovering a renamed field in their QA environment, three weeks after we’d already published the spec – wasn’t an isolated incident. It was roughly the fourth or fifth version of the same conversation on a client project: the code was right, the tests were green, and the document describing the API to someone outside the team was wrong.

The standard responses to that are procedural: a review checklist, a step in the PR template, a line in the Definition of Done. We tried those. They work until the team is under deadline pressure, and then the step that isn’t enforced by the build is the step that gets skipped. That’s not a judgment of any particular team’s discipline. It’s what happens to any manual step in a system that has no mechanism checking for it.

The reframe that led to baklava: stop treating documentation as an artifact that describes the code, and start treating it as an artifact the tests produce. Routing tests already assert everything documentation needs – the path, the method, the shape of a valid request, the shape of the response for each case you’ve thought to test. That metadata was already sitting in every test suite we’d ever written. It just wasn’t captured in a form anything outside the test runner could use.

Building baklava was mostly a matter of capturing that metadata instead of discarding it the moment the assertion passed. The library isn’t solving a hard algorithmic problem. It’s noticing that the data documentation needs and the data tests already produce are the same data, and refusing to maintain them twice.

We open-sourced it for the same reason we open-sourced kebs: the problem isn’t specific to our client work. Any team running a Scala HTTP service with a test suite already has the routing tests sitting there, capable of generating the documentation nobody has time to hand-maintain.

At Iterators, we build and maintain production Scala backends and develop the open-source libraries that come out of that work. If your team is dealing with API contract drift, service-to-service integration issues, or a migration from Akka to Pekko, we’re glad to talk.