
This post is for tech leads, founding engineers and CTOs of teams around five people who are deciding whether the next service should be a new deployable or a new module. It argues that microservices for a small team are usually a cost paid early for a benefit that arrives late, and it measures that cost instead of asserting it. You will see one order feature built as a modular monolith and as two services, then compared on latency, throughput, memory, lines of code and, most importantly, what happens to stock when a write fails halfway. This site has taken the other side before, in migrating from a monolith to microservices with Spring Boot and in building microservices with NestJS, so treat this post as the counterweight.
The numbers below came from this environment:
Measured 2026-10-29
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 is deliberately framework-free. Spring Boot or NestJS would add their own startup and memory costs to every process, which would inflate the services side. Leaving them out keeps the comparison about the architecture rather than about any one framework.
Should a Small Team Use Microservices?
Usually not yet. Microservices mainly solve a coordination problem between several teams that need to deploy independently. Five engineers are one team, so they pay the full price of distribution, meaning network calls, split data and extra operations, without the organizational benefit. A modular monolith with enforced boundaries keeps the option open at a fraction of the cost.
That answer has exceptions, and a later section lists them. However, the default matters more than the exceptions, because the default is what a team picks before it understands its own problem. Martin Fowler made the same observation in Monolith First in 2015: almost all the successful microservice stories he heard started with a monolith that got too big, while systems built as microservices from scratch often ended up in serious trouble.
What Five Engineers Change About the Argument
Most lists of microservice benefits are really lists of benefits for organizations. The Spring Boot migration post on this site lists independent scaling, faster deployments, team autonomy, technology flexibility, fault isolation, clear ownership and easier testing. Each one is real. Each one also depends on a precondition that a five-person team rarely meets.
| Benefit | What it needs to pay off | What five engineers usually have |
|---|---|---|
| Independent deploys | Several teams blocked on each other’s releases | One team, one release rhythm |
| Team autonomy | Teams that own services end to end | Everyone touches everything |
| Independent scaling | Components with very different load profiles | One web tier and a database |
| Technology flexibility | A workload the main stack handles badly | One language the whole team knows |
| Fault isolation | Calls designed for partial failure | Synchronous calls that fail together |
| Clear ownership | More owners than codebases | Five people and N services |
| Easier testing | Contract tests between teams | Integration tests that now need a network |
Melvin Conway’s original paper observed that systems mirror the communication structure of the organizations that design them. Read in reverse, a team of five that communicates constantly will build a tightly coupled system no matter how many repositories it creates. As a result, the services end up sharing release trains, data assumptions and on-call rotations, which is a distributed monolith with extra steps.
Notably, the Spring Boot post itself listed “small team with limited DevOps expertise” under when not to migrate. This post takes that line and argues it is the main point rather than a footnote.
The Feature Built Twice
To put numbers on the cost, one order-placement feature was built two ways. The feature reserves stock for a SKU and writes an order row. The schema, the SQL and the HTTP surface are identical in both versions, and the only real difference is where the boundary between inventory and orders sits.
- Modular monolith: one process, one H2 database, an
inventorymodule and anordersmodule. The orders module calls inventory through a Java method, inside one transaction. - Two services: an order service and an inventory service, each with its own H2 database, talking over HTTP. This mirrors the flow in the NestJS post, where the order service “reserves inventory through a synchronous call to the inventory service, creates the order record” and moves on.
Both versions carry the same fault-injection switch, failEvery, which makes every Nth order write throw. That lets the bench ask what happens when the second step of a two-step operation fails.
Version One: The Modular Monolith
This is the core of the orders module. The inventory module’s reserve method takes the caller’s connection, so the reservation joins the transaction that the order write uses.
package shop.orders;
import java.sql.Connection;
import java.sql.SQLException;
import javax.sql.DataSource;
import shop.inventory.InventoryModule;
import shop.inventory.InventoryModule.OutOfStockException;
public final class OrderModule {
private final DataSource db;
private final InventoryModule inventory;
// constructor and insertOrder(...) omitted; full file in the bench
// Reservation and order row commit together or not at all.
public long place(String sku, int qty) throws SQLException, OutOfStockException {
try (Connection conn = db.getConnection()) {
conn.setAutoCommit(false);
try {
inventory.reserve(conn, sku, qty);
long id = insertOrder(conn, sku, qty);
conn.commit();
return id;
} catch (SQLException | OutOfStockException | RuntimeException e) {
conn.rollback();
throw e;
}
}
}
}
Passing a Connection across a module boundary is a coupling, and it is a deliberate one. It is exactly the coupling that disappears when the modules become services, so keeping it visible in one method signature tells the team precisely where a saga would be needed on the day they split.
Version Two: Orders and Inventory as Services
The order service does the same two steps, except that step one is an HTTP call that commits in another database.
// OrderService.java, inside the /orders handler (excerpt)
// Step 1: ask inventory to reserve. This commits in inventory's database.
HttpResponse<String> reservation;
try {
reservation = http.send(HttpRequest.newBuilder(URI.create(inventoryUrl + "/reservations"))
.timeout(Duration.ofMillis(timeoutMs))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(
"sku=" + URLEncoder.encode(sku, StandardCharsets.UTF_8) + "&qty=" + qty))
.build(), HttpResponse.BodyHandlers.ofString());
} catch (IOException | InterruptedException e) {
reply(ex, 503, "inventory unavailable: " + e.getClass().getSimpleName());
return;
}
if (reservation.statusCode() == 409) {
reply(ex, 409, reservation.body());
return;
}
if (reservation.statusCode() != 200) {
reply(ex, 502, "inventory returned " + reservation.statusCode());
return;
}
// Step 2: write the order in this service's own database.
try (Connection c = db.getConnection()) {
long n = attempts.incrementAndGet();
if (failEvery > 0 && n % failEvery == 0) {
throw new SQLException("injected failure writing order #" + n);
}
try (PreparedStatement ps = c.prepareStatement(
"INSERT INTO orders (sku, qty) VALUES (?, ?)", new String[] {"id"})) {
ps.setString(1, sku);
ps.setInt(2, qty);
ps.executeUpdate();
try (var keys = ps.getGeneratedKeys()) {
keys.next();
reply(ex, 201, "order " + keys.getLong(1));
}
}
} catch (SQLException e) {
// The reservation in inventory is already committed. Nothing here undoes it.
reply(ex, 500, e.getMessage());
}
This is not a strawman. It is the shape most tutorials show, including this site’s own NestJS post, and it is the shape a busy team writes first. The section after the measurements shows what it takes to make it correct.
How the Bench Was Run
A small load generator, written with the JDK’s HttpClient, posts orders and records each request’s latency with System.nanoTime. It reports p50, p95 and p99 from the sorted latencies, plus a count of status codes. For the correctness tests it retries once on a 503 with the same idempotency key, as a well-behaved client would. This script drives everything:
#!/usr/bin/env bash
# Modular monolith vs two services, same feature. Run from the bench folder.
# Usage: ./bench.sh build | perf <runs> [naive|fixed] | correctness [naive|fixed]
set -euo pipefail
H2=../lib/h2-2.5.252.jar
wait_up() { until curl -s -o /dev/null "http://127.0.0.1:$1/report"; do sleep 0.2; done; }
start_monolith() { # extra -D flags in $@
java -cp "out/monolith;$H2" -Dport=8080 "$@" shop.App > data/monolith.log 2>&1 &
wait_up 8080
}
start_services() { # $1 = naive|fixed, $2 = inventory flags, $3 = order flags
java -cp "out/inventory-$1;$H2" -Dport=8082 $2 inventory.InventoryService > data/inventory.log 2>&1 &
wait_up 8082
java -cp "out/orders-$1;$H2" -Dport=8081 $3 orders.OrderService > data/orders.log 2>&1 &
wait_up 8081
}
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; }
mem() { # working set and private bytes, MB, for the pid files given
for f in "$@"; do
powershell -NoProfile -Command "\$p = Get-Process -Id $(cat "$f"); '{0}: working_set={1:N0}MB private={2:N0}MB' -f '$(basename "$f" .pid)', (\$p.WorkingSet64/1MB), (\$p.PrivateMemorySize64/1MB)"
done
}
load() { java -cp out/load LoadGen "$@"; } # url warmup requests concurrency sku retry503
case "${1:-}" in
perf)
v="${3:-naive}"
for run in $(seq 1 "${2:-5}"); do
if [ "$v" = naive ]; then
echo "=== run $run: monolith"
fresh; start_monolith
load http://127.0.0.1:8080 1000 2000 1 spread 0
load http://127.0.0.1:8080 2000 20000 16 spread 0
mem data/monolith.pid
fi
echo "=== run $run: services ($v)"
fresh; start_services "$v" "" ""
load http://127.0.0.1:8081 1000 2000 1 spread 0
load http://127.0.0.1:8081 2000 20000 16 spread 0
mem data/orders.pid data/inventory.pid
stop_all
done ;;
esac
The build and correctness branches are left out here for length, and the correctness commands appear with their output further down. Each perf run starts from empty databases, warms up, then sends 2,000 sequential orders followed by 20,000 orders from 16 concurrent clients spread over ten SKUs. The script ran five times per variant with ./bench.sh perf 5 naive and ./bench.sh perf 5 fixed, and the reported value is the median of the five runs.
What the Network Hop Costs in Latency and Memory
Here is the raw output of run 3, which sits near the median on most metrics:
=== run 3: monolith
requests=2000 concurrency=1 seconds=0.75 rps=2667
p50=0.350ms p95=0.491ms p99=0.659ms max=3.576ms
status={201=2000} client_retries=0
requests=20000 concurrency=16 seconds=1.15 rps=17366
p50=0.826ms p95=1.602ms p99=2.524ms max=13.782ms
status={201=20000} client_retries=0
monolith: working_set=271MB private=415MB
=== run 3: services (naive)
requests=2000 concurrency=1 seconds=1.73 rps=1154
p50=0.783ms p95=1.396ms p99=1.771ms max=3.819ms
status={201=2000} client_retries=0
requests=20000 concurrency=16 seconds=2.88 rps=6948
p50=1.536ms p95=6.054ms p99=8.987ms max=88.949ms
status={201=20000} client_retries=0
orders: working_set=284MB private=419MB
inventory: working_set=218MB private=366MB
And here are the medians across all five runs, printed by a short awk summary over the raw files (./summarize.sh perf-naive.txt perf-fixed.txt). The svc+fix rows belong to the corrected services version, covered two sections down.
seq req/s monolith median 2790 runs: 2802 2790 2667 2653 2796
seq req/s services median 1175 runs: 1191 1230 1154 1050 1175
seq req/s svc+fix median 1058 runs: 1058 1017 1070 1034 1070
seq p50 ms monolith median 0.338 runs: 0.335 0.333 0.350 0.357 0.338
seq p50 ms services median 0.783 runs: 0.777 0.753 0.783 0.900 0.786
seq p50 ms svc+fix median 0.874 runs: 0.874 0.897 0.847 0.889 0.870
seq p99 ms monolith median 0.638 runs: 0.619 0.611 0.659 0.638 0.638
seq p99 ms services median 1.647 runs: 1.590 1.530 1.771 1.888 1.647
seq p99 ms svc+fix median 1.951 runs: 2.004 2.273 1.878 1.951 1.849
c16 req/s monolith median 17534 runs: 17629 17544 17366 17534 17321
c16 req/s services median 6944 runs: 7041 6942 6948 6944 6884
c16 req/s svc+fix median 6572 runs: 6463 6443 6770 6738 6572
c16 p50 ms monolith median 0.815 runs: 0.812 0.812 0.826 0.820 0.815
c16 p50 ms services median 1.460 runs: 1.461 1.296 1.536 1.404 1.460
c16 p50 ms svc+fix median 1.383 runs: 1.569 1.416 1.383 1.364 1.325
c16 p99 ms monolith median 2.524 runs: 2.573 2.432 2.524 2.468 2.668
c16 p99 ms services median 8.987 runs: 8.912 9.303 8.987 8.765 9.474
c16 p99 ms svc+fix median 10.221 runs: 10.098 9.910 10.297 10.586 10.221
ws MB monolith monolith median 271 runs: 271 271 271 271 271
ws MB orders services median 276 runs: 276 287 284 240 239
ws MB orders svc+fix median 245 runs: 247 244 244 295 245
ws MB inventory services median 218 runs: 218 218 218 244 217
ws MB inventory svc+fix median 224 runs: 223 224 224 226 229
Put side by side, the medians read like this:
| Median of 5 runs | Modular monolith | Two services | Ratio |
|---|---|---|---|
| Sequential p50 | 0.338 ms | 0.783 ms | 2.3x |
| Sequential p99 | 0.638 ms | 1.647 ms | 2.6x |
| Throughput, 16 clients | 17,534 req/s | 6,944 req/s | 0.40x |
| p99, 16 clients | 2.524 ms | 8.987 ms | 3.6x |
| Working set, all processes | 271 MB | 494 MB (276 + 218) | 1.8x |
The services version does strictly more work per order: two HTTP parses instead of one, two database commits instead of one, and two JVMs competing for the same twelve hardware threads. Consequently, its throughput under concurrency fell to 40 percent of the monolith’s, and its tail latency grew more than its median. The memory figure is mostly two copies of the JVM’s baseline, so a Go or Node service would shift the absolute numbers while keeping the shape.
The Result That Cuts Against This Post
Look at the absolute numbers before quoting the ratios. The extra hop cost about 0.45 ms at the median, and even the worst p99 here is under 11 ms. For a typical product with a few hundred requests per minute, nobody will ever notice that, and a team that adopts microservices will not fail because of loopback latency. Similarly, 6,944 orders per second on one desktop is far beyond what most five-person companies will ever need.
So the performance case against microservices for a small team is weak on its own, and it would be dishonest to lean on it. A real network adds its own round trip on top of loopback, and chains of three or four synchronous calls multiply it, but those are second-order costs. The cost that matters showed up in the correctness test.
The Failure Nobody Sees: Stock That Leaks Between Two Databases
The first correctness test makes every 20th order write fail, sends 1,000 orders for one SKU with 10,000 units in stock, and then reads back both the stock level and the order count. It ran with ./bench.sh correctness naive:
=== A. order write fails every 20th time, 1000 orders, stock 10000
requests=1000 concurrency=1 seconds=0.77 rps=1307
p50=0.646ms p95=1.279ms p99=1.779ms max=48.722ms
status={201=950, 500=50} client_retries=0
SKU-0 available=9050 orders=950 (monolith)
requests=1000 concurrency=1 seconds=1.75 rps=572
p50=1.559ms p95=2.731ms p99=3.579ms max=81.539ms
status={201=950, 500=50} client_retries=0
SKU-0 available=9000 (inventory)
orders=950 (orders)
Both versions returned the same status codes to the client: 950 successes and 50 errors. The monolith’s books balance, with 950 orders and 950 units gone. The services version also has 950 orders, yet 1,000 units are gone. Fifty units of stock are now reserved for orders that do not exist, and no log line, alert or error response says so. In an e-commerce system that shows up weeks later as products marked sold out while the warehouse is full.
The cause is that the reservation committed in one database before the order write failed in another. In the monolith, the rollback undid both. In the services version there is no shared transaction to roll back, so undoing the first step becomes the application’s job.
A Timeout That Actually Succeeded
The second test is nastier because nothing fails at all. In this run, the inventory service commits every reservation, but every 50th time it answers after 2.5 seconds, as a GC pause or a slow network might. The order service gives up after 1 second and returns a 503, and the client retries once:
=== B. inventory answers late (2500 ms) every 50th reservation, 1000 orders, client retries once on 503
requests=1000 concurrency=1 seconds=21.75 rps=46
p50=1.454ms p95=2.962ms p99=1004.324ms max=1009.426ms
status={201=1000} client_retries=20
SKU-0 available=8980 (inventory)
orders=1000 (orders)
Every one of the 1,000 orders succeeded from the client’s point of view. Even so, 1,020 units of stock left inventory, because each of the 20 timeouts reserved once on the first attempt and again on the retry. This is the classic distributed systems trap: a timeout does not mean the work failed, only that the answer did not arrive. The monolith has no equivalent here, because an in-process method call cannot time out while its work commits behind the caller’s back.
Retries make this worse at scale, and the retry storms failure modes post orders the ways that happens. For this bench, however, a single polite retry was enough.
What It Takes to Make the Services Version Correct
The fair response to those two tests is that the services version was naive, and a competent team would fix it. That is true, so the third variant fixes it with the two standard tools: an idempotency key, the pattern Stripe documents for its idempotent requests, and a compensating action, the building block of the saga pattern for distributed transactions.
The client sends a key with each order and reuses it on retry. The order service passes that key to inventory as the reservation reference, and inventory records it in a reservations table in the same transaction as the stock update:
// InventoryService.java, the /reservations handler (excerpt)
try (Connection c = db.getConnection()) {
c.setAutoCommit(false);
try (PreparedStatement claim = c.prepareStatement(
"INSERT INTO reservations (ref, sku, qty) VALUES (?, ?, ?)");
PreparedStatement take = c.prepareStatement(
"UPDATE stock SET available = available - ? WHERE sku = ? AND available >= ?")) {
claim.setString(1, ref);
claim.setString(2, sku);
claim.setInt(3, qty);
try {
claim.executeUpdate();
} catch (SQLException dup) {
if (!"23505".equals(dup.getSQLState())) throw dup; // 23505 = unique violation
c.rollback();
reply(ex, 200, "reserved (replay)"); // a retry of a request we already applied
return;
}
take.setInt(1, qty);
take.setString(2, sku);
take.setInt(3, qty);
if (take.executeUpdate() == 0) {
c.rollback();
reply(ex, 409, "out of stock: " + sku);
return;
}
c.commit();
} catch (SQLException e) {
c.rollback();
throw e;
}
}
The unique key on ref is what turns a retry into a no-op. On the other side, when the order write fails, the order service calls a new /reservations/release endpoint that deletes the reservation row and returns the stock in one transaction. The order service also checks for an existing order with the same key before doing anything, so a retried request returns the original order instead of creating a second one.
Here is ./bench.sh correctness fixed:
=== A. order write fails every 20th time, 1000 orders, stock 10000
requests=1000 concurrency=1 seconds=2.13 rps=470
p50=1.883ms p95=3.563ms p99=4.558ms max=86.008ms
status={201=950, 500=50} client_retries=0
SKU-0 available=9050 (inventory)
orders=950 (orders)
=== B. inventory answers late (2500 ms) every 50th reservation, 1000 orders, client retries once on 503
requests=1000 concurrency=1 seconds=22.11 rps=45
p50=1.804ms p95=3.745ms p99=1005.315ms max=1011.861ms
status={201=1000} client_retries=20
SKU-0 available=9000 (inventory)
orders=1000 (orders)
Both tests now balance. In test A, 950 orders match 950 units, and in test B, 1,000 orders match 1,000 units. The fix works, and the perf table above shows what it cost at runtime: median throughput under 16 clients went from 6,944 to 6,572 requests per second. The p50 under concurrency moved the other way, from 1.460 ms to 1.383 ms, which is within the run-to-run spread and should be read as no change.
Lines of Code the Fix Added
This script counts non-blank, non-comment Java lines per variant and then diffs the naive and fixed services:
#!/usr/bin/env bash
# Non-blank, non-comment lines of Java per variant, then per file.
for v in monolith services services-fixed; do
printf '%-16s %5s lines\n' "$v" "$(find "$v" -name '*.java' -exec cat {} + | grep -cvE '^\s*($|//)')"
done
echo
for f in $(find monolith services services-fixed -name '*.java' | sort); do
printf '%5s %s\n' "$(grep -cvE '^\s*($|//)' "$f")" "$f"
done
echo
git diff --no-index --stat services services-fixed 2>/dev/null | tail -3
Raw output of ./loc.sh:
monolith 152 lines
services 205 lines
services-fixed 306 lines
80 monolith/src/shop/App.java
23 monolith/src/shop/inventory/InventoryModule.java
49 monolith/src/shop/orders/OrderModule.java
143 services-fixed/inventory-service/src/inventory/InventoryService.java
163 services-fixed/order-service/src/orders/OrderService.java
88 services/inventory-service/src/inventory/InventoryService.java
117 services/order-service/src/orders/OrderService.java
.../src/inventory/InventoryService.java | 75 +++++++++++++++++++---
.../order-service/src/orders/OrderService.java | 74 ++++++++++++++++++---
2 files changed, 129 insertions(+), 20 deletions(-)
The correct services version is 306 lines against 152 for the monolith that was correct from the start, roughly twice the code for the same feature. Of the 154 extra lines, 53 come from the split itself: a second entry point, duplicated request helpers and the HTTP call. The other 101 exist only to rebuild the atomicity that one database transaction gave the monolith for free. Line counts depend on the author, so treat the ratio as indicative rather than precise. The direction, however, does not depend on style: every correctness mechanism added here is code the monolith does not need at all.
What the Fix Still Does Not Cover
The fixed version passes both tests, but it is still not production-safe, and pretending otherwise would undercut this whole post. Three gaps remain:
- If the order service crashes after reserving and before compensating, the release never happens. Closing that gap needs a reservation expiry or a reconciliation job that compares both databases.
- If the release call itself times out, the same problem returns. The code logs nothing and moves on, which is honest about where a real system would need a durable retry queue.
- If the order service needs to publish an event after writing the order, the write and the publish can diverge, which is the problem the transactional outbox pattern exists to solve.
Each gap has a known solution. Each solution, however, is another table, another background job and another thing to monitor, built by the same five people who are also supposed to be shipping the product.
The Costs That Never Show Up in a Benchmark
The bench measured one feature in isolation, which understates the bill for microservices for a small team. A real system multiplies its costs by the number of services and adds several that no benchmark captures:
- Pipelines and environments. Each service needs its own build, deploy, configuration and secrets. A five-person team with six services maintains six of each, plus whatever glues them together.
- Observability. A request that crosses three services needs distributed tracing to debug, plus correlated logs and per-service dashboards. In a monolith, one stack trace covers it.
- Local development. Running the system means running every service and every database, usually through Docker Compose, and keeping their versions in step on every laptop.
- Contract changes. Renaming a field becomes a versioned API change with a compatibility window, rather than a refactor the compiler checks in one commit.
- On-call load. More deployables mean more things that page someone, and five people means each engineer is on call often.
Segment’s engineering team described the end state in Goodbye Microservices: after years of splitting, they reached a point where three full-time engineers spent most of their time keeping the system alive, and they consolidated back into a monolith. For a five-person company, three engineers is most of the headcount.
The Modular Monolith: What to Build Instead
A modular monolith is a single deployable whose code is split into modules with enforced boundaries, so each module could become a service later without a rewrite. Shopify described its own move in Deconstructing the Monolith, keeping one codebase but ensuring boundaries between components were defined and respected.
How to build a monolith you can split later:
- Give each module one public entry point. In Java, keep everything else package-private and verify the structure with Spring Modulith or ArchUnit. In Rails, Packwerk does the same job, and in a Node monorepo the
exportsfield of each workspace package can play that role. - Let each module own its tables. No module reads or writes another module’s tables directly, and cross-module joins go through the owning module’s API. A schema per module makes violations visible.
- Keep cross-module transactions explicit. The
Connectionparameter in the bench’sreservemethod is an example: the coupling exists, it is in one signature, and it marks where a saga would go later. - Use in-process events for side effects. Notifications and analytics subscribe to domain events, so moving them to a broker later changes the transport, not the design.
- Fail the build on boundary violations. A rule that only lives in a wiki decays within months.
- Deploy one artifact, in more than one role if needed. The same build can run as a web tier and as a background worker, which isolates heavy jobs without splitting the codebase.
Boundaries drawn this way follow the bounded contexts that domain-driven design describes. Getting those boundaries right is the hard part of any split, and a modular monolith lets a team correct a wrong boundary with a refactor instead of a migration.
Signals That It Is Time to Split
Waiting is not the same as never, and the case against microservices for a small team has limits. These signals justify extracting a service, and most of them are observable rather than speculative:
- A workload with a very different resource profile. GPU inference, video transcoding or large PDF rendering will starve a web tier, and they scale on different hardware.
- A different compliance boundary. Moving card handling into its own service can shrink the scope of a PCI audit to that service alone.
- A runtime the main stack cannot host well. A Python ML model in a JVM shop is a real reason for a separate process.
- Measured deploy contention. If releases are repeatedly blocked by another area’s failing tests or migrations, and the team can point to the incidents, independent deploys start paying off.
- A second team. Once two or more teams with distinct ownership share the codebase, the organizational benefits that this post discounts become real.
Notice that the first three call for one extracted component, not an architecture. Pulling a single worker out of a monolith is a small, reversible step. Turning the whole system into a mesh of services is neither.
The Case for Microservices, Argued Honestly
The losing side deserves its strongest form, and there are honest reasons to choose microservices for a small team. First, some teams of five are not building a typical product. A data pipeline with independent stages, or a platform that runs customer-supplied workloads, may have natural service boundaries from day one, and forcing it into one process can create its own coupling.
Second, a team that expects to grow fast has a real argument. Splitting a monolith under pressure is expensive, and a team that will be thirty engineers in eighteen months may reasonably pay some distribution cost early. The counterargument is that boundaries drawn before the domain is understood tend to be wrong, and a wrong service boundary costs more to move than a wrong module boundary.
Third, managed platforms have lowered the operational floor. Serverless functions, managed queues and hosted tracing remove much of the infrastructure work that made microservices expensive a decade ago. They do not, however, remove the correctness work measured above, since no platform provides a transaction across two databases.
Finally, fault isolation is real when the design supports it. If the notification service is asynchronous and its failure only delays emails, splitting it out does protect the order path. The NestJS post’s claim that orders keep flowing when notifications go down holds for exactly that kind of asynchronous edge. It fails for the synchronous inventory call in this bench, where the order service returned 503s whenever inventory was slow.
A Real Scenario: Five Engineers and an Architecture Slide
Consider a seed-stage B2B SaaS company with five engineers, one product and a few dozen paying customers. The investor deck shows a microservices diagram with an auth service, a billing service, a core API and a notifications service, and a new senior hire has proposed building it out over the next quarter. The current monolith is a year old, has no enforced module boundaries and deploys in about ten minutes.
Working through the costs above, the stronger move is usually to spend that quarter turning the monolith into a modular monolith: carve out modules for auth, billing, core and notifications, give each its own schema, and add boundary checks to CI. Notifications can move to an in-process event bus with a background worker in the same deployable. That buys most of the clarity of the diagram, keeps billing and core in one transaction, and leaves the team shipping features.
The trade-off is real, though. If the company doubles headcount within a year, it will spend some of that time extracting the first service. Because the boundaries are already enforced, however, that extraction becomes a contained project rather than a rewrite, and the team chooses which module goes first based on evidence rather than on the slide.
When to Use Microservices for a Small Team
- A component needs very different hardware or scaling, such as GPU inference or media processing
- A compliance boundary, like card data, is much cheaper to audit as a separate system
- Part of the system has to run in a language the rest of the stack cannot host
- The team can name the specific incidents in which shared deploys blocked a release
- The architecture is mostly asynchronous, so a failed service delays work rather than failing requests
When NOT to Use Microservices for a Small Team
- The team is one team that already talks every day and shares one release rhythm
- A core operation spans two pieces of data that must stay consistent, like stock and orders
- Nobody on the team has run distributed tracing, idempotency keys or sagas in production
- The domain is still changing quickly and the bounded contexts are guesses
- The main driver is a diagram, a job posting or “we will need to scale later”
Common Mistakes with Microservices on Small Teams
- Treating a timeout as a failure, which in this bench turned 20 timeouts into 20 phantom stock reservations
- Splitting services but sharing one database, which keeps all the coupling and adds the network
- Writing the naive two-step call from tutorials, which leaked 50 units of stock in 1,000 orders here
- Adding retries without idempotency keys, so every retry becomes a duplicate side effect
- Counting latency as the main cost, when the measured hop was under half a millisecond and correctness was the real bill
- Extracting services before module boundaries exist, then discovering the boundaries were wrong
What This Measurement Does Not Cover
Every process ran on one machine over loopback, so the latency figures are a floor. A real network adds round-trip time, TLS and occasional packet loss, while separate hosts remove the CPU contention that two JVMs created here. The databases are embedded H2 rather than PostgreSQL over a network, which makes each commit cheaper than in production and shrinks the monolith’s advantage, not the services’.
Furthermore, the JVMs ran with default flags, and memory figures are Windows working sets at the end of a run, not peak heap. The load is one endpoint with one write path. Systems dominated by reads, caches or asynchronous work will show different ratios. Finally, nothing here measures developer productivity, deploy frequency or incident rates. Those are the outcomes that actually decide this question, and they come from team data, not from a bench.
Conclusion
Microservices for a small team are a bet that organizational benefits will arrive before the operational and correctness costs sink the team. On the feature measured here, the network hop cost under half a millisecond, which is not the problem. The problem is the 50 units of stock that silently leaked in 1,000 orders, the 20 phantom reservations from polite retries, and the 101 lines of compensation code needed to make two services as correct as one transaction already was.
For a five-person team, the better default is a modular monolith with enforced boundaries, plus a written list of signals that would justify the first extraction. Start this week by adding one boundary check to CI for your most-touched module. When the time to split does arrive, read the saga pattern guide before writing the first cross-service call.