Migrating a billing system from Rails to Go — process, phases, and what nearly broke us

Migrating a billing system from Rails to Go — process, phases, and what nearly broke us

If you are about to migrate a billing system from Rails to Go, please sit down. Get water. Open a notebook. Read this twice before you write the first line of code.

I am writing this because I keep meeting engineers in the middle of one of these migrations, and they all have the same look. The look says: I thought this was a rewrite of a CRUD app. It is not a rewrite of a CRUD app.

A billing migration is its own kind of project. The code is the easy part. What's hard is preserving correctness across a moving target — invoices in flight, subscriptions mid-renewal, refunds half-processed, webhooks queued for retry — while two systems run side by side for weeks or months. This post is the field guide I wish someone had handed me.

It's deliberately generic. Patterns, not specifics. Any resemblance to a system I or you might have worked on is incidental, and the techniques apply regardless of stack flavor.

Why migrate at all?

Before any process discussion, the project needs an answer to why. "Ruby is slow" is not an answer. Specific, measurable answers I've seen earn the migration:

If you can't write the why on one whiteboard line, you probably should not do this migration yet.

The phase map

Every migration I've worked on or watched has roughly the same five phases. The phase you're tempted to skip is, invariably, the one that bites you.

Phase 0: Understand
Phase 1: Carve a boundary
Phase 2: Shadow (build in parallel, run silent)
Phase 3: Cut over (one slice at a time)
Phase 4: Decommission

The naming is mine. The order is not negotiable.

Phase 0 — Understand the existing system

You do not rewrite a system you do not understand. You especially do not rewrite a billing system you do not understand, because billing is where every weird historical decision your company ever made comes home to live.

The deliverable from Phase 0 is a single document — call it the domain audit — that answers:

That last one is the killer in a Rails-to-Go migration. Rails callbacks are the great hidden coupling of every Rails app. Every after_save :recalculate_balance is a side effect the Go rewrite will need to replicate explicitly, and you won't notice the missing one until a customer's balance is wrong.

Spend a week on this. Maybe two. The cost of skipping it is shipping a bug that issues a refund twice.

Phase 1 — Carve a boundary

You do not rewrite the whole billing system in one shot. You rewrite one capability at a time.

The criteria for the first capability to carve out:

A typical first carve: a read API that returns a customer's invoices, formatted for the customer-facing UI. The data still lives in Rails's database (we'll get to data decoupling). The Go service reads from the same tables, formats responses, and that's it. No writes.

Why start here? Because it forces you to build, on day one:

If those four things don't exist by the end of Phase 1, you don't have infrastructure to do the harder work.

Phase 2 — Shadow

Before you cut a single user over, the Go service runs silently in parallel with Rails for at least a few weeks.

The shape: every request that hits Rails is also dispatched to Go, with a different request ID. Both responses are recorded. A diff job runs over the recorded pairs and produces a report:

2026-06-12  GET /v1/invoices/123
  status: rails=200 go=200
  body  : equal (after canonicalization)

2026-06-12  GET /v1/invoices/124
  status: rails=200 go=200
  body  : DIFFERENT
  diff  :
    -  "total_cents": 12000
    +  "total_cents": 11999

The diff is everything. Every divergence is either a bug in the Go service, or — terrifyingly often — a bug in the Rails service that nobody knew about because the output was never compared against an independent implementation. (You will find both. Budget for it.)

Critical: canonicalize before comparing. JSON field order, floating-point formatting, decimal trailing zeros, timestamp timezones — all of these need to be normalized before a diff. Otherwise every response looks different and you'll dismiss real bugs as noise.

You hold here, in shadow mode, until the diff is clean for every request shape for at least a week. Then you can think about cutting traffic over.

Phase 3 — Cut over, one slice at a time

The cutover is the moment you start serving real production responses from the Go service.

The technique I keep reaching for: a feature flag, scoped by customer. Not "10% of requests to Go" — that's noisy and undebuggable. Instead: "this list of explicitly-enabled customers go to Go; everyone else goes to Rails." Start the list with one customer — yourself. Then a handful of internal accounts. Then opt-in beta customers. Then a percentage, by customer hash, growing weekly.

Why per-customer and not per-request? Because a single customer must see consistent behavior across requests. If their invoice list comes from Go but their invoice detail comes from Rails, and the two have drifted by a cent, the customer sees an arithmetic error.

The thing that makes per-customer cutover work is a routing layer somewhere in front of both services that knows the feature flag:

if billing.IsOnGo(ctx, customerID) {
    return goBilling.Handle(req)
}
return railsBilling.Handle(req)

That layer is the cheapest insurance you'll buy. When something goes wrong on Go, you flip the flag for the affected customer and they're back on Rails before you've finished your second sip of coffee.

Phase 4 — Decommission

Eventually, every customer is on Go. The Rails code path is dead. Now you have to prove it's dead before you delete it.

Tactics:

Treat this phase as a project with its own scope. "We finished cutover, we're done" is how you end up paying for two systems for a year.

Data decoupling — the hard part

The single hardest question in a Rails-to-Go billing migration: who owns the data?

The naive answer is "share the database". It works on day one and becomes a tar pit by month three. Two services writing to the same tables, with subtly different assumptions about callbacks, validations, and indexes, will diverge. The clean answer is "split the database eventually". Both answers are right; the migration is the messy middle.

The four strategies I keep using, roughly in order of how I introduce them:

Strategy 1: Shared database, read-only Go

In Phase 1, the Go service reads directly from the Rails database. It does not write. This is the simplest possible starting point and gives you the entire read API without any data-sync work.

Constraints:

This phase will last longer than you expect.

Strategy 2: Dual write through an anti-corruption layer

When Go starts writing, you don't let it write directly to the shared Rails schema. You introduce an anti-corruption layer (ACL) on the Rails side — a small, explicit Ruby module whose only job is to accept calls from Go and translate them into the Rails idioms. (The pattern itself was coined by Eric Evans in Domain-Driven Design and is often paired with Martin Fowler's Strangler Fig approach.)

module Acl
  module Billing
    def self.create_invoice(attrs)
      ActiveRecord::Base.transaction do
        invoice = Invoice.create!(
          customer: Customer.find(attrs[:customer_id]),
          amount_cents: attrs[:amount_cents],
          # ... mapping continues
        )
        AfterCommitJobs.enqueue_for(invoice) # explicit, not implicit
        invoice
      end
    end
  end
end

The ACL is the seam. Go calls a thin HTTP or gRPC endpoint backed by this Ruby code. The Rails internals (callbacks, observers, validations) are preserved on the Rails side, where they belong. Go doesn't have to know about them.

This is uglier than "direct DB writes" but dramatically safer. The implicit becomes explicit, and the failure modes are surfaceable.

Strategy 3: Outbox + projection

Once the Go service has its own write path, both sides need to stay in sync. The pattern I wrote about for the saga / outbox post applies directly here.

Each side writes to its own database in a single local transaction, and writes an outbox row describing the change. A relay reads the outbox and publishes events. The other side subscribes and updates its projection of the data.

┌─────────── Rails ──────────┐    ┌──────────── Go ────────────┐
│  BEGIN                     │    │  BEGIN                     │
│    UPDATE subscriptions    │    │    UPDATE invoices         │
│    INSERT outbox(...)      │    │    INSERT outbox(...)      │
│  COMMIT                    │    │  COMMIT                    │
└─────────┬──────────────────┘    └─────────┬──────────────────┘
          │                                  │
          └──────────┬───────────────────────┘
                     ▼
                ┌──────────┐
                │  Broker  │
                └─────┬────┘
                      │
            ┌─────────┴─────────┐
            ▼                   ▼
        ┌─────────┐         ┌─────────┐
        │ Rails   │         │   Go    │
        │ updates │         │ updates │
        │ its     │         │ its     │
        │ view    │         │ view    │
        └─────────┘         └─────────┘

The benefit is that neither side reads the other's transactional state directly. Each side has its own consistent local view, refreshed from events. The cost is eventual consistency — there's a window, usually a few seconds, where the two sides disagree.

For billing, the question becomes: which fields are okay to be eventually consistent, and which are not?

The hard work of Phase 2 is mapping each field to that question and acting accordingly.

Strategy 4: Single-writer migration per entity

Eventually each entity belongs to one service. Subscriptions live in Go; legacy refund records live in Rails; the customer record is owned by Go but mirrored to Rails as a read-only projection.

The transition for each entity is its own mini-migration:

  1. Both services write. Go writes via its own path, Rails writes via the ACL.
  2. Backfill historical data into the new owner's schema. Cron-based, batched, restartable. Never lock the source table for the whole backfill.
  3. Compare reads from both sides until they match for some agreed window.
  4. Flip the writer. Now only Go writes. Rails reads from a projection updated by Go's outbox.
  5. Eventually, retire the Rails-side projection. Anyone who needs the data calls Go.

Each entity moves through these stages independently. You will be in the middle of stage 4 for one entity and stage 1 for another, at the same time. That's normal.

Money is not a number

A specific section because this is the single most expensive mistake I've seen.

In Ruby, money commonly lives as a BigDecimal. In Go, the temptation is to use float64. Don't. Ever.

The patterns that have worked:

The first time you bring up the Go service and the integration tests pass, run a reconciliation report against Rails over a month of real data. If even one cent disagrees, find it before you go further. A cent is the smoke; the fire is somewhere in your rounding rules.

Deployment hurdles

The architectural patterns get the most ink, but the deployment hurdles are what make these projects miss their dates.

Schema migrations during cutover

While both Rails and Go run, schema changes need to be additive and backwards-compatible. No dropping columns. No renaming columns. No tightening NOT NULL constraints without a default. Every migration goes through the dance:

  1. Add the new column (nullable, or with a default).
  2. Backfill values.
  3. Deploy both services to read/write the new column.
  4. Only after both are deployed and stable: tighten constraints or drop the old column.

This is true for any rolling deployment, but it's especially true when "both services" run in different languages and one of them might be three weeks behind the other on the new shape.

Background jobs are their own migration

Sidekiq jobs don't magically become Go workers. A typical Rails billing system has dozens of jobs — renewal, dunning, reconciliation, currency conversion, webhook retries, report generation. Each one needs a Go equivalent, and the transition has the same shadow / cutover / decommission shape as the HTTP API.

A trap: jobs that enqueue other jobs. If your Go renewal worker enqueues a Sidekiq dunning job by name, you've coupled the two systems' queues. Untangling that mid-migration is painful. Decide early: do you bridge the queues (Go enqueues via Sidekiq's Redis protocol), or do you migrate both producer and consumer together?

Cron jobs and locks

Anything that runs on a schedule needs a careful handover. You do not want both the Rails renewal cron and the Go renewal cron running on the same day. Pick one. Cut over with a flag. Confirm the old one is disabled before you let the new one run.

Distributed locks help — a row in a cron_locks table with (job_name, run_date) as the primary key, both services acquire-or-skip — but the real solution is "exactly one writer per scheduled job during transition".

Connection pool sizing

Ruby and Go have radically different concurrency models. A Rails app with a pool of 25 DB connections per Puma worker is reasonable. A Go app with the same per-process pool size will exhaust the database under the same load, because Go can have thousands of goroutines waiting on those 25 connections.

Calculate the database's max connections, divide by the number of Go pods, divide again by a safety factor, and that's your db.SetMaxOpenConns. Don't copy the Rails number.

Webhooks in and out

Webhooks deserve special attention because they are the place where a billing migration leaks correctness if you're not careful.

A duplicated outbound webhook means your customer's system receives a "paid" notification twice. They might charge their downstream customer twice. You will get the email.

Rollback strategy

The thing that lets you sleep at night during the cutover is a rollback that takes seconds, not days. Concretely:

The point isn't that you'll need to roll back often. The point is that you can. Without that, every cutover is a heart-attack moment.

Observability parity

Whatever you have on Rails — request logs, error tracking, metrics, tracing — you must have on Go at parity before the first real customer moves. The migration's first weeks will produce surprises, and the only way to debug a surprise on Go without panicking is to have the same instrumentation you'd have on Rails.

Specific things to wire up day one:

Thinking patterns

A few mental moves I keep coming back to.

Migrations are not rewrites. A rewrite is "I'm going to build a better version of this." A migration is "I'm going to move this thing from system A to system B, preserving its current behavior, including the bugs I don't know about." The temptation to "fix the data model along the way" is the temptation that kills the project. Move first. Improve later. They are different projects.

The migration is the project. Two teams I've seen treated the migration as a "while we're here" alongside the year's product roadmap. Both teams failed. A migration of this size is a 6–18 month project that needs ownership, a roadmap, and an end date.

Test invariants, not implementations. Your assertions should be statements like "the sum of an invoice's line items equals the invoice's total, post-tax, in every currency, on every day of every customer's history." That assertion holds for both Rails and Go and it's checkable. "The calculate_total method returns 12.00 for input X" is an implementation test that needs rewriting on the other side and tells you nothing about correctness.

One slice. One customer. One day. When you cut over, cut one slice of one capability for one customer for one day. Watch what happens. Then expand. The instinct to "just enable everyone next Monday" is the same instinct that ships the bug that issues twelve refunds in twelve seconds.

The boring decisions are the load-bearing ones. Money as integers. One writer per entity. Outbox for cross-service events. Feature flags scoped by customer. None of these will impress in a tech talk. All of them will determine whether this migration succeeds.

Lessons that are worth repeating

A handful that are easy to write down and surprisingly hard to internalize:

  1. The migration is its own project. Resource it that way.
  2. Spend a real amount of time on Phase 0. A week is not enough. Two might be.
  3. Shadow before you cut. A diff log is the cheapest insurance you'll ever pay for.
  4. Per-customer feature flags beat per-request percentages. Always.
  5. Money is integers + currency. Never floats. Never floats. Never floats.
  6. One writer per entity, eventually. Get there as fast as the project will allow.
  7. Apply the outbox pattern early. The cost is small. The correctness gain is enormous.
  8. Treat webhooks like a critical section. Exactly one publisher per event. Exactly one consumer per event.
  9. Build observability parity before the first cutover. Not after.
  10. Have a rollback that takes seconds. Or you will be the person paged when it doesn't.

I have left out a lot — currency conversion, tax engines, accounting exports, regulatory holds, the joy of trying to explain dunning to a non-billing engineer. Maybe future posts. For now: if you're about to start one of these, take the phase map, take Phase 0 seriously, and remember that the migration is the project.

Good luck. And mind your cents.

References

Patterns

Rails / Ruby

Go

Related posts

Jeffrey Cheung

Backend Software Engineer at Stransa. Obsessed to learn and build things. Occasionally, I write blogs about neovim related stuffs and articles I read lately.