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:
- Feature Flags: Control exact traffic distribution using percentage rollouts (1%, 5%, 25%, 100%).
- Strict Timeouts: Limit inter-service HTTP requests to 200ms. If the Go service stalls, drop back to the Ruby execution path immediately.
- Circuit Breakers: Automatically flip the feature flag to
falseif 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 Phase | Duration | Team Split (Feature vs Platform) | Key Architectural Milestone | Risk Level |
|---|---|---|---|---|
| Phase 1: Event Infrastructure | Weeks 1–4 | 80% Product / 20% Platform | Setup Kafka/Debezium CDC, export Rails domain schemas | Low |
| Phase 2: First Domain Extraction | Weeks 5–10 | 70% Product / 30% Platform | Go service deployed, CDC data syncing, shadow reads active | Medium |
| Phase 3: Write Switchover | Weeks 11–14 | 70% Product / 30% Platform | Dark launch write switch, Automated failover to Rails active | High |
| Phase 4: Direct Ingress Routing | Weeks 15–18 | 80% Product / 20% Platform | Move route from Rails proxy to Envoy API Gateway | Medium |
| Phase 5: Legacy Code Deletion | Weeks 19–24 | 90% Product / 10% Platform | Drop legacy Postgres tables, remove Rails gems and controllers | Low |
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.xlargeAWS EC2 instances ($0.17 per hour each) to prevent thread pool starvation. Monthly cost: ~$1,224. - Go Microservice Cluster: Requires 2 small
c6i.largeinstances 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.
- Audit your current bottleneck: Identify whether your monolith is bound by database locks, CPU limits, or background job queues.
- Isolate one business domain: Pick a non-critical domain with low database write dependency for your first extraction target.
- Decouple the data layer first: Set up Change Data Capture to run real-time syncs before writing a single line of microservice business logic.
- 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.

