API-First Development in Scala
This article is written by Sakib Hadžiavdić , owner of SaCode, and creator of several Scala libraries including FlowRun, hepek, and recently, openapi4s.All opinions expressed are his own.
There are generally two ways to build APIs that have a specification:
- the code-first approach and
- the API-first approach (schema-first)
A specification is useful when:
- there are many consumers of your API
- you need to generate clients in many languages
- you need to generate some API mocks for testing
- you need documentation for consumers of the API
- you need to maintain backwards compatibility of the API
- etc
Code-first Approach#
In the "code-first" approach you start with the coding right away. Schedules are tight, the pressure is real.
The specification (if any) is mostly an afterthought. Consider annotations in
Spring framework, you can always sprinkle some @ApiParam
here and there in
your controllers. And voila, you got yourself an OpenApi spec generated from
code!
There are also approaches like Tapir, where you use a Scala DSL to define your API. Then you implement the server logic, and generate the spec from that Scala code. Potentially the client(s) too.
Benefits:
- you start with coding right away
- you can generate many different artifacts: server, clients, documentation etc.
- it is very popular, so you can find lots of examples on the Internet
Downsides:
- you kinda play russian roulette with the spec output, since it is generated from the code, you can't really call it stable
- it is more code to maintain, increasing your cognitive load and compile times (especially in Scala due to many implicits and macros)
- bundle size is increased, since you have to include the dependencies
- sometimes you have to fight the DSL to get what you want (e.g polymorphic payloads and such), and most of the time you repeat 99% of the same code as for JSON codecs for example
- you are limited by what the DSL provides, if you need some framework-specific feature, it's challenging to get to it (or impossible). In this sense code-first approach feels like using an ORM, and API-first like using SQL directly.
API-first Approach#
In the "API-first" approach you start with the API specification. You define the API usually as an OpenAPI (Swagger) spec in a YAML or JSON format. Then you generate the server from that spec (and client code etc).
This approach has some advantages:
- you are in full control of the spec, it is the source of truth
- spec is stable and versioned
- faster collaboration between frontend and backend:
- you can quickly update the spec
- frontend devs can make an openapi example of what they need
- less code to maintain, there is no DSLs or annotations that obfuscate your code
- you can leverage your framework's features to the max
Disadvantages:
- you need to write the spec, it is lots of boilerplate (there are some UI tools and IDE plugins that help)
- the spec file can get huge, but OpenApi supports splitting it into multiple files
Several tools exist to facilitate the API-first approach in Scala, such as:
- various openapi-generator plugins
- Guardrail from Twilio
- OpenApi4s
OpenApi Generator Plugins#
This project supports generating many different server frameworks: Akka, Finch,
Lagom, Play, Cask, Scalatra.
The main painpoint is that it is one-shot, meaning if you introduce any changes,
the generator would overwrite your code.
This is a good starting point for some smaller projects.
Guardrail#
Guardrail generates server boilerplate code to the
target/.../src_managed/main
folder (you don't commit this to git). Then you
implement the server logic by overriding those abstract definitions, fill in
the blanks essentially. Note that when you change the openapi.json
, the
src_managed
code is overwritten.
This approach is good, the main problem I see is that the generated code is hidden from your eyes and git. Also, it doesn't support Scala 3 yet.
OpenApi4s#
A new kid on the block is my OpenApi4s
tool. It takes a bit different approach than Guardrail.
OpenApi4s doesn't hide the code from you, it generates it directly in your
src
folder (that you commit to git).
Yes, you read that right. Exactly like you would have written it by hand.
So how does it handle changes in the spec?
You might try to guess:
- overwrites the code every time? that would be pointless
- some AI tool doing it behind the scenes? hmm
Answer is: no, and no, so let's see how it actually works...
TLDR: OpenApi4s refactors your code
automatically, by using regenesca
diff+merge library.
Regenesca uses Scalameta to parse the Scala code and
do the diffing and patching with precision.
Generating the models and controllers is the easy part:
- parse the OpenAPI spec
- generate models
- generate controllers
and that's it.
The hard part is how to handle changes in the spec.
Consider the classical PetStore spec.
The following examples use the Sharaf framework.
OpenApi4s will generate this for the User
model:
case class User(
id: Option[Long],
username: Option[String],
firstName: Option[String],
lastName: Option[String],
email: Option[String],
password: Option[String],
phone: Option[String],
userStatus: Option[Int]
) derives JsonRW
Adding a New Model Property#
Let's say you add a new property to the User
model: age
with type integer
.
OpenApi4s will compare the newly generated case class User(..)
(in-memory),
with the existing case class User(..)
in your existing source code. Then it
will figure out that age: Int
parameter is missing, and it will add it.
Here is the git diff you would see:
--- a/api/src/com/example/petstore/api/models/User.scala
+++ b/api/src/com/example/petstore/api/models/User.scala
@@ -14,5 +14,5 @@ case class User(
email: Option[String],
password: Option[String],
phone: Option[String],
- userStatus: Option[Int]
+ userStatus: Option[Int], age: Long
) derives JsonRW
Changing a Model Property#
Let's say you change the userStatus
's format from int32
to int64
.
OpenApi4s will figure out that userStatus: Option[Int]
needs to be changed to
userStatus: Option[Long]
.
--- a/api/src/com/example/petstore/api/models/User.scala
+++ b/api/src/com/example/petstore/api/models/User.scala
@@ -14,5 +14,6 @@ case class User(
email: Option[String],
password: Option[String],
phone: Option[String],
- userStatus: Option[Int]
+ userStatus: Option[Long]
Adding a New Endpoint#
Adding a new endpoint to existing controller is easy too. It will just add
another case
to your existing routes:
case GET -> Path("user", "new-endpoint") =>
Response.withStatus(StatusCodes.NOT_IMPLEMENTED)
It will even generate a boilerplate implementation for you. You can then fill in the blanks.
Changing the Route Implementation#
What happens when you change the body of a route? Now this is a bit more tricky.
OpenApi4s must not touch your existing code, since you might have already
implemented some logic. So it will not touch your existing expressions, for
example Response.withStatus(..)
in the previous example.
But it will update the query parameters for example (if needed). For example if you add query params to an existing endpoint it would add this snippet:
case class QP(country: String) derives QueryStringRW
val qp = Request.current.queryParamsValidated[QP]
Removing a Model or Endpoint#
When you remove a model or endpoint from the OpenApi spec.. OpenApi4s will do ..
nothing!
This is because sometimes you have endpoints that should not be exposed in the
openapi spec (e.g. payment webhooks and similar).
That's why OpenApi4s does not remove any code.
You are responsible to remove it, if you really want to do that. This is a limitation, or a feature, depends on the perspective.
CI#
Preventing Accidental Overwrites#
You might be cautious about the changes that OpenApi4s makes. Thinking, will it overwrite my code? How can I make sure it doesn't? Here is a useful check you can do in your CI pipeline:
- change the
openapi.json
file, just so thatmill
build tool detects a change and triggers OpenApi4s - compile the code, to regenerate the files
- see if there are any changes in the git diff
Example:
echo " " >> api/resources/openapi.json # append a space at end of openapi.json
./mill api.compile # trigger OpenApi4s
truncate -s -1 api/resources/openapi.json # remove the space at end of openapi.json
git diff --exit-code # check if there are any accidental diffs
[ $$? -eq 0 ] || exit 1 # abort if there are
Preventing Breaking Changes#
Since the openapi.json
is now in git
, you can do some cool stuff with it.
One very useful check is preventing breaking changes. You can do it with
openapi-diff for example:
# copy the openapi.json from main branch to a temp file
git show origin/main:api/resources/public/openapi.json > main_openapi.json
# compare them and exit with error if there are breaking changes
cs launch org.openapitools.openapidiff:openapi-diff-cli:2.1.0-beta.12 -M org.openapitools.openapidiff.cli.Main -- --fail-on-incompatible main_openapi.json ./api/resources/public/openapi.json
[ $$? -eq 0 ] || exit 1
This example is using Coursier for launching a JVM
app.
But you could use any other CLI tool you find useful.
See the CI script in the openapi4s demo repo.
Here is an example of
compatible change PR.
And an example of
breaking change PR, the CI
build fails of course.
Conclusion#
OpenApi4s uses a different approach to generating code.
All new ideas feel strange when you hear them.
But in the era of AI tools that almost "randomly" refactor your code and
hallucinate, using OpenApi4s feels much more robust, straightforward and
predictable.
Hope you find this post (and the OpenApi4s tool) useful.
You can find more tools to combine with the API-first approach at
https://openapi.tools/
Check out the OpenApi4s
demo repository and try it out in
your next Scala API project!
There is also a video demo on
YouTube.
Additional Resources#
- Understanding the API-First Approach
- Guardrail
- Tapir
- Documenting a Spring REST API Using OpenAPI 3.0
Sakib Hadžiavdić, 11. April 2025, Sarajevo