
This post is for tech leads and backend engineers who already run microservices and suspect one of them should not exist. It covers when to kill a microservice, how to merge it back into the monolith without a rewrite, and what the merge actually buys, measured instead of asserted. The bench takes one checkout feature whose pricing lives in a separate service, then runs it chatty, batched and merged. This site has argued for splitting before, in migrating from a monolith to microservices with Spring Boot, and against splitting early, in microservices for a small team. This post handles the case neither covered: the split already happened, and part of it was a mistake.
The numbers below came from this environment:
Measured 2026-11-02
Windows 11 Pro 25H2 (build 26200), AMD Ryzen 5 8600G (6C/12T), 16 GB RAM
Java 18.0.2.1 (HotSpot, default JVM flags), H2 2.5.252 embedded
JDK HttpServer and HttpClient, no framework
All processes and the load generator on one machine, over 127.0.0.1
The bench avoids frameworks for the same reason as the small-team post. Spring Boot or NestJS would add startup and memory costs to each process, so leaving them out keeps the comparison about the boundary rather than about a framework.
When Should You Kill a Microservice?
Kill a microservice when it cannot do useful work without another service, when the two always change and deploy together, and when no team would notice its independence disappearing. In that state the service delivers none of the benefits of distribution while still paying for network calls, separate pipelines and split data. Merging it into a module removes the cost and keeps the boundary.
That definition leaves out two popular reasons. “The service is small” is not a reason, because small services with a clear job are fine. Similarly, “the service is slow” is not enough on its own, since the bench below shows that batching recovers most of the speed without a merge. The strong signals are about coupling, not size or speed.
Signals That a Service Should Be Merged Back
Most of these signals can be checked with data the team already has. Consequently, the decision does not need to start as an architecture debate. It can start as a query against the deploy log, the tracing backend and the git history.
| Signal | How to check it | What it usually means |
|---|---|---|
| Lockstep deploys | Count releases of service A that shipped within a day of a release of B | One logical component split across two pipelines |
| Chatty calls | Calls from A to B per inbound request, from tracing | The boundary cuts through a loop |
| Shared database or tables | Grep both services for the same table names | A distributed monolith with an extra hop |
| Changes that span both repos | Pull requests or tickets linked to both | One feature, two codebases |
| No independent consumer | List every caller of B from tracing or gateway logs | B exists only to serve A |
| Orphaned ownership | The owning team was reorganized or left | Nobody can safely change it alone |
| Distributed transaction code | Sagas or compensations between exactly two services | Atomicity rebuilt by hand |
A single signal rarely settles the question. Lockstep deploys alone may only mean a shared release train. However, lockstep deploys plus chatty calls plus no other consumer describe a service that is really a module in a separate process. That combination is the clearest case, and it is the one the bench reproduces.
Notably, the Spring Boot migration post on this site lists independent deployments, team autonomy and fault isolation as the reasons to split. Each signal in the table is the observable absence of one of those benefits. When none of them is present for a given service, the original reason for splitting it is gone.
Kill, Batch or Keep: The Three Options
Once a service shows these signals, the team has three moves, and only one of them is a merge:
- Keep it and fix the call pattern. Replace per-item calls with a batch endpoint, add caching, or move the call off the request path. This keeps the deployable and fixes the latency.
- Merge it back as a module. Move the code into the caller’s process behind the same interface, move its data into a schema the module owns, and delete the service.
- Keep it as it is. If the coupling is real but cheap, and the service has an owner who values deploying it separately, the cost of migrating can exceed the cost of living with it.
The usual articles about merging back discuss option two in isolation. That framing is misleading, because option one is often cheaper and fixes the symptom people notice first. The bench therefore measures options one and two against the original chatty version.
The Bench: One Checkout, Three Pricing Adapters
The feature is checkout pricing. A checkout request carries a cart of N lines, each with a SKU and a quantity. Pricing owns a table of 1,000 prices and a table of volume discounts, and returns a total per line. Checkout sums the lines and writes an order row.
In the starting state, pricing is a separate service with its own database. Checkout calls it once per cart line, which is the shape a per-item endpoint invites. The same codebase also runs two other ways, chosen by one system property, so all three variants share every line except the adapter.
The Seam the Merge Happens Behind
Everything in checkout talks to one interface. This is Martin Fowler’s branch by abstraction, and it is the single most useful step in any merge, because it turns the cutover into a configuration change.
package checkout;
import java.util.List;
// The seam the merge happens behind. Checkout only ever talks to this interface,
// so swapping the remote adapter for the in-process one is a one-line config change.
public interface Pricing {
record Line(String sku, int qty) {}
final class PricingUnavailable extends Exception {
public PricingUnavailable(String message, Throwable cause) {
super(message, cause);
}
}
// Line totals in cents, in the same order as lines.
long[] quote(List<Line> lines) throws PricingUnavailable;
}
The interface takes the whole cart even though the chatty adapter loops over it. That choice matters: an interface shaped like the remote endpoint, one SKU at a time, would lock the chattiness into every caller and make batching a breaking change.
The Remote Adapter, Chatty and Batched
The remote adapter has two modes. Chatty mode sends one GET /quote per line, while batched mode sends the cart in one POST /quotes. This is an excerpt; the full class also builds the batch body and parses the comma-separated response.
// HttpPricing.java (excerpt)
private long[] quoteEach(List<Line> lines) throws PricingUnavailable {
long[] out = new long[lines.size()];
for (int i = 0; i < lines.size(); i++) {
Line l = lines.get(i);
String url = baseUrl + "/quote?sku=" + URLEncoder.encode(l.sku(), StandardCharsets.UTF_8)
+ "&qty=" + l.qty();
out[i] = Long.parseLong(send(HttpRequest.newBuilder(URI.create(url)).GET()));
}
return out;
}
private String send(HttpRequest.Builder request) throws PricingUnavailable {
try {
HttpResponse<String> res = http.send(request.timeout(timeout).build(),
HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) {
throw new PricingUnavailable("pricing returned " + res.statusCode(), null);
}
return res.body();
} catch (IOException e) { // includes HttpTimeoutException
throw new PricingUnavailable("pricing call failed: " + e.getClass().getSimpleName(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new PricingUnavailable("interrupted", e);
}
}
Each call has a one-second timeout, and any failure becomes PricingUnavailable, which checkout turns into a 503. As a result, the caller sees one error type whichever adapter is active.
The In-Process Adapter and the One-Line Switch
The merged adapter wraps PricingCore, the class that holds the pricing queries. That class is the same file the pricing service runs; the merge moves it, it does not rewrite it.
package checkout;
import java.sql.SQLException;
import java.util.List;
import pricing.PricingCore;
// In-process adapter: the merged state. Same interface, no network.
public final class LocalPricing implements Pricing {
private final PricingCore core;
public LocalPricing(PricingCore core) {
this.core = core;
}
@Override
public long[] quote(List<Line> lines) throws PricingUnavailable {
long[] out = new long[lines.size()];
try {
for (int i = 0; i < lines.size(); i++) {
out[i] = core.quote(lines.get(i).sku(), lines.get(i).qty());
}
} catch (SQLException e) {
throw new PricingUnavailable("pricing query failed", e);
}
return out;
}
}
Checkout picks the adapter at startup. In the merged mode, the pricing tables live in a pricing schema inside checkout’s own database, which keeps the module’s data separate even though the process is shared.
// CheckoutApp.java (excerpt). The only line that changes during the merge.
Pricing pricing = switch (mode) {
case "local" -> {
PricingCore core = new PricingCore(pool); // pricing schema now lives in this database
core.seed();
yield new LocalPricing(core);
}
case "http" -> new HttpPricing(System.getProperty("pricingUrl", "http://127.0.0.1:8091"),
Duration.ofMillis(Long.getLong("timeoutMs", 1000)), false);
case "http-batch" -> new HttpPricing(System.getProperty("pricingUrl", "http://127.0.0.1:8091"),
Duration.ofMillis(Long.getLong("timeoutMs", 1000)), true);
default -> throw new IllegalArgumentException("pricing=" + mode);
};
In a production merge, this switch would read a feature flag instead of a system property, so the cutover can roll out per environment and roll back without a deploy.
How the Bench Was Run
A load generator built on the JDK’s HttpClient posts checkouts with deterministic carts, so every variant prices exactly the same lines. It reports p50, p95 and p99 latency, a status-code count, and a checksum of the returned totals. Matching checksums prove the adapters agree on every price, which is the same check a real merge needs before cutover. This script drives the runs:
#!/usr/bin/env bash
# Checkout + pricing as two services (chatty, batched) and merged into one process.
# Usage: ./bench.sh build | perf <runs> | slow
set -euo pipefail
H2=../lib/h2-2.5.252.jar
CP="out;$H2"
wait_up() { until curl -s -o /dev/null "http://127.0.0.1:$1/report"; do sleep 0.2; done; }
stop_all() {
for f in data/*.pid; do [ -f "$f" ] && taskkill //PID "$(cat "$f")" //F > /dev/null 2>&1 || true; done
sleep 1
}
fresh() { stop_all; rm -rf data && mkdir data; }
start() { # $1 = local|http|http-batch, $2 = extra pricing-service flags
if [ "$1" != local ]; then
java -cp "$CP" -Dport=8091 ${2:-} pricing.PricingService > data/pricing.log 2>&1 &
wait_up 8091
fi
java -cp "$CP" -Dport=8090 -Dpricing="$1" checkout.CheckoutApp > data/checkout.log 2>&1 &
wait_up 8090
}
mem() { # working set in MB for each running process
for f in data/checkout.pid data/pricing.pid; do
[ -f "$f" ] || continue
powershell -NoProfile -Command "\$p = Get-Process -Id $(cat "$f"); '{0}: working_set={1:N0}MB' -f '$(basename "$f" .pid)', (\$p.WorkingSet64/1MB)"
done
}
load() { java -cp out LoadGen http://127.0.0.1:8090 "$@"; } # warmup requests concurrency cart retry503
case "${1:-}" in
build)
rm -rf out && mkdir out
javac -d out -cp "$H2" $(find src -name '*.java') ;;
perf)
for run in $(seq 1 "${2:-5}"); do
for v in local http-batch http; do
echo "=== run $run: $v"
fresh; start "$v"
for cart in 1 5 20; do
load 500 1000 1 "$cart" 0
load 500 10000 16 "$cart" 0
done
mem
done
done
stop_all ;;
slow)
for v in local http-batch http; do
echo "=== $v: pricing answers late (1500 ms) every 200th request, timeout 1000 ms, retry once on 503"
fresh; start "$v" "-DslowEvery=200 -DslowMs=1500"
load 0 1000 1 20 1
curl -s http://127.0.0.1:8090/report; echo
done
stop_all ;;
esac
Each run starts from empty databases. For each cart size of 1, 5 and 20 lines, the generator warms up with 500 checkouts, then sends 1,000 sequential checkouts and 10,000 from 16 concurrent clients. The sweep ran with ./bench.sh perf 5, and every reported value is the median of the five runs.
What a Chatty Microservice Costs, Measured
Here is the raw output for 20-line carts from run 3, which sits near the median on most metrics:
=== run 3: local
cart=20 requests=1000 concurrency=1 seconds=0.40 rps=2493
p50=0.384ms p95=0.495ms p99=0.582ms max=3.220ms
status={201=1000} client_retries=0 totals_checksum=612251200
cart=20 requests=10000 concurrency=16 seconds=0.68 rps=14718
p50=0.989ms p95=1.807ms p99=2.702ms max=5.350ms
status={201=10000} client_retries=0 totals_checksum=6122300380
checkout: working_set=443MB
=== run 3: http-batch
cart=20 requests=1000 concurrency=1 seconds=0.59 rps=1694
p50=0.561ms p95=0.733ms p99=0.833ms max=3.046ms
status={201=1000} client_retries=0 totals_checksum=612251200
cart=20 requests=10000 concurrency=16 seconds=1.09 rps=9195
p50=1.573ms p95=2.766ms p99=4.353ms max=19.769ms
status={201=10000} client_retries=0 totals_checksum=6122300380
checkout: working_set=245MB
pricing: working_set=274MB
=== run 3: http
cart=20 requests=1000 concurrency=1 seconds=3.13 rps=319
p50=2.988ms p95=3.954ms p99=4.941ms max=9.400ms
status={201=1000} client_retries=0 totals_checksum=612251200
cart=20 requests=10000 concurrency=16 seconds=4.14 rps=2414
p50=6.457ms p95=8.140ms p99=9.474ms max=17.510ms
status={201=10000} client_retries=0 totals_checksum=6122300380
checkout: working_set=251MB
pricing: working_set=336MB
The checksums match across all three variants at every cart size and in every run, so swapping the adapter changed no price. All 90 load runs in the sweep returned only 201 responses.
A short awk script, ./summarize.sh perf.txt, sorts each metric across the five runs and prints the median. These are its throughput lines:
cart 1 c1 req/s local median 2035 runs: 2135 2041 1980 2035 2003
cart 1 c1 req/s http-batch median 827 runs: 823 844 821 885 827
cart 1 c1 req/s http median 860 runs: 902 736 812 860 884
cart 1 c16 req/s local median 13361 runs: 13488 13286 13361 13449 13088
cart 1 c16 req/s http-batch median 4254 runs: 4105 3900 4365 4347 4254
cart 1 c16 req/s http median 4415 runs: 4056 3916 4471 4440 4415
cart 5 c1 req/s local median 2455 runs: 2560 2450 2455 2326 2496
cart 5 c1 req/s http-batch median 1536 runs: 1446 1514 1619 1597 1536
cart 5 c1 req/s http median 925 runs: 934 884 895 925 948
cart 5 c16 req/s local median 21421 runs: 21421 22143 21538 21374 20694
cart 5 c16 req/s http-batch median 10572 runs: 10765 9444 10237 10572 10911
cart 5 c16 req/s http median 5836 runs: 5233 5231 5836 6049 5948
cart 20 c1 req/s local median 2490 runs: 2446 2433 2493 2512 2490
cart 20 c1 req/s http-batch median 1701 runs: 1652 1705 1694 1701 1710
cart 20 c1 req/s http median 329 runs: 329 335 319 337 329
cart 20 c16 req/s local median 14592 runs: 13723 14206 14718 15366 14592
cart 20 c16 req/s http-batch median 9621 runs: 9563 9621 9195 9748 9911
cart 20 c16 req/s http median 2440 runs: 2494 2433 2414 2440 2484
And these are the latency lines for 20-line carts, plus memory:
cart 20 c1 p50 ms local median 0.386 runs: 0.391 0.394 0.384 0.383 0.386
cart 20 c1 p50 ms http-batch median 0.56 runs: 0.550 0.562 0.561 0.560 0.558
cart 20 c1 p50 ms http median 2.927 runs: 2.925 2.911 2.988 2.927 2.997
cart 20 c1 p99 ms local median 0.595 runs: 0.628 0.654 0.582 0.587 0.595
cart 20 c1 p99 ms http-batch median 0.847 runs: 1.382 0.837 0.833 0.847 0.858
cart 20 c1 p99 ms http median 4.38 runs: 4.380 4.464 4.941 3.733 3.843
cart 20 c16 p50 ms local median 0.989 runs: 1.060 1.003 0.989 0.937 0.981
cart 20 c16 p50 ms http-batch median 1.563 runs: 1.563 1.564 1.573 1.534 1.518
cart 20 c16 p50 ms http median 6.437 runs: 6.288 6.450 6.457 6.437 6.301
cart 20 c16 p99 ms local median 2.826 runs: 2.990 2.978 2.702 2.597 2.826
cart 20 c16 p99 ms http-batch median 3.662 runs: 3.812 3.662 4.353 3.624 3.462
cart 20 c16 p99 ms http median 8.813 runs: 8.813 9.247 9.474 8.787 8.413
ws MB checkout local median 445 runs: 450 445 443 445 448
ws MB checkout http-batch median 245 runs: 244 246 245 265 244
ws MB checkout http median 251 runs: 251 251 251 258 257
ws MB pricing http-batch median 275 runs: 277 280 274 275 274
ws MB pricing http median 336 runs: 275 337 336 335 336
Put side by side for 20-line carts, the medians read like this:
| Median of 5 runs, 20-line cart | Merged | Batched services | Chatty services |
|---|---|---|---|
| Sequential p50 | 0.386 ms | 0.560 ms | 2.927 ms |
| Sequential p99 | 0.595 ms | 0.847 ms | 4.380 ms |
| Throughput, 16 clients | 14,592 req/s | 9,621 req/s | 2,440 req/s |
| p99, 16 clients | 2.826 ms | 3.662 ms | 8.813 ms |
| Working set, all processes | 445 MB | 520 MB (245 + 275) | 587 MB (251 + 336) |
Under 16 clients, the merged version handled 6.0 times the throughput of the chatty one. Moreover, the chatty cost grows with the cart. From 5 lines to 20 lines, the sequential p50 of the chatty version rose from 1.009 ms to 2.927 ms, which is roughly 0.13 ms per extra HTTP call on loopback. Over the same range, the merged version did not get slower at all, since twenty indexed lookups in an embedded database cost less than one round trip.
The 1-line rows carry a caveat. They are the first load each fresh process receives, so they include JIT warm-up that the 500-request warm-up did not fully absorb, which is why 1-line carts look slower than 5-line carts in every variant. With one line, the chatty and batched versions make the same single call, and their throughput, 4,415 against 4,254 requests per second, is the same within run-to-run spread.
The Result That Cuts Against Merging
Batching alone took the chatty version from 2,440 to 9,621 requests per second, which is 66 percent of the merged throughput, without deleting a service. At the median, it brought sequential latency to within 0.18 ms of the merged version. For a team whose only complaint is latency, that is the cheaper fix by a wide margin: one new endpoint and one adapter change, against a code move, a data migration and a decommission.
So the performance case for killing this microservice is weak on its own, and it would be dishonest to rest the post on it. The merge still wins every row of the table. However, the reason to merge is not that the merged version is faster than the batched one. The reason is whether pricing needs to be a service at all, which the signals table answers and the stopwatch does not.
Memory: One Process Is Not Half the Footprint
The merged process ended at a 445 MB working set, against 520 MB and 587 MB for the two-process versions. That is a saving of 75 to 142 MB, between 14 and 24 percent, which is less than the “one fewer JVM” intuition suggests. The merged process does all the work, so its heap grows to hold what both processes held before. The saving is real but modest, and in a container platform it mostly shows up as one fewer memory reservation to size and one fewer service to keep warm.
What a Slow Dependency Does to Each Version
The second test asks what happens when the remote call misbehaves. The pricing service answers every 200th request after 1.5 seconds, as a long GC pause or a congested link might. Checkout gives up after 1 second and returns a 503, and the client retries once. Each variant sends 1,000 checkouts of 20 lines, via ./bench.sh slow:
=== local: pricing answers late (1500 ms) every 200th request, timeout 1000 ms, retry once on 503
cart=20 requests=1000 concurrency=1 seconds=1.04 rps=958
p50=0.784ms p95=2.237ms p99=4.374ms max=50.922ms
status={201=1000} client_retries=0 totals_checksum=612251200
orders=1000 revenue_cents=612251200
=== http-batch: pricing answers late (1500 ms) every 200th request, timeout 1000 ms, retry once on 503
cart=20 requests=1000 concurrency=1 seconds=7.01 rps=143
p50=1.535ms p95=4.126ms p99=6.631ms max=1005.718ms
status={201=1000} client_retries=5 totals_checksum=612251200
orders=1000 revenue_cents=612251200
=== http: pricing answers late (1500 ms) every 200th request, timeout 1000 ms, retry once on 503
cart=20 requests=1000 concurrency=1 seconds=116.43 rps=9
p50=3.640ms p95=1009.730ms p99=1015.782ms max=1035.393ms
status={201=1000} client_retries=111 totals_checksum=612251200
orders=1000 revenue_cents=612251200
The same fault rate per call produced 5 retried checkouts in the batched version and 111 in the chatty one. Because the chatty version makes 20 calls per checkout, it hits the slow request 20 times as often. Every retry also re-sends lines that had already been priced, which pushes the count above the 100 that simple arithmetic predicts. Consequently, more than 5 percent of chatty checkouts took over one second, and the run lasted 116 seconds instead of 7.
The merged version is listed for completeness, and the comparison needs a caveat: the injected pause lives in the pricing service’s HTTP handler, so the merged version had nothing to inject into. A real GC pause in the merged process would stall checkout too. What it would not do is turn the stall into a timeout, a 503 and a retry, because an in-process call has no timeout to miss.
Notice also what did not go wrong. Every variant ended with 1,000 orders and identical revenue, because pricing is read-only and retrying a price lookup is harmless. That contrasts with the stock leak in the small-team post, where the remote call was a write. Before merging a service, check which of its calls are reads and which are writes. A chatty read-only service mostly costs latency, which batching fixes, while a chatty service that writes costs correctness, and that is a far stronger reason to merge. Retry behavior like this one is also exactly what circuit breakers and resilience patterns exist to contain, and every one of them is code the merged version no longer needs.
What the Merge Deletes
This script counts non-blank, non-comment Java lines in checkout and pricing, and marks the files the merge removes:
#!/usr/bin/env bash
# Non-blank, non-comment Java lines per file, and which files the merge deletes.
count() { grep -cvE '^\s*($|//)' "$1"; }
deleted="src/pricing/PricingService.java src/checkout/HttpPricing.java"
for f in $(find src/checkout src/pricing -name '*.java' | sort); do
tag=kept; [[ " $deleted " == *" $f "* ]] && tag=deleted
printf '%5s %-8s %s\n' "$(count "$f")" "$tag" "$f"
done
printf '\ndeleted by the merge: %s lines\n' "$(cat $deleted | grep -cvE '^\s*($|//)')"
Raw output of ./loc.sh:
108 kept src/checkout/CheckoutApp.java
64 deleted src/checkout/HttpPricing.java
22 kept src/checkout/LocalPricing.java
11 kept src/checkout/Pricing.java
56 kept src/pricing/PricingCore.java
80 deleted src/pricing/PricingService.java
deleted by the merge: 144 lines
The merge removes 144 of 341 lines, or 42 percent of the feature’s code, plus the two remote branches of the adapter switch. None of the deleted lines is pricing logic. All of it is transport: an HTTP server, request parsing, a client, timeouts and error mapping. The pricing rules in PricingCore survive untouched, which is the point of moving code behind a seam rather than rewriting it.
Line counts depend on the author, so treat 42 percent as indicative. The larger deletions in a real system sit outside the code entirely: a build pipeline, a deployment manifest, a set of dashboards and alerts, a secrets entry, and a line in the on-call runbook.
How to Merge a Microservice Back Into the Monolith
The steps below assume the service has an HTTP or gRPC API, its own database, and one main caller. They follow the strangler fig pattern in reverse: the monolith grows around the service’s function until the service has no traffic left.
- Confirm the signals with data. Pull deploy history, tracing call counts and the list of callers. If another team or a third party calls the service, stop here and reconsider.
- Pin the contract with tests. Before moving anything, cover the service’s current behavior with contract tests, so the in-process version can be checked against the same expectations.
- Introduce the seam. Put the remote client behind an interface shaped for the caller, as
Pricingis above, and ship that change on its own. - Move the code as a module. Copy the service’s domain logic into the monolith, with one public entry point and everything else package-private. Leave the transport layer behind.
- Move the data into a module-owned schema. Copy the service’s tables into a schema that only the new module reads and writes.
- Run both paths and compare. Call the remote and local adapters side by side for a sample of traffic, compare results, and log mismatches. The matching checksums in the bench are this step in miniature.
- Cut over with a flag. Switch traffic to the local adapter per environment, watch error rates, and keep the remote path available for a quick rollback.
- Decommission completely. Delete the remote adapter, the service code, its pipeline, dashboards, alerts and secrets, then add a boundary check to CI so the module stays a module.
Moving the Data Without Downtime
Step five is the risky one, because the service keeps writing while the copy runs. For read-mostly data like prices, a bulk copy followed by a short write freeze is often enough. For data the service writes constantly, the safer route is the expand and contract sequence from zero downtime database migrations: dual-write to both stores, backfill, verify counts and checksums, switch reads, then stop writing to the old store.
Two details catch teams here. First, IDs generated in the service’s database may collide with IDs in the monolith, so decide early whether to keep the old keys, prefix them, or remap them. Second, foreign keys that the split removed, such as an order line pointing at a price row, can now come back. Adding them is tempting but optional, because a module that owns its schema should still be reachable only through its API.
Running Both Paths Before Cutting Over
The comparison in step six catches the bugs the contract tests miss, because it runs on production inputs. Typical mismatches come from serialization rather than logic: a decimal that crossed the wire as a floating-point number, a timestamp that lost its time zone, or a default that the HTTP layer filled in and the domain code never set.
Run the comparison on reads first, since a read can execute twice with no side effects. For writes, compare the computed result before persisting, and persist only from the path that is currently authoritative. Keep the comparison running for at least one full business cycle, such as a week that includes a weekend batch, before trusting it.
The Case for Keeping the Service, Argued Honestly
The losing side deserves its strongest form. First, batching is a real alternative, and the bench shows it recovering two thirds of the merged throughput. If the service has a clear owner and the only problem is a chatty endpoint, fixing the endpoint is a day of work, while a merge is weeks.
Second, a service boundary is a hard boundary, and a module boundary is only as strong as the CI check that enforces it. Teams that merge without enforcement often find the merged module’s tables being queried directly within a few months, at which point splitting it out again is harder than it was the first time.
Third, independent deploys can matter even for a tightly coupled pair. If the service has a very different release risk, such as pricing rules that business users change daily through a config deploy, isolating those deploys can be worth a network hop.
Finally, the merge has its own blast radius. The monolith now carries the service’s dependencies, startup cost and failure modes, and a memory leak in the merged module takes checkout down with it. In the bench, the merged process held 445 MB in one heap, so a leak there has more to consume before anything restarts.
Notice, though, that each argument applies to some services and not others. None of them defends a service that fails every row of the signals table.
Two Public Merges Worth Reading
Segment’s engineering team described consolidating more than 140 destination services back into one service in Goodbye Microservices. The services shared libraries that drifted between versions, and keeping them alive had come to occupy three full-time engineers. After the merge, a change to a shared library deployed once instead of across every service.
Amazon Prime Video published a 2023 engineering post about its audio and video quality monitoring tool, which moved from a distributed design built on AWS Step Functions and Lambda to a single process on Amazon ECS and cut that tool’s infrastructure cost by 90 percent. The original article has since been taken down from its address on primevideotech.com, so treat secondhand summaries of it with care. The detail usually lost in those summaries is that the components stayed the same and only the boundaries between them moved, which is the same move the bench makes.
A Real Scenario: Pricing That Never Needed Its Own Deploy
Consider an online retailer with two backend teams and around a dozen services. Several years ago, pricing was split out of the order system so that a separate team could own discount rules. That team has since been merged into the checkout team, pricing deploys almost always ship alongside checkout changes, and tracing shows checkout as pricing’s only caller, making one call per cart line.
The checkout team first ships a batch endpoint, which fixes the latency complaints within a sprint. Over the following quarter, they then work through the merge steps: a contract suite, a Pricing interface, a pricing module with its own schema, and a week of shadow comparisons that surfaces one rounding difference in how discounts were serialized. After the cutover, they delete one service, one pipeline and one set of alerts, and pricing changes become ordinary pull requests in the checkout codebase.
The trade-off is real. The pricing module now shares checkout’s deploy cadence, so a risky checkout release delays an urgent price change. The team accepts that because it had been true in practice for years, and because a boundary check in CI keeps the module extractable if a separate pricing team ever returns.
When to Kill a Microservice
- Its only caller is one other service, confirmed from tracing or gateway logs
- The two services deploy in lockstep, shown by the release history rather than recollection
- A single inbound request triggers many calls into it, and batching would only treat the symptom
- It shares tables with its caller, so the boundary exists in the network but not in the data
- Its owning team is gone, and the team that inherited it already owns the caller
When NOT to Kill a Microservice
- Other teams, partners or clients call it directly
- Its only problem is a chatty endpoint that a batch call or cache would fix
- It has a very different scaling, hardware or compliance profile from the caller
- Its release risk differs sharply, such as daily rule changes against a weekly product release
- The monolith it would join has no module boundaries and no way to enforce them
Common Mistakes When Merging Microservices Back
- Merging the code but keeping the old database connection, so the module still crosses a process boundary for every query
- Shaping the new interface like the old endpoint, which preserves the chattiness inside the process
- Skipping the shadow comparison and finding serialization differences in production
- Forgetting the operational leftovers, so dead pipelines, alerts and secrets linger for months
- Letting other modules read the merged tables directly, which undoes the boundary within a quarter
- Treating the merge as a rewrite instead of a move, which turns a weeks-long project into a quarter
What This Measurement Does Not Cover
Every process ran on one machine over loopback, so each remote call cost about 0.13 ms. A real network adds round-trip time, TLS and occasional packet loss, which makes the chatty version worse and widens the gap between batched and merged. On the other hand, separate hosts remove the CPU contention that two JVMs created here. The databases are embedded H2, which makes each query far cheaper than a networked PostgreSQL, so the merged version’s flat latency across cart sizes would not hold against a remote database without batching its own queries.
Furthermore, the 1-line rows include JIT warm-up, the JVMs ran with default flags, and memory figures are Windows working sets at the end of a run, not peak heap. The slow-call test injected pauses only into the remote path. Finally, nothing here measures the migration effort, deploy frequency or incident rates, which are the outcomes that should decide whether to kill a microservice, and which come from a team’s own history, not from a bench.
Conclusion
Kill a microservice when the signals say it is a module in a separate process: one caller, lockstep deploys, chatty calls and no owner who values its independence. On the bench, the merge delivered 6.0 times the throughput of the chatty version and deleted 42 percent of the feature’s code, all of it transport. Batching, however, recovered two thirds of that throughput without a merge, so speed alone is a reason to fix the endpoint, not to delete the service.
Start this week by pulling one number: for each service, how many of its calls come from a single caller. Any service near 100 percent is a candidate, and the eight steps above take it from interface to decommission. Before drawing the new module’s boundary, revisit domain-driven design fundamentals, so the merged module ends up shaped around a business capability rather than around the old service.