Everyone tells you not to build microservices until you have the org chart for it. They're right. I'm basically a one-person team on this codebase. No domain teams, no payments-vs-inventory boundaries, one shared database.
And I still split a service out of my monolith last month.
Not because I needed microservices, but because I needed exactly one of their superpowers. It turns out you can take that superpower without taking the whole religion. The pattern has a boring name, technical partitioning, and it's been the most fun I've had architecting in a while.
The problem: one feature with a completely different metabolism
The product is a visitor-analytics app built on Streamlit. It's a great fit for what it does: analysts pick locations (POIs), the app renders reports. Python, Postgres, Redis cache, done.
Then the requirement arrived: typeahead search over ~1.2 million POIs. Per keystroke. With fuzzy matching, alias handling (Japanese chain names have many spellings), and ranking.
Think about what that means inside a Streamlit app:
- The search index wants to live in memory (~270MB resident once built) and answer in single-digit milliseconds.
- Streamlit's execution model re-runs your script on every interaction. It's wonderful for dashboards and absolutely hostile to "hold a giant hot data structure and answer 20 queries a second."
- And here's the kicker: when the app gets busy and we scale horizontally, every replica would drag that 270MB index along, for a feature that has nothing to do with why we're scaling.
That last point is the Scale Cube in one sentence. Horizontal scaling (X-axis) clones your entire application. Functional decomposition (Y-axis) splits it so you only multiply the part that's hot. Most of us reach for X by reflex: add another instance, tune the autoscaler, move on. It works until the thing you're cloning is mostly dead weight.
What I actually wanted from microservices
When I listed what I wanted, it was embarrassingly short:
- A different runtime. This workload wants a compiled language and an in-memory index. I wanted Rust for this, and I didn't want to justify rewriting anything else.
- Independent sizing. Search needs memory and CPU with low latency. The app needs neither in the same shape. I wanted two knobs, not one.
- Independent failure. If search dies, reports should still render.
- Independent deploys. Shipping a ranking tweak shouldn't redeploy the app.
Notice what's not on the list: separate teams, separate repos, separate databases, event buses, service meshes, distributed sagas. All the parts of microservices that hurt? I didn't need any of them.
Software Architecture: The Hard Parts calls the legitimate reasons to split a service "granularity disintegrators": service scope, code volatility, scalability & throughput, fault tolerance, security, extensibility. My case checked three of six, and none of them were "it's a different business domain." That's the tell that this is a technical partition, not a domain one. Knowing which kind you're doing keeps you honest about what you owe in return.
The shape: one repo, one cluster, two metabolisms
So here's what "microservice flexibility without the microservices" ended up looking like:
repo/
├── app/ # Streamlit monolith (Python), untouched
└── api/ # Rust workspace
├── crates/infra # Redis + Postgres clients. Technology, no domain.
└── crates/catalog # The deployable: search + (future) POI-catalog features
- Same repo. A cross-cutting change (like when the frontend's search payload contract changed) is one commit, one review. No multi-repo choreography.
- Same ECS cluster, separate service. A cluster is a namespace, not an isolation boundary. Isolation lives one level down: the search service gets its own task definition, its own security groups, its own (narrower) IAM role, its own instance sizing. On the AWS bill, the hot path and the cozy path finally show up as separate line items.
- Same database. Heresy in microservice-land, completely fine here. Mark Richards would call this service-based architecture: a small number of coarse-grained deployables, shared data, ops far simpler than a fleet.
The data flow is deliberately boring: Postgres → Redis → container memory. A publisher batch job snapshots the catalog into Redis (gzipped, chunked, blue-green keys); the Rust service loads it once at boot and serves searches purely from memory. Redis stays out of the request path. Boot takes ~20 seconds; queries take 3-5ms.
The part that keeps it from becoming theater
"Clean architecture" gets a bad name because most implementations are theater: interfaces with one implementation, DTOs mapped to identical DTOs, five layers guarding nothing. Two rules kept this one surgical:
1. Abstract technology, not domain. crates/infra knows how to pool Postgres connections, do TLS, stream a million rows, and chunk Redis blobs. It does not know what a POI is. The SQL lives in the catalog's repository layer. When I later had to add TLS for the managed Postgres (it rejects plaintext; ask me how I know), the fix touched one file in infra, and every current and future consumer got encrypted connections for free. Change-size versus effect-size: that's the only abstraction metric I trust.
2. Let the compiler enforce the layering. The rule is "service code never imports the web framework or the Redis client." In most codebases that rule lives in a wiki and erodes one expedient import at a time. In a Cargo workspace, the service crate simply doesn't have those dependencies in scope, so violating the architecture is a compile error. Cheapest architecture cop money can buy.
And one boundary decision I'd defend hard: the service is named catalog, not search. Search is the first feature, but the deployable is a bounded context: reference data about places, and everything that operates on it. Future features land as siblings inside it instead of spawning search-service-2. The reason for the split was technical; the boundary still follows a domain seam. That's the combination that ages well. A partition cut purely along "fast stuff goes here" becomes a misc-performance junk drawer within a year.
Failure is a feature
Because search is now a network dependency, the frontend treats it like one. A cheap health check (cached ~30s) decides which UI to render: the API-backed free-text search, or the original database-driven picker as an automatic fallback. Kill the search container and the app quietly downgrades within seconds; bring it back and it upgrades again. Nobody files a ticket.
The same idea gates the rollout. The frontend code shipped before any infrastructure existed. With the API URL unset, it's inert and renders the legacy path. Activation is two environment variables on the app's task definition. Merge order stopped mattering. Deploys became boring. Boring is the goal.
The bill
Honesty section: you do pay for this.
- One more deployable: image build, service, target group, health checks, log group.
- Snapshot publishing needs an operational answer (ours: a batch job, plus a service restart until hot-reload lands).
- You now own cross-service contracts. Ours is a small JSON payload, and byte-level serialization details (looking at you,
serde_jsonvs Python'sjson.dumpsseparators) become things you actually think about.
That's real overhead. It's also a fraction of what full microservices cost, because we skipped the expensive parts: no distributed data, no inter-service call graphs, no team coordination tax.
Takeaways
- When you're resource-tight, notice which axis you're reaching for. Horizontal scaling clones everything; if one feature has a different metabolism, a functional split multiplies only what's hot.
- You can adopt microservice flexibility (runtime freedom, independent sizing, failure isolation, independent deploys) without adopting microservice architecture. One repo, one cluster, shared database, two deployables.
- Split for technical reasons if you must, but draw the boundary on a domain seam anyway. Name the service after what it's about, not what it's fast at.
- Abstract technology, never domain. And if you can, make the compiler enforce your layering instead of a wiki page.
- Build the fallback before you build the infrastructure. A new dependency you can survive losing is a dependency you can ship calmly.
Further reading, the sources behind the ideas: The Scale Cube (AKF original), the granularity chapter of Software Architecture: The Hard Parts (free chapter PDF), and service-based architecture.