The 2024-2025 industry reversal on microservices is real. Amazon, Shopify, and DHH have all published about consolidating distributed systems back into single deployables. This is the concrete migration playbook -- service graph analysis, network boundary elimination, database schema merging, and module boundary enforcement -- from an architect who has run both sides.

There's a moment in every distributed system where you stop feeling clever and start feeling tired. Your order service needs to know if a user has a valid subscription before processing a checkout — so it calls the user service, which calls the entitlement service, which times out under load, which causes the order service to return a 500, which surfaces as 'Something went wrong' to a user who was about to spend $200. That's not a microservices problem. That's an over-segmented system that turned a simple lookup into a distributed transaction.

Amazon, Shopify, and DHH published about this in 2023-2024. Prime Video's team wrote about collapsing a distributed video monitoring pipeline into a single process — costs dropped 90%, latency improved. Shopify documented managing over a million lines of Ruby in a single deployable while maintaining clear ownership boundaries. DHH's Basecamp and Hey are both modular Rails monoliths. This isn't a backlash to microservices. It's architects recognizing that the unit of deployment is not the same thing as the unit of ownership.

Why microservices fail at the wrong scale

The problem isn't microservices as a concept. The problem is teams running the operational complexity of microservices without the scale that justifies it. Each service boundary introduces: independent deployment pipelines, independent observability stacks, network call latency and failure modes, cross-service schema coordination, service discovery and load balancing, and distributed tracing just to understand what a single request did. That list is worth carrying when you have 300 engineers and services that genuinely need independent scaling. It's a tax that kills teams of 10.

// A checkout request before consolidation: // 1. API Gateway -> Order Service (HTTP) // 2. Order Service -> User Service: GET /users/:id/entitlements (HTTP) // 3. User Service -> Entitlement Service: GET /entitlements?userId= (HTTP) // 4. Order Service -> Inventory Service: POST /inventory/reserve (HTTP) // 5. Order Service -> Pricing Service: GET /pricing/calculate (HTTP) // 6. Order Service -> Notification Service: POST /notifications/send (HTTP, async) // // 6 network hops. 6 places to fail. 6 separate trace IDs to correlate. // Average p99 latency: 1.2s -- not from compute, from the round-trips. 

What a modular monolith actually is

A modular monolith is a single deployable process with enforced internal boundaries. The boundaries are real — different teams own different modules, modules don't share database tables across their boundaries, and public interfaces are explicit. But the boundary enforcement happens in code and at build time, not at the network layer. Within the same process, a function call replaces an HTTP call. Within the same database (using separate schemas per module), a direct query replaces an API call for data that genuinely belongs together.

// src/ // modules/ // orders/ // index.ts <- public interface: only exports allowed by this module // OrderService.ts // OrderRepository.ts // types.ts // users/ // index.ts <- public interface // UserService.ts // UserRepository.ts // types.ts // entitlements/ // index.ts // EntitlementService.ts // EntitlementRepository.ts // // The rule: OrderService imports from users/index.ts, NOT users/UserRepository.ts // Direct DB cross-module access is banned by lint rule or review convention 

The migration playbook — six phases

Phase 1: Map your service graph and find the candidates

Not everything should be consolidated. The goal is to find synchronous request-response pairs that share a domain — services that are chatty with each other, always deploy together, owned by the same team, and that don't have fundamentally different scaling profiles. Services that process background jobs at different throughput from your main request path, or that are isolated by compliance requirements, stay separate.

# Pull call frequency between services from your access logs or APM. # If you use Datadog or Honeycomb, query via their API. # For raw nginx access logs -- adjust fields for your log format: awk '{print $1, $7}' /var/log/nginx/access.log \ | sort | uniq -c | sort -rn | head -20 # The pairs at the top are your consolidation candidates. # A service pair that generates 10,000 intra-service calls per day # but only 500 calls from actual clients is a domain artificially # split across a network boundary. 

Look for pairs where more than 80% of calls go to each other rather than to external consumers, where the same team owns both services, where services fail together (check your PagerDuty history — services that always page together are already coupled in production), and where neither service has had an independent deployment in the last 90 days.

Phase 2: Eliminate the network boundary — synchronous pairs first

This is the structural change. You're replacing an HTTP call with a direct function call. The receiving service becomes a module in the same process. Its public API stays the same — you just stop serializing it over the network. Do one pair at a time. The urge to do everything at once is how migrations stall.

// -- BEFORE: HTTP call from OrderService to UserService ---------------------- import axios from 'axios' async function getUserEntitlements(userId: string): Promise<Entitlement[]> { const response = await axios.get( `${USER_SERVICE_URL}/users/${userId}/entitlements`, { headers: { Authorization: `Bearer ${SERVICE_TOKEN}` } } ) return response.data } // -- AFTER: direct module call ----------------------------------------------- import { getUserEntitlements } from '@modules/users' // same-process call // The import replaces the HTTP call. No serialization, no retry logic, // no service token. The function signature is identical -- types travel with it. // OrderService no longer needs to handle UserService being down. 

Phase 3: Merge the databases

This is the step teams are most afraid of. It also has the clearest migration path if you do it schema-first. The goal is not one undifferentiated blob of tables — it's one database server with module-owned schemas. Each module keeps ownership of its tables. Cross-module queries go through the module's public service interface, not raw SQL joins.

-- Step 1: Create module schemas in the target database CREATE SCHEMA IF NOT EXISTS orders; CREATE SCHEMA IF NOT EXISTS users; CREATE SCHEMA IF NOT EXISTS entitlements; -- Step 2: Move tables into their module schemas -- Run this before the service code migration, then update connection strings ALTER TABLE public.orders SET SCHEMA orders; ALTER TABLE public.users SET SCHEMA users; ALTER TABLE public.entitlements SET SCHEMA entitlements; -- Step 3: Confirm no cross-module foreign keys exist -- (They shouldn't -- that's the boundary you're enforcing) SELECT tc.table_schema, tc.table_name, ccu.table_schema AS foreign_schema, ccu.table_name AS foreign_table FROM information_schema.table_constraints tc JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema != ccu.table_schema; -- Any rows here = cross-module FK = needs to become an application-level join 

Run the old and new databases in parallel while you migrate. Write to both, read from the old one until you've validated the new schema under production load. Then flip the read target and decommission the old database. This is the strangler-fig pattern applied to data — no big-bang cutover, no all-hands incident.

Phase 4: Enforce module boundaries in code

The discipline that makes this a modular monolith rather than a big ball of mud is boundary enforcement. If any developer can import any internal class from any module, you'll have a tightly coupled monolith within six months. The boundaries need to be enforced automatically — by tooling, not by hoping developers read the architecture doc.

// src/modules/users/index.ts -- the only file other modules should import from export type { User, Entitlement } from './types' export { getUserById } from './UserService' export { getUserEntitlements } from './EntitlementService' // NOT exported: UserRepository, database connection, internal DTOs // Other modules call getUserById() -- they don't touch the users schema directly // Enforce with an ESLint rule in eslint.config.js: // { // rules: { // "no-restricted-imports": ["error", { // "patterns": [{ // "group": ["*/modules/*/!(index)"], // "message": "Import from the module's public index.ts, not internal files" // }] // }] // } // } 

Phase 5: Consolidate CI/CD and deployment

One of the immediate wins after consolidation is a single build pipeline. You're no longer coordinating deployments across six services, managing independent versioning, or running six separate test suites before a single logical feature can ship. One commit, one test run, one image, one deploy.

# BEFORE: six separate pipeline files, one per service # orders/.github/workflows/deploy.yml -- independent version, independent tests # users/.github/workflows/deploy.yml # entitlements/.github/workflows/deploy.yml # AFTER: one pipeline for the consolidated modular monolith name: Deploy on: push: branches: [main] jobs: build-and-deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install and test run: | npm ci npm test # all modules tested in one pass - name: Build image run: docker build -t app:${{ github.sha }} . - name: Deploy run: | docker push app:${{ github.sha }} kubectl set image deployment/app app=app:${{ github.sha }} 

Phase 6: Verify the contracts held

The risk in consolidation is removing a behavior that external consumers depended on without noticing. Contract tests catch this. Before migration, write a test that captures the exact request and response shape of each service's public API. After consolidation, run the same test against the new module interface. If it passes, the contract held.

// Contract test -- written BEFORE migration, run AFTER to prove the behavior held import { getUserEntitlements } from '@modules/users' describe('getUserEntitlements -- contract', () => { it('returns the same shape as the old /users/:id/entitlements endpoint', async () => { const entitlements = await getUserEntitlements('test-user-1') // These assertions ARE the contract. // They mirror exactly what callers of the old HTTP endpoint depended on. expect(entitlements).toBeInstanceOf(Array) expect(entitlements[0]).toMatchObject({ id: expect.any(String), planId: expect.any(String), expiresAt: expect.any(Date), isActive: expect.any(Boolean), }) }) }) 

What not to consolidate

Three categories should stay separate regardless of call volume. Async background workers with fundamentally different scaling profiles — a batch job that processes 10 million events per hour has different compute requirements from your request-handling web process; they should scale independently. Compliance-isolated workloads — if a service handles PCI-scoped card data or HIPAA-regulated health records, keep it behind its own network boundary; consolidating it pulls the compliance scope into your main service. Services owned by fully separate teams with independent release cadences — the modular monolith works when modules are owned by teams that coordinate; if two teams genuinely never need to talk, a shared deployment forces them to coordinate on every release.

The Amazon Prime Video case is instructive here. They consolidated a specific video monitoring pipeline — a tightly coupled, synchronous processing chain — into a single service. They didn't collapse all of Prime Video into one process. Selective consolidation. The boundary between 'things that belong together' and 'things that just happen to call each other a lot' is the judgment call the playbook can't make for you.

The real win from consolidation isn't performance — though the latency improvements are real. It's operational simplicity. Fewer deployable units, fewer observability stacks to maintain, fewer service tokens to rotate, fewer places for a request to fail. When something goes wrong at 2am, you trace one process, not six. Teams consistently discover this after the migration: the complexity they thought was inevitable was actually a choice.