Spring Boot to Scala and Play on a live site, without losing an email
I have been learning Scala 2 and Play in public on this blog, from a Java and Spring background. The most honest exercise I could find was not another toy project: it was the backend of this site. On 2 October 2026 I replaced its Spring Boot backend with Scala 2.13, Play 3, Anorm and Twirl, in one pull request: 308 files, 6,673 lines added, 15,989 removed.
The rewrite was the easy part. This post is about the part that let me ship it on a live site: deciding what must not change, and proving it before cutover.
What stayed fixed
The backend serves four hosts from one container: the gated dossier, the server-rendered blog, a client-only CV builder and a private kanban board. It brokers sign-in through Keycloak, stores everything in PostgreSQL, sends a durable notification email when a recruiter signs in, and streams answers from an AI concierge. The migration was defined by the contracts the new runtime had to honour:
- HTTP: the same JSON field names (nulls included), the same routes, the same SSE framing for the concierge.
- Schema: Flyway migrations V1 to V6 byte-for-byte unchanged. Hibernate's schema validation became Flyway validation plus an explicit schema-contract check at startup.
- Outbox: the same table, event type, listener id and JSON payload as Spring Modulith.
- Rendering: the same HTML for the blog, case study and emails, now produced by Twirl instead of Thymeleaf.

The application code shrank from 121 Java files (about 8,200 lines) to 40 Scala files (about 2,700 lines) under app/, plus Twirl templates and 24 test files. Part of that is Scala, but most of it is what disappeared: annotations, proxies and framework configuration replaced by plain constructors and explicit SQL.
Wiring without an injector
Spring's dependency injection became a Cake pattern assembly, which I wrote about in Scala's Cake Pattern, One Dependency at a Time. Each feature is a trait that states what it needs through a self-type:
trait AuthComponents { self: InfrastructureComponents =>
lazy val oidcSecurity: OidcSecurity = new OidcSecurity(settings, authCache)
lazy val accessService: AccessService = new AccessService(settings, oidcSecurity.current)
lazy val authSecurityFilter: AuthSecurityFilter = new AuthSecurityFilter(accessService)
}
trait ConciergeComponents { self: InfrastructureComponents with AuthComponents with BlogComponents => ... }
One class, CvNextComponents, mixes all the traits together. If a feature needs something nobody provides, it is a compile error, not a startup failure. The services themselves are ordinary classes with constructor arguments, so a test builds exactly the graph it needs. No Guice injector is created; pac4j still brings a Guice JAR along, but nothing uses it.
Two rules kept the assembly honest: shared resources are lazy vals, and no trait starts background work or reads an abstract member in its constructor. The application loader validates configuration, migrates and validates the schema, and only then starts the jobs.
Not losing an email: the outbox
When a recruiter signs in, the same transaction records the visit and inserts an outbox row; a worker sends the email later and retries on failure. In Spring this was Spring Modulith's event publication table. The Scala outbox writes and reads that exact format:
val EventType = "com.elzakaria.cvnext.visits.RecruiterSignedIn"
val ListenerId = "com.elzakaria.cvnext.notifications.RecruiterSignInMailListener.on(com.elzakaria.cvnext.visits.RecruiterSignedIn)"
The Scala runtime has no Java class with that name; the strings are a wire contract. Keeping them means either runtime can pick up the other's pending rows, which is what makes a rollback safe: a sign-in recorded by Play and not yet emailed can still be emailed by the old Spring image. At startup, the Scala outbox counts pending rows with any other event type or listener id and refuses to start, without deleting anything. Delivery stays at least once, so a duplicate email is possible; a lost one is not.
Proof before cutover

- Tests on real PostgreSQL. The retained Spring baseline passed 240 tests; the Scala build passed 96 across 14 suites. Every suite creates its own database, applies the real Flyway migrations and drops only that database. No H2.
- Rendering parity. The old Thymeleaf classes and the new Twirl classes rendered the same sample models. Eleven fixtures, covering all eleven original templates, matched across 2,184 normalized HTML tree entries. The CV PDF and preview PNG matched byte-for-byte.
- A separate consumer. My analytics agent is its own Java application that reads the database directly. All nine of its queries passed, unchanged, against the migrated schema.
- A sanity load. 2,400 requests without failures under a 1.5 GiB container limit. That verifies the exercised workload, not production capacity.
Rehearsing the rollback on a restored backup
Tests prove behaviour; they do not prove that I can go back. So I restored the full production backup into a disposable container: database roles, the six migrations and all 416 media files. Then I ran both real runtimes against it.

Each runtime made synthetic writes and queued mail, and each consumed the other's outbox rows. Afterwards, every original application row, Flyway history row and Keycloak record was unchanged. External providers were disabled and only fake local credentials were used.
Cutover, and what came later
The cutover itself was deliberately boring: retain the previous image on the box, provision the new Play secret, enter maintenance and stop the old writers so that only one runtime dispatches mail, start Play, run acceptance, reopen. The new session cookie meant everyone had to sign in again, which I accepted.
Two follow-ups are worth recording. A later migration (V8, for the public concierge) made a column required that the original Spring image cannot write, so the image-only rollback stopped applying; I built a V8-compatible rollback image rather than pretending the old one still worked. And the box's Compose file kept Spring-era comments for a week, because CD ships the image and not the Compose file. The code moved in one PR; the operational surface around it took longer.
What I would do the same way
Write the contracts down first, keep the old wire formats as strings rather than classes, and test the way back before you need it. The Scala was the part I wanted to learn. The rehearsal was the part that let me ship it.