I have seen every one of these anti-patterns bite a production system, usually the ones nobody flagged in the design review. Sharing what actually breaks when microservices go wrong, straight from the trenches, not the textbook.
Somewhere around 2019, on a public sector integration program built on Kafka and Debezium, I sat in a war room (well, a Zoom call, but it felt like a war room) trying to figure out why a single price update to one product record was triggering eleven downstream services to fire events at each other in a loop. Nobody could point to the one service that owned that data. We had "microservices." We did not have architecture. That gap is what I want to talk about here. I've spent most of the last decade building distributed systems — FinTech and public sector platforms, hospital microservices running on AWS with Istio, and now my own platform, ITJobOpportunities. Across all of that, I've watched teams adopt microservices for the right conceptual reasons and then get burned by decisions that had nothing to do with the framework and everything to do with how the system was actually built and run. Here are the patterns that keep showing up. The Distributed Monolith (You Didn't Actually Decompose Anything) This is the most common one, and it's almost always invisible until something breaks badly. You split a monolith into ten services. Each one has its own repo, its own deployment pipeline, its own Kubernetes manifest. Looks great on the architecture diagram. Then you try to deploy service A by itself and discover you can't, because it shares a database schema with services B and C, and a migration in one breaks the other two. That's not microservices. That's a monolith wearing a costume, and it's often worse than the monolith it replaced, because now your single points of failure are scattered across network boundaries instead of living in one process where at least a stack trace could tell you what happened. On one FinTech program, we inherited a setup where "the loan service" and "the payment service" were separate deployables but shared one PostgreSQL instance with foreign keys crossing service boundaries. Every schema change became a two-team negotiation. We fixed it with an event-carried state transfer pattern using Debezium change data capture, so each service kept its own local read model instead of reaching across the fence. Took about two sprints. Should have been designed that way from day one. The tell here is simple: if you can't deploy a service independently without coordinating with two other teams, you don't have a microservice. You have a distributed monolith with extra latency and extra YAML. Chatty Services and the Network You Forgot About A lot of teams design service boundaries the way they'd design classes in a single JVM. Call this, get the result, call that next. It works fine in a diagram. In production, every one of those calls is now a network hop, and network hops fail, time out, and add latency that compounds. I saw this firsthand on a hospital microservices rewrite where a single "get patient dashboard" request fanned out to seven downstream services synchronously. On a good day it took 400ms. On a bad day, when one of those seven services had a GC pause, the whole request chain backed up and we got cascading timeouts across services that had nothing to do with the original slow one. // anti-pattern: synchronous fan-out with no isolation @GetMapping("/dashboard/{patientId}") public DashboardResponse getDashboard(@PathVariable String patientId) { var demographics = demographicsClient.get(patientId); var labs = labsClient.get(patientId); var meds = medsClient.get(patientId); var appointments = appointmentsClient.get(patientId); // if any one of these is slow, the whole request is slow return DashboardResponse.combine(demographics, labs, meds, appointments); } We fixed it with a mix of things: circuit breakers via Resilience4j, timeouts tuned per downstream call instead of one global timeout, and honestly the biggest win was just asking "does this actually need to be synchronous?" Half of those calls didn't. We moved appointment data to an async projection updated via Kinesis events, and the dashboard read from a local cache instead of calling live every time. @CircuitBreaker(name = "labsService", fallbackMethod = "labsFallback") @TimeLimiter(name = "labsService") public CompletableFuture<LabsResponse> getLabs(String patientId) { return CompletableFuture.supplyAsync(() -> labsClient.get(patientId)); } Not a silver bullet. It's a mitigation. The real fix was rethinking the boundary so a "dashboard" wasn't one service's problem to assemble live in the first place. Shared Databases: The Anti-Pattern That Refuses to Die I get why teams do this. You've got two services, they both need "customer" data, and standing up a whole new database with its own replication and backup strategy feels like overkill early on. So you point both services at the same schema. It's fast. It works for a while. Then six months later you can't change a column type in the customers table without a cross-team meeting, because Service A reads it one way and Service B reads it another, and now every schema migration is a negotiation instead of a deploy. I've been strict about this on ITJobOpportunities from day one. The job schema in Postgres is owned by the posting API. Nothing else touches it directly. If the admin console or a future service needs job data, it goes through the REST API or, eventually, through an event. That decision cost me a little more upfront work — building proper endpoints instead of letting a script query the table directly — but it means I can change internal schema details without breaking anything downstream. Liquibase migrations only ever have one owner to worry about. Rule of thumb I use now: if two services need the same data and you're tempted to share a schema, ask whether one of them should actually own that data and expose it, or whether you're looking at an event that should flow between them instead. Nine times out of ten, it's the latter. Decomposing by Technical Layer Instead of Business Capability This one is sneaky because it feels organized. You end up with a "validation service," a "notification service," a "logging service," and it looks clean on a slide. The problem is that almost every business transaction now touches four or five of these technical-layer services, so you've traded a monolith for a distributed monolith with worse latency and harder debugging. Domain-Driven Design has the right idea here, and I don't say that lightly because I'm generally allergic to methodology buzzwords. The services should map to business capabilities: "job application processing," "candidate profile management," "featured job promotion." Not "the service that sends emails." On ITJobOpportunities, Easy Apply touches candidate creation, resume parsing, skill extraction, and notification, but I didn't split those into four separate deployables early on. I kept them inside the jobs-posting API as cohesive modules under one bounded context, because splitting them prematurely would have meant four network calls and four failure points for something that's really one business transaction: a candidate applying to a job. If that module ever gets big enough to need its own scaling profile — say, resume parsing starts eating CPU independently of everything else — that's when it earns its own service. Not before. Ignoring Data Consistency Distributed transactions are hard, and a lot of teams pretend they aren't until a partial failure leaves data in a state nobody expected. Classic example: a job application gets created in the applications table, but the notification event to the recruiter never fires because the message broker was down for ninety seconds during a deploy. Nobody notices for three days. The candidate thinks they applied. The recruiter never saw it. The Saga pattern exists for exactly this, and honestly it took me longer than I'd like to admit to get comfortable with it. The idea is straightforward once it clicks: break the transaction into steps, each with a compensating action if a later step...