Sharing DTOs Between a Scala Backend and a Scala.js Frontend
The most annoying bug class in a typical web app isn’t in the backend or the frontend, it’s in the gap between them. The backend renames a field, the frontend keeps sending the old one, and nothing fails until runtime. JSON is the world’s most popular untyped interface.
The usual fix is to generate code from an OpenAPI spec. It works, but now you have a spec to maintain, a codegen step, and generated code that never quite matches how you’d write it yourself.
There’s a more direct option if you’re willing to go full Scala: make the frontend and the backend literally share the same source code for the API contract. Same case classes, same JSON codecs, compiled twice, once to JVM bytecode, once to JavaScript. This is the setup I landed on for my current side project, using Mill, and it’s the nicest API workflow I’ve ever had.
The Shape of the Build
Three modules: shared, backend, frontend. The shared one is compiled for both platforms:
1object shared extends Module {
2 trait SharedModule extends PlatformScalaModule {
3 def scalaVersion = "3.6.2"
4 def mvnDeps = Seq(mvn"com.lihaoyi::upickle::4.1.0")
5 }
6 object jvm extends SharedModule
7 object js extends SharedModule with ScalaJSModule {
8 def scalaJSVersion = "1.18.1"
9 }
10}
11
12object backend extends ScalaModule {
13 def scalaVersion = "3.6.2"
14 def moduleDeps = Seq(shared.jvm)
15}
16
17object frontend extends ScalaJSModule {
18 def scalaVersion = "3.6.2"
19 def scalaJSVersion = "1.18.1"
20 def moduleDeps = Seq(shared.js)
21}
PlatformScalaModule is the Mill trait that makes shared/src visible to both shared.jvm and shared.js. One folder of source files, two compilation targets. Note the triple :: in the upickle dependency, that’s Mill’s spelling for “pick the artifact matching the platform”, so the JVM module gets the JVM jar and the JS module gets the .sjsir one.
The Contract Is Just Code
The shared module contains the DTOs and their codecs, and nothing else:
1package journal.shared
2
3import upickle.default.*
4
5case class Entry(
6 id: EntryId,
7 title: String,
8 tags: List[String],
9 publishedAt: Long
10) derives ReadWriter
11
12case class CreateEntry(title: String, tags: List[String]) derives ReadWriter
13
14enum ApiError derives ReadWriter:
15 case NotFound(id: EntryId)
16 case Invalid(field: String, message: String)
Scala 3’s derives ReadWriter gives you the uPickle codec at compile time, in the companion object, defined exactly once. The backend serializes with it, the frontend deserializes with it. There is no schema file because the Scala source is the schema.
The routes live in the shared module too, as plain values instead of strings:
1object Routes:
2 val entries = "/api/entries"
3 def entry(id: EntryId) = s"/api/entries/${id.value}"
Not fancy, but it means an endpoint can’t be misspelled on one side only.
Both Sides of the Wire
The backend (Cask here, but this works with anything):
1@cask.postJson("/api/entries")
2def create(body: ujson.Value) =
3 val req = read[CreateEntry](body)
4 write(service.create(req)) // returns an Entry
And the frontend, in Scala.js:
1def createEntry(req: CreateEntry): Future[Entry] =
2 fetch(Routes.entries, method = POST, body = write(req))
3 .map(res => read[Entry](res))
The write on one side and the read on the other are the same function from the same compiled source file. If I rename title to heading in Entry, the backend and the frontend both fail to compile until they agree again. The bug class from the introduction is just… gone. Refactoring an API becomes a normal IDE rename instead of an archaeology project.
What About Evolution?
Compile-time agreement holds within one deploy, but a browser can hold yesterday’s frontend while talking to today’s backend. Sharing code doesn’t exempt you from the usual rules:
- Adding a field: give it a default value in the case class, uPickle then tolerates its absence in old payloads.
- Removing or renaming: that’s a breaking change, same as it ever was. Do the add-migrate-remove dance.
The difference is that within the codebase these changes are visible, the compiler shows you every usage site on both platforms the moment you touch the contract.
The Trade-offs
To be fair about what this costs:
- Both sides must be Scala. This is the big one. The day you want a mobile app or a public API consumed by others, you need a language-neutral contract anyway, and OpenAPI comes back into the picture (libraries like tapir can generate the spec from the Scala definitions, which is a nice middle ground).
- The shared module needs discipline. It’s tempting to put “just one helper” in there, and suddenly your frontend depends on half your domain logic. DTOs, codecs, routes, nothing else.
- Long is a footgun: my publishedAt: Long above works, but JS numbers only have 53 safe integer bits, and uPickle serializes Long as a string by default to protect you. Decide the wire format for timestamps once, early.
Verdict
For a solo full-stack project, this setup removes an entire category of coordination work. The API can’t drift because there’s physically only one definition of it. Combined with Mill making the cross-compilation setup about fifteen lines of build code, this is the first time full-stack type safety has felt cheap to me instead of like a science project.
The frontend side of this app is built with Laminar, which deserves an article of its own, that’s next.
Thanks for reading.