Back to Insights
// // insight

Migrating a Rails Monolith to Go Microservices: Zero-Downtime Architecture, Team Sequencing, and Velocity Con…

Migrating a Ruby on Rails monolith to Go microservices without freezing feature delivery requires the Strangler Fig pattern paired with Change Data Capture (CDC). Keep Rails as your API gateway, extract high-throughput or CPU-bound domains into Go services behind feature flags, and mirror writes using asynchronous event streams. This isolates product development from platform migration, letting product teams ship on Rails while platform engineers slice off microservices.

Published September 3, 2026 · Reviewed by the NextGen engineering team

The Fatal Flaw of the "Pause All Features" Rewrite

Every VP of Engineering has heard the pitch: freeze product feature development for six months, rewrite the codebase in a modern compiled language, and launch a fast microservice architecture.

It fails almost every time.

A feature freeze turns into nine months of dual maintenance. Product managers lose trust because market commitments slip. Meanwhile, the platform team discovers that eight years of implicit Rails domain logic—ActiveRecord hooks, hidden callbacks, raw SQL patches, and gem monkey-patches—were never documented. The greenfield rewrite becomes a moving target because the market did not stand still while you refactored.

The alternative is incremental extraction using the Strangler Fig pattern. You keep the Rails application alive, serving 90% of traffic, while you carve out distinct boundaries into Go services. You change the engine while the plane is flying.

If your team is deciding whether Go or another modern language makes sense for your stack, evaluate your performance bottlenecks before writing code. We documented when compiled languages pay off in our breakdown of whether to rewrite system components in Rust.

Identifying Extraction Boundaries (Stop Carving Up ActiveRecord Models)

The most common migration mistake is splitting services along ActiveRecord database tables. Carving out a UserService simply because you have a users table creates tight coupling, distributed transactions, and network latency loops across HTTP boundaries.

Microservices should follow business domain boundaries, not database schemas.

Look for extraction candidates using three criteria:

  • Resource consumption profiles: Find endpoints choking on memory or CPU bound tasks. Heavy background processing (Sidekiq jobs parsing CSVs, generating PDFs, or processing image transformations) should be extracted to Go services long before primary CRUD APIs.
  • Deployment frequency disparity: Identify modules where product managers request weekly changes versus stable, legacy core components that rarely change.
  • Database IO contention: Find tables suffering from lock contention or high read/write ratios during peak hours.

Instead of pulling out the User model, pull out the Notification Engine or the Billing Calculation Pipeline. These are self-contained domains that process inputs, produce deterministic outputs, and rarely require complex SQL JOIN operations back to the monolith’s primary database.

The Data Migration Strategy (CDC, Dual-Writes, and Eventual Consistency)

You cannot move a Rails domain to a Go microservice until you decouple its data. Sharing a Postgres database between a Rails monolith and a Go microservice is an anti-pattern that causes schema migration locks and hidden domain coupling.

To move data without downtime, use a four-phase data migration workflow powered by Change Data Capture (CDC).

Phase 1: Monolith Writes -> CDC (Debezium/Kafka) -> Go Consumer -> New DB
Phase 2: Shadow Reads    -> Monolith Reads vs. Go Reads (Compare Latency/Errors)
Phase 3: Primary Write   -> Go Service Writes -> CDC Backfill -> Monolith DB (Fallback)
Phase 4: Full Cutover    -> Monolith Database Table Deprecated

1. Enable Change Data Capture (CDC)

Deploy Debezium or AWS Database Migration Service (DMS) to tail Postgres WAL (Write-Ahead Logs). Stream every insert, update, and delete on target tables to a Kafka or AWS Kinesis topic.

2. Stand Up the Go Database

Build the Go service with its own isolated database schema (PostgreSQL or DynamoDB). Write a Go consumer to process the CDC stream and populate the new database asynchronously. Your Go database stays eventually consistent with the Rails monolith in real time without impacting Puma request threads.

3. Implement Shadow Reads

In the Rails API layer, issue dual reads. Fetch the response from the legacy database, make an asynchronous background call to the new Go service endpoint, and log output discrepancies or performance deltas to Datadog or OpenTelemetry. Do not block the client response on the Go service output yet.

// Example: Shadow Read Comparer in Go Service Gateway
package main

import (
	"context"
	"log"
	"net/http"
	"time"
)

type CompareResult struct {
	Match    bool
	Duration time.Duration
	Err      error
}

func ShadowCompare(ctx context.Context, railsUrl string, goUrl string) {
	client := &http.Client{Timeout: 500 * time.Millisecond}

	go func() {
		req, _ := http.NewRequestWithContext(ctx, "GET", goUrl, nil)
		start := time.Now()
		resp, err := client.Do(req)
		elapsed := time.Since(start)

		if err != nil || resp.StatusCode != 200 {
			log.Printf("[SHADOW-MISMATCH] Go service failed: %v, Latency: %s", err, elapsed)
			return
		}
		// Log metrics to collector for verification
	}()
}

4. Switch Write Authority

Once shadow read outputs match for 72 consecutive hours, flip write authority to the Go service using a feature flag. Have the Go service write to its database and emit an event back to Kafka so a legacy consumer updates the Rails database. This gives you a instantaneous rollback option if the Go service encounters edge-case failures under real load.

Architecture for Zero-Downtime Routing and Fallbacks

Do not replace your edge infrastructure on day one. Keep your existing Nginx, AWS ALB, or Cloudflare setup in place, but use Rails itself or an Envoy sidecar as an intelligent proxy layer.

Using Rails as an initial API router lets you leverage existing authentication middleware, session stores, and rate-limiting logic without re-implementing them in Go upfront.

## app/controllers/api/v2/reports_controller.rb
class Api::V2::ReportsController < ApplicationController
  def show
    if Flipper.enabled?(:go_reports_service, current_user)
      begin
        response = GoServiceClient.get_report(params[:id], timeout: 0.2)
        render json: response.body, status: response.status
      rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
        Rails.logger.warn("Go service failed, falling back to Rails: #{e.message}")
        render json: LegacyReportSerializer.new(Report.find(params[:id])).to_json
      end
    else
      render json: LegacyReportSerializer.new(Report.find(params[:id])).to_json
    end
  end
end

This pattern guarantees high availability:

  1. Feature Flags: Control exact traffic distribution using percentage rollouts (1%, 5%, 25%, 100%).
  2. Strict Timeouts: Limit inter-service HTTP requests to 200ms. If the Go service stalls, drop back to the Ruby execution path immediately.
  3. Circuit Breakers: Automatically flip the feature flag to false if the Go service error rate crosses a 1% threshold in a 60-second window.

Once a domain handles 100% of traffic stably for 30 days, move the routing layer out of Rails and up to Envoy or your ingress controller to bypass Ruby execution overhead entirely.

Migration Phasing, Timelines, and Team Allocation

Running a dual-architecture engineering phase requires strict staffing splits. If platform engineers are pulled into product sprint work, the migration drags on indefinitely.

A typical $150k to $400k modernization effort across a 30-person engineering org follows a phased timeline over 6 months:

Migration PhaseDurationTeam Split (Feature vs Platform)Key Architectural MilestoneRisk Level
Phase 1: Event InfrastructureWeeks 1–480% Product / 20% PlatformSetup Kafka/Debezium CDC, export Rails domain schemasLow
Phase 2: First Domain ExtractionWeeks 5–1070% Product / 30% PlatformGo service deployed, CDC data syncing, shadow reads activeMedium
Phase 3: Write SwitchoverWeeks 11–1470% Product / 30% PlatformDark launch write switch, Automated failover to Rails activeHigh
Phase 4: Direct Ingress RoutingWeeks 15–1880% Product / 20% PlatformMove route from Rails proxy to Envoy API GatewayMedium
Phase 5: Legacy Code DeletionWeeks 19–2490% Product / 10% PlatformDrop legacy Postgres tables, remove Rails gems and controllersLow

To maintain product momentum, never assign engineers to both tracks in the same sprint. Feature squads keep building product on the Rails monolith. A dedicated 2-to-3 person platform pod focuses exclusively on service extraction, data streaming pipelines, and infrastructure.

Our specialized engineering teams handle this exact split alongside internal staff. If you need external platform capacity to execute a migration without slowing your roadmap, explore our legacy system modernization engineering services.

Go vs. Rails Runtime Overhead: The Financial Tradeoff

Modernizing from Rails to Go is not just an architectural choice; it directly lowers your monthly infrastructure bill.

Ruby on Rails depends on multi-process concurrency (Puma workers). Each worker loads the full application codebase into RAM, consuming 300MB to 600MB of memory. A high-throughput Rails app requires dozens of large compute instances simply to hold worker processes in memory, even when CPU utilization stays below 15%.

Go relies on lightweight green threads (goroutines). A Go service baseline footprint often sits below 25MB of RAM and can process tens of thousands of concurrent requests on a single vCPU.

Infrastructure Cost Formulas:
Rails Memory Need = (Total Concurrent Requests / Requests Per Worker) * 450MB
Go Memory Need    = Base Service Overhead (20MB) + (Active Goroutines * 4KB)

Consider the infrastructure profile difference for an API receiving 4,000 requests per second:

  • Rails Monolith Cluster: Requires 80 Puma workers spread over 10 c6i.xlarge AWS EC2 instances ($0.17 per hour each) to prevent thread pool starvation. Monthly cost: ~$1,224.
  • Go Microservice Cluster: Requires 2 small c6i.large instances running goroutines under an ALB. Monthly cost: ~$122.

The 90% drop in compute costs pays for the cloud resources spent running Kafka and CDC pipelines during the transition.

What This Means for Your Team

Migrating off a legacy Rails monolith does not mean choosing between technical debt and product progress. By leveraging CDC streams, short-circuited API proxies, and domain isolation, you keep shipping features on your legacy stack while systematically moving throughput-critical pathways to Go.

  1. Audit your current bottleneck: Identify whether your monolith is bound by database locks, CPU limits, or background job queues.
  2. Isolate one business domain: Pick a non-critical domain with low database write dependency for your first extraction target.
  3. Decouple the data layer first: Set up Change Data Capture to run real-time syncs before writing a single line of microservice business logic.
  4. Enforce fallback paths: Never point production user traffic to a new service without automated circuit breakers back to Rails.

If your team is balancing a demanding feature roadmap with a legacy codebase that can no longer scale, you do not have to staff the platform work alone. Contact NextGen Coding Company to review your architecture, run team sequencing math, and deploy senior platform engineers to execute your migration cleanly.

More answers in Insights or see AI development services.

// let's build something

Start your project request

Tell us what you're building — engineering capacity, AI, QA, cloud, or a fixed-scope software engagement. Our NYC team responds within one business day.

// what to expect
  • Response within 1 business day
  • 30-minute discovery conversation
  • Recommended engagement model & pricing
  • NYC-focused — in-person available
Start Project Request

Inbound sales only. All form information is encrypted in transit.