TecLeads TecLeads Blog
2026-07-15 · 10 min read

Modernize the Monolith While It Is Still Running

Hands typing code on a laptop keyboard
software developmentmodernizationapiscloud nativeinternal tools

At 2:17 in the morning, nobody cares that the rewrite branch has a cleaner domain model.

The checkout job is stuck, the queue is backing up, and the one person who understands the old pricing code is trying to remember whether a null currency means USD, unknown, or please do not touch this row. That is the part rewrite plans tend to skip. Legacy systems are not ugly because engineers were lazy. They are ugly because they survived payroll, outages, acquisitions, deadline math, and five different ideas of what the business was supposed to be.

A big-bang rewrite asks you to replace all of that while still understanding all of it. That is why so many rewrites turn into a second system that is almost correct, except for the boring edge cases where the money lives.

Modernization works better when you treat the monolith as a production system, not a moral failure.

The rewrite is usually a requirements discovery project in disguise

Teams often pitch a rewrite as a technical plan. New framework, new database model, new API design, new deployment pipeline, maybe a cloud native platform because the old app still thinks one beefy VM is a personality trait.

Then the work starts and the team finds out the hard part was never the framework. It was the behavior nobody wrote down.

Why does a cancelled subscription still renew if it was cancelled after 5 PM local time? Why does the invoice exporter retry some failures but mark others as final? Why does the admin screen allow support to edit a field the public API rejects? The answers are usually scattered across code, database triggers, cron jobs, support runbooks, and institutional memory.

A rewrite does not remove that complexity. It moves the complexity into meetings.

The better move is to force the old system to reveal itself while you make controlled changes around it. Start by drawing the real boundaries, not the ones in the architecture diagram. Look at tables that change together, jobs that fail together, controllers that share transaction scope, and internal tools that operators use when the public path breaks.

That gives you a modernization map based on operational reality.

Put a wrapper around behavior before you move it

The first useful step is often boring. Put the monolith behind a clearer interface.

If ten services, three scripts, and one internal dashboard all write directly to the same tables, extraction will be miserable. You need a choke point. That might be an HTTP API, a message contract, or a small adapter inside the monolith itself. The point is not elegance. The point is control.

For example, if order creation is currently a controller action that mixes validation, pricing, persistence, and email side effects, do not start by creating an orders-service repository. Start by making the monolith call one internal application boundary.

# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
 def create
 result = Orders::Create.call(
 account_id: current_account.id,
 input: order_params.to_h,
 requested_by: current_user.id
 )

 if result.ok?
 render json: OrderPresenter.new(result.order), status: :created
 else
 render json: { error: result.error_code }, status: :unprocessable_entity
 end
 end
end

That looks small because it is small. It also matters. Now the behavior has a name, inputs, outputs, and a place to test without dragging the whole web stack into every assertion.

This is where good software development earns its keep. You are not polishing the old app for fun. You are creating a stable contract so the next move has less blast radius.

Make the database stop being the integration layer

Most monolith modernization projects are secretly database modernization projects.

The shared database is where shortcuts go to become policy. Reporting reads from production tables. Background jobs update records without using application validation. A warehouse sync depends on column meanings that changed three years ago. Someone added a trigger because it was faster than waiting for the app deployment window.

You do not fix that with Kubernetes.

Start by finding direct writers. Postgres makes this less mysterious than people think. Query logs, pg_stat_statements, application logs, and connection labels can tell you who is touching what. If your apps do not set an application name, fix that first.

ALTER ROLE billing_app SET application_name = 'billing_app';
ALTER ROLE support_tools SET application_name = 'support_tools';

Then watch the traffic.

SELECT application_name, query, calls
FROM pg_stat_statements
WHERE query ILIKE '%orders%'
ORDER BY calls DESC
LIMIT 20;

The goal is to remove surprise writers one at a time. Move writes behind an API. Move reads behind views or read models where that makes sense. For internal tools, resist the urge to let them keep special database access because they are only used by staff. Those tools are production paths. They deserve the same review as customer-facing code.

A clean internal tool that calls a boring, well-tested API is far better than a clever admin console with direct table access and unlimited ways to corrupt state.

Extract capabilities, not nouns

A common failure pattern is noun-driven extraction. The monolith has users, so we create a user service. It has orders, so we create an order service. It has invoices, so we create an invoice service. Soon every request is a committee meeting over HTTP.

Extract capabilities instead.

A capability has a business outcome and a failure mode you can reason about. Password reset is a capability. Tax calculation is a capability. Invoice finalization is a capability. User is usually not. User is a data shape that half the company wants to read and nobody fully owns.

Good API design starts with the workflow and the consistency requirements. If invoice finalization must be atomic with ledger entries, do not split those across services because the nouns look separate on a whiteboard. If product search can tolerate stale data for a few minutes, it may be a good candidate for an event-fed read service.

The question is not, can this be a microservice? The question is, can this fail independently without making the rest of the system lie?

That is the standard worth using.

Use events where they reduce coupling, not where they hide it

Events are useful when consumers need to react to facts that already happened. They are painful when teams use them to avoid deciding who owns a transaction.

A decent first event is boring and explicit.

{
 "event_id": "01J6Z8Y7K2F4R9V0Q3S6T1M5NA",
 "event_type": "invoice.finalized",
 "occurred_at": "2026-07-15T09:41:22Z",
 "invoice_id": "inv_48291",
 "account_id": "acct_1044",
 "schema_version": 1
}

No mystery payload with half the database embedded in it. No consumer reaching back into the monolith for missing fields on every message. Version it. Publish it through an outbox table so database commit and event publication do not drift apart.

CREATE TABLE outbox_events (
 id uuid PRIMARY KEY,
 event_type text NOT NULL,
 payload jsonb NOT NULL,
 created_at timestamptz NOT NULL DEFAULT now(),
 published_at timestamptz
);

This pattern is not glamorous, which is one reason it works. A worker can publish unpublished rows to Kafka, SNS, SQS, RabbitMQ, or whatever fits your platform. The important bit is that a committed business change and the notification about that change have a recoverable relationship.

Cloud native does not mean pretending state disappeared

Moving pieces out of the monolith often coincides with a platform move. Containers, managed databases, queues, service mesh, CI policies, image scanning, secrets management, the whole tool cart.

That can be the right direction. It can also turn a simple failure into a distributed scavenger hunt.

Before a newly extracted service gets production traffic, it needs the dull things: health checks that mean something, request timeouts, structured logs, metrics, trace IDs, dependency dashboards, rollback paths, and database migrations that do not require crossed fingers. Hardened pipelines matter here because modernization creates more deployable units. More deployable units means more chances to ship nonsense faster.

A minimal Kubernetes deployment should at least admit the app can fail.

readinessProbe:
 httpGet:
 path: /ready
 port: 8080
 initialDelaySeconds: 5
 periodSeconds: 10
livenessProbe:
 httpGet:
 path: /live
 port: 8080
 initialDelaySeconds: 20
 periodSeconds: 20

Then make /ready check dependencies needed to serve traffic, not just whether the process is awake and feeling optimistic.

Cloud native engineering is not about scattering a monolith into smaller outages. It is about making change smaller, recovery faster, and ownership clearer.

Strangle slowly, measure honestly

The strangler pattern is popular because it sounds clean. Route a slice of traffic to the new thing, expand the slice, retire the old path. In practice, it is less like surgery and more like plumbing in a building that cannot close.

Use routing you can reverse. Feature flags, gateway rules, or a simple branch in the monolith can all work. Keep the first slice narrow. One tenant. One region. One workflow. One internal user group. Pick something where incorrect behavior will be noticed quickly and corrected without a public incident.

Also compare outputs before you switch authority. For a pricing replacement, run the new calculation in shadow mode and log differences. Do not average them away. Inspect the weird ones. The weird ones are usually the business.

legacy_total=129.90 new_total=129.90 status=match
legacy_total=0.00 new_total=14.99 status=diff reason=trial_coupon_expired

When the new path is boring for long enough, move authority. Then delete the old path. That last step is where teams get squeamish, but leaving both paths alive forever is how modernization becomes archaeology with pager duty.

The people boundary matters too

Technical boundaries fail when ownership is fake.

If a platform team owns the Kubernetes manifests, an application team owns the code, a database team owns migrations, and nobody owns the customer-visible workflow, incidents will expose that gap. Modernization should make ownership easier to state.

For each extracted capability, decide who owns the API contract, who approves schema changes, who responds when it fails, and who can say no to a shortcut. Write it down in the repo, not in a slide deck nobody opens during an incident.

This is also where product and platform engineering meet. Building cloud-native apps, APIs, and internal tooling is not just feature work. The pipelines, permissions, tests, and observability are part of the product because they decide how safely the product can change.

If you only do one thing this week

Pick one painful workflow in the monolith and put a named application boundary around it.

Do not extract it yet. Do not create three new repositories. Do not hold a two-hour naming meeting. Give the workflow one entry point, define its inputs and outputs, add characterization tests around the behavior that already exists, and make callers use that boundary.

That one move will teach you more than another rewrite proposal. It will show where the hidden database writes are, which edge cases matter, which internal tools bypass the normal path, and whether your team can change the system without pretending production is a lab.

Legacy monoliths rarely need a heroic rewrite. They need pressure relief, good contracts, and patient removal of the parts that make every change feel like trespassing.


If this is on your plate, TecLeads does exactly this as part of our Software Development work. If you'd like a second pair of eyes on your setup, book a 30-minute call or explore what we do.

📍 Tech Pulse · today's quick question 🟢 Level: Basic Networking

What does a VPN mainly provide?

Pick an answer to see how other engineers voted.

Want a hand with this?

TecLeads helps engineering teams ship faster and more securely.

Book a 30-minute call

← All posts