For a few years now, in my spare time, I’ve been working on NuCorder : a platform that lets musicians rehearse online together with low-latency constraints. Founded by three musicians, I’ve been responsible for all the technical development and infrastructure from day one.

The stack evolved naturally over time β€” from a Java / Spring + Struts monolith to Spring Boot, Docker, managed Kubernetes, and a gradual shift toward micro-services. Like many developers, I spent years trying to apply patterns I’d read about or experienced in high-traffic environments. It took me a while to accept that the best architecture is the one that fits your actual project β€” not the one that looks impressive on paper.

It was in that context that an accumulation of problems eventually made migration unavoidable.

Illustration of the Java to Go migration
AI-generated illustration (ChatGPT / DALLΒ·E).

Small problems, daily stress

On a side project managed outside of work hours, every friction point costs twice as much.

The CI build had become one of the worst offenders: up to 30 minutes per pipeline, tests included. On an evening or a weekend, that’s a significant chunk of mental bandwidth spent watching a progress bar. I’d started skipping tests on the main branch to work around it β€” trading resilience for speed, and gradually building bad habits. On top of that, I was dependent on GitLab CI’s free tier: at the end of the month, I’d be counting remaining minutes and falling back to a local runner on a slower machine.

Spring Boot’s boot time on small Kubernetes nodes β€” a deliberate choice to keep costs down β€” created another headache. Two instances could try to start in parallel, saturate the CPU during initialization, and end up getting killed by the readiness timeout. Scaleway node maintenance triggered the same unscheduled restarts, sometimes at the worst possible moments. The user impact was limited, but for me it meant recurring alerts going off at inconvenient times.

Then there was the growing fragmentation of the codebase: I’d started experimenting with a few Go micro-services alongside the Java backend, with the predictable result of an architecture that was harder to maintain alone β€” precisely the opposite of what I was after.

The turning point

The decision to migrate had been brewing for months. I had a plan in mind, but not yet the confidence that I’d actually go through with it β€” alone, on spare time, with a codebase I knew inside out but that still represented years of work.

Working on a frontend rewrite changed things. I was using generative AI to speed up parts of the work, and I quickly realized the same approach could apply to the backend. Not as a tool that writes code for me β€” but as a collaborator capable of carrying part of the mechanical load of migration, while I kept control over decisions and validation.

What the AI actually did

The first step was resisting the urge to dive straight in. Before writing a single line of Go, I used Claude to audit the existing Java backend and produce a structured migration plan β€” domain by domain, step by step. I learned this lesson quickly: give a language model too large a task and the output becomes inconsistent. A detailed plan with short, verifiable steps works far better.

Before starting the migration itself, I wrote initial architecture documentation in Markdown β€” a format readable by both humans and models. But what really made the difference was how this documentation evolved: incrementally, as the work progressed. Each time we solved a problem or tackled a new domain, I’d create or update the relevant documentation file and reference it in the index. The documentation became a living artifact of the work, not a static prerequisite.

Here’s what the final structure looks like:

β”œβ”€β”€ brevo-contact-updates.md
β”œβ”€β”€ crons.md
β”œβ”€β”€ database-objects.md
β”œβ”€β”€ domain-layouts.md
β”œβ”€β”€ handlers.md
β”œβ”€β”€ i18n.md
β”œβ”€β”€ k8s-deployment.md
β”œβ”€β”€ pitfalls.md
β”œβ”€β”€ redis-cache.md
β”œβ”€β”€ sql-migrations.md
β”œβ”€β”€ storage.md
β”œβ”€β”€ swagger.md
└── testing.md

The CLAUDE.md file at the root serves as a quick orientation guide for the model β€” server entry point, workflow rules, and a table indicating which file to read depending on the task:

markdown
# CLAUDE.md - nuapp Go Internals Guide

## Quick orientation

- Go server entrypoint: `cmd/server/main.go`
- Core API code: `internal/domain/<domain>/`
- All API routes are served by Go.
- Request locale is resolved centrally by `internal/middleware/locale.go` + `internal/i18n/`.

## Core workflow rules

1. Read only the relevant handler/schema context before coding β€” don't explore broadly first.
2. Use `rtk` for commands.
3. After each handler/store change, run `go test ./...` (or `rtk go test ./...`).
4. Keep changes small and shippable.
5. Keep stores domain-owned (no cross-domain SQL in consumer stores).

## Reference docs

Before working on a task, read the doc files relevant to what you're about to touch.

| Doc | Read when… |
|-----|------------|
| `docs/domain-layouts.md` | adding or modifying a domain package |
| `docs/handlers.md` | writing or modifying handlers, routes, or services |
| `docs/storage.md` | adding or modifying stores, SQL queries, or persistence-facing interfaces |
| `docs/i18n.md` | adding or modifying translated messages |
| `docs/testing.md` | writing or modifying tests |
| `docs/pitfalls.md` | working with DB columns or error handling |
| `docs/database-objects.md` | working with DB schema or unfamiliar tables |
| `docs/sql-migrations.md` | creating or modifying SQL migrations |
| `docs/redis-cache.md` | using Redis or the coordinator pattern |
| `docs/crons.md` | adding or modifying cron jobs |
| `docs/brevo-contact-updates.md` | pushing Brevo contact attributes from a domain |
| `docs/swagger.md` | updating API documentation |
| `docs/k8s-deployment.md` | deploying or modifying Kubernetes config |

In practice, this prevents the model from wandering through the codebase before understanding what it needs to do.

Worth noting: Codex uses an AGENTS.md file as its equivalent entry point. In my case, it simply sources CLAUDE.md, avoiding the need to maintain two separate sets of documentation.

The migration itself alternated between Claude Code and Codex, depending on which session limit I’d hit β€” a practical constraint that turned out to be useful, since it forced me to work in coherent blocks. The Java codebase served as an explicit reference at every step. A tracking document let me know exactly where to resume after each session. For review, I systematically used whichever tool hadn’t produced the code to read what the other had written β€” a form of cross-model review that proved effective at catching the most obvious inconsistencies.

What worked least well: trying to move too fast and taking on too much at once. Sessions with overly broad scope produced code that looked functional but was stylistically inconsistent across domains β€” which required a full second pass of standardization.

One week of migration, one week of cleanup

The backend migration took about a week of intensive work. But finishing the migration didn’t mean having a clean backend β€” far from it. I had functional code, but produced across different sessions, with different models, on different days. The result was predictable: style inconsistencies, slightly different approaches across domains, a few shortcuts taken along the way.

The second week was spent on careful review, tackling issues by theme rather than domain: security, behavioral conformance against the Java reference backend, style standardization, and modular structure. I built tests in parallel to catch regressions, again relying on AI to speed up the writing.

A few regressions did slip through β€” the kind of subtleties that only show up in a real environment, not in tests. Null value handling in certain payloads is a good example: behavior that looked identical on the surface but differed slightly in edge cases. I fixed them using the same approach as the migration itself β€” plan, execute, review.

What this phase confirmed: even with good documentation and a structured workflow, a dedicated standardization phase is unavoidable. Better to plan for it upfront than discover it at the end.

What actually changed

The most immediate result β€” and the one that has made the biggest difference day to day β€” is CI build time. A pipeline that used to take between 15 and 30 minutes now runs in around 5 minutes, tests included. On a side project, that’s the difference between running a build without hesitation, no longer gaming the system to avoid waiting, and getting back into good habits.

Boot time is now near-instant. No more readiness probe timeouts, no more alerts firing during Scaleway maintenance windows, no more two instances stepping on each other at startup. The cluster is more stable, and I sleep better.

Infrastructure costs have improved β€” Go uses significantly less CPU and RAM than Spring Boot under equivalent load. In practice, some of that gain was reallocated toward better Kubernetes nodes rather than direct savings, but that’s a trade-off I couldn’t have made before.

Finally, the codebase is unified again. The experimental Go micro-services were absorbed into the modular monolith, and I now maintain a single backend in production β€” simpler to evolve, simpler to debug, simpler to operate alone.

The Go backend has been running in production for several weeks. There are still small things to polish, as with any project, but the migration is a success.

What I take away

Generative AI let me complete in a few weeks a migration I would have kept putting off indefinitely. But it didn’t do the work for me β€” and that’s worth saying clearly.

What made this migration possible was first and foremost a thorough knowledge of the source code. I knew this backend in detail: its quirks, its edge cases, its expected behavior. Without that, I couldn’t have validated what the AI produced, or caught the subtle regressions that only appear in production. AI amplifies what you already know β€” it doesn’t fill in what you don’t.

A few principles I’d keep for a similar migration:

  • Document before you migrate, not during. Markdown documentation, built incrementally and referenced in an index file, dramatically improves session coherence.
  • Work in small steps, verify after each one. Overly broad sessions produce inconsistent code. Granularity is a discipline, not a constraint.
  • Plan for a dedicated standardization phase. Code produced across multiple sessions will inevitably be heterogeneous β€” that’s expected, just anticipate it.
  • A tracking document is essential. It lets you resume work without losing context between sessions, and keeps the big picture visible throughout.

What AI gave me, ultimately, was time β€” time to do something I would otherwise have kept postponing. The rest was craftsmanship.

Working on something similar?

If you’re considering a comparable migration, if your technical stack is starting to weigh on your ability to ship, or if this account gave you ideas, contact me!

Let's talk