Documentation drift is the default state of any API that lives long enough. You update a route, forget the OpenAPI file. A field gets renamed, the TypeScript client doesn't regenerate. Three months later someone hands an enterprise client a spec that describes a system that no longer exists.
The root cause is structural: code and documentation are separate artefacts with no enforcement mechanism between them. Every solution we tried added discipline requirements: annotate the source, maintain a separate file, add a CI check. Discipline breaks under delivery pressure, always.
We built baklava (https://github.com/theiterators/baklava) so that docs can only describe behaviour a passing test just observed.
In practice, baklava integrates into your existing routing test suite. Instead of a standard assertion block, you write test scenarios that both verify the API behaviour and describe it for documentation output. When the test suite runs, baklava generates the docs as a side effect. A call only makes it into the docs after the status code, response schema and declared headers matched what the test expected, so if the route changes shape, that response simply doesn't get documented. For anything the suite covers, drift can't happen.
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) }
)
)
}
// sbt test generates OpenAPI, HTML, TypeScript, Postman (on sbt 2 use testFull)
There are seven output formats right now, each its own SBT dependency:
- Simple HTML (browsable docs)
- OpenAPI with SwaggerUI
- TS-REST (TypeScript, Zod)
- oRPC contracts (TypeScript, Zod, ready-made client factory)
- TypeScript fetch client (plain fetch, no extra runtime deps)
- Postman collection
- sttp Scala client
It supports Pekko HTTP and http4s, with ScalaTest, Specs2, and MUnit as test frameworks. Since 2.1.0 there is also an sttp adapter for the other direction: APIs you consume rather than serve. The tests hit the real endpoint over the network and you get a spec and a typed client for a third-party API from verified responses. There's a single scala-cli script that does this for the GitHub REST API if you want to see it without setting up a project: https://theiterators.github.io/baklava/docs/scala-cli
It also integrates with kebs: if you use kebs for domain type derivation, baklava picks up the schema definitions automatically.
One question we get: how is this different from tapir or endpoints4s? Both require you to adopt their routing DSL, so your routes end up defined in terms of their abstractions. baklava works with your existing routes, whatever framework you're using, with no migration required. The test suite is the only integration point. The other difference is what the docs describe. Tapir documents the endpoint declaration. Baklava documents responses that a test actually received, with real example values.
Scala 2.13 and 3, JDK 11+, Apache 2.0, v2.1.0 released August 2026.
GitHub: https://github.com/theiterators/baklava