DevOps

Splitting a client's monolith into Go microservices

OPS

A client's core application had grown over several years into a large PHP monolith, and by this year every deploy risked breaking an unrelated feature, and any engineer touching the checkout code had to understand half the codebase to do it safely. The monolith was not badly written, exactly — it was the natural result of years of incremental feature work with no single owner responsible for keeping its internal boundaries clean, which is how most monoliths we inherit actually got that way.

Why we did not consider a full rewrite

A full rewrite was on the table early in the conversation and we talked the client out of it quickly. Rewrites of systems this size have a well-documented failure mode: the new system takes far longer than estimated, the old system cannot be meaningfully improved while the rewrite is underway because all the attention is on the new thing, and the business ends up worse off during the transition than if nothing had changed at all. Instead we proposed carving off the specific parts of the monolith causing the most pain, leaving the rest in place and still actively maintained.

What we split out first

  • The inventory and pricing engine, which had the heaviest read load and the most independent release cadence, and which other teams were most often blocked waiting on
  • Notification sending (email, SMS), which had no business logic dependency on the rest of the app and was one of the easiest boundaries to draw cleanly
  • A new recommendations service, built from scratch rather than extracted, since nothing like it existed yet and greenfield code gave us a chance to establish clean patterns before extracting anything else

Why Go

We chose Go for the extracted services over sticking with PHP or moving to Node, mainly for the combination of a small static binary that is trivial to deploy, genuinely good concurrency primitives for the inventory service's heavier read fan-out, and a standard library that covers most of what a small service needs without a large dependency tree. A secondary factor mattered more than we expected going in: Go's strict compiler and explicit error handling forced a level of discipline in these new services that the original PHP codebase's looser conventions had never enforced, and engineers extracting code found themselves fixing latent bugs simply by being forced to handle error cases the compiler would not let them ignore.

How the extraction actually happened

Each extraction followed roughly the same pattern: identify the narrowest possible slice of functionality with the fewest hidden dependencies on the rest of the monolith, build the new service alongside the old code path rather than replacing it outright, and run both in parallel with the new service's output compared against the old path's behavior before cutting traffic over. This parallel-running phase caught real discrepancies twice — once a subtle rounding difference in the pricing calculation that had existed in the original PHP code for years without anyone noticing, and once a genuine bug we introduced in the Go rewrite around how a discount code interacted with bulk pricing. Neither would have been caught nearly as fast without deliberately running both versions side by side before trusting the new one.

What changed for the team

  • Each service now deploys independently, on its own schedule, without needing to coordinate a release window with every other team touching the monolith
  • The core monolith calls out to these services over a small internal HTTP API rather than in-process function calls, with a thin client library shared across teams so nobody hand-rolls their own HTTP client against these internal APIs
  • On-call incidents became easier to scope, since a service boundary usually tells you where to look first, rather than every incident starting with "which of the dozen things this monolith does could be responsible"

What we would tell another team considering the same move

The temptation with any monolith-splitting project is to keep going once the first extraction succeeds, chasing the satisfaction of pulling apart more and more of the system. We deliberately capped this project at the three services listed above for this year, specifically because each new service boundary is also a new thing to operate, monitor, and keep backward compatible, and there is a real point past which more services means more coordination overhead rather than less. Knowing where that point is for a given team's size and operational maturity matters more than any specific technology choice in this whole project.

The result so far

The monolith is still the majority of the application and will be for a long time — this was not a rewrite, it was carving off the pieces that most needed independence. That measured approach is, we think, most of why it succeeded where a full rewrite likely would have stalled. Deploy frequency on the extracted services has gone up several times over compared to how often that same code could safely ship as part of the monolith, and the team members who own them report far more confidence making changes now that the blast radius of a mistake is a single service rather than the whole application.

Testing strategy for the extracted services

Extracting a service is only as trustworthy as the tests that back it, and the original monolith's test coverage around the code we were extracting was, honestly, thin in places — a common reality for code that has been in production for years and mostly just worked. Before extracting the pricing engine specifically, we spent a week writing characterization tests against the existing PHP behavior, deliberately capturing what the system actually did rather than what anyone assumed it did, since a couple of edge cases around bulk discounts turned out to behave in ways nobody currently on the team could fully explain from memory alone. Those characterization tests became the acceptance criteria for the Go rewrite, and catching a real behavioral difference before it reached production, rather than after, justified the week spent writing them many times over.

How we handled the on-call transition

Splitting ownership of a system across more services also splits on-call responsibility, and we underestimated how much coordination this needed at first. Early on, an incident in the new inventory service was initially triaged by an engineer who assumed, reasonably given the old monolith's structure, that the problem was upstream in the main application, costing real time before the right person was looped in. We fixed this with a simple service-ownership map published internally, listing exactly who owns each service and what its dependencies are, checked and updated as part of every new extraction going forward.

What we would do differently on the next extraction

If we were starting the recommendations service again, we would invest earlier in structured logging and distributed tracing across the boundary between the monolith and the new service, since our first few weeks of running it in production involved more manual log correlation between two separate systems than we would have liked, simply because neither system's logs referenced the other's request in a way that made tracing a single user's request across both systems straightforward. We have since added a shared request ID passed through every internal API call, the same pattern already used elsewhere in our Express projects, specifically to avoid repeating this on future extractions.

A note on hiring and onboarding for Go specifically

Bringing on new engineers who already had production Go experience proved harder than hiring for PHP or Node had been, simply because our own client base and reputation up to this point had been built around those languages. We ended up training two existing PHP engineers into Go rather than hiring externally for the first round of extractions, pairing them closely with an engineer who had prior Go experience from outside client work, which took longer than an external hire might have but meant the resulting services were built by people who also deeply understood the original monolith's business logic they were extracting from. We also budgeted real time for internal Go style guide discussions early on, since a team new to a language tends to develop inconsistent conventions fast, and standardizing on formatting, error-handling patterns, and package layout before writing much code saved us from a costly cleanup pass later. A small but genuinely useful side effect of that style guide work: it doubled as onboarding material for every Go engineer we have brought on since, cutting a new hire's ramp-up time on our specific conventions from what used to be a week of osmosis-based learning to a single afternoon of focused reading followed by a real pull request reviewed against the documented standard.

← Back to the journal

Have a project in mind?
Let’s talk.

Tell us where you are and where you want to go. We'll map the fastest route between the two.

Currently accepting new clients