I spent a week turning a working six-service system into something a stranger could run. The code barely changed. Almost all of the work was in the gap between "it runs on my machine" and "it runs on yours, on the first try, without me."
That gap is where most side projects quietly die, and it is much wider than it looks from the inside.
The memory budget is a product decision, not an ops detail
Six JVMs, Kafka, PostgreSQL, Redis, a tracing collector, a server-rendered storefront and two static consoles. Thirteen containers. Someone has to run this on a box they pay for monthly, and the size of that box is the first thing they decide — before they read a line of my code.
So I capped the Docker VM at 6 GB and measured a cold start, sampling every two seconds:
| Peak, all thirteen containers | 3.35 GiB |
| Steady state after the full product walk | 3.26 GiB |
| Containers killed for memory | 0 |
Building the jars (mvn clean package, peak RSS) |
530 MiB |
The peak matters more than the steady state, and it is the number nobody publishes. Steady state is six JVMs that have finished waking up. The peak is six JVMs initialising at once while Flyway migrates five schemas and Kafka creates its topics — the exact moment a 4 GB box dies. If I had only measured the calm afterwards, I would have been advertising a floor I had never tested.
Three decisions came out of that budget rather than out of taste:
-
-Xmx256mon every service, and Kafka's heap capped explicitly. Kafka's default heap will happily take a gigabyte it does not need on a single-broker demo. - One PostgreSQL instance, one database per service. Five containers of Postgres would have been more purist and would have cost about 400 MB more for nothing. The isolation that matters is schema ownership, not process count.
- I stopped at six services. Splitting further was tempting and would have pushed the floor to 16 GB — which does not make the design better, it makes the product unsellable.
Cold start is the gate
The single highest-value thing I built was not a feature. It is one script that:
- builds the jars,
- tears the stack down including volumes,
- brings all thirteen containers up from nothing,
- waits for every health check,
- then walks the whole product path with 32 assertions — a buyer applies to sell, an admin approves them, the shop is created, a cross-shop order splits, stock is reserved, payment moves it, each shop fulfils its own slice, one is refunded, the rest is billed with commission and paid out.
One command, exit code 0 or a specific failed assertion. It runs in about ninety seconds to all-green.
This turned out to matter more than the tests. Unit and integration tests told me my code was right given my machine's state. The gate tells me a stranger's first command works. Those are different claims, and only the second one is what shipping means.
It caught things nothing else did. Deleting three products from a seed file left another service's seed generating stock rows for SKUs that no longer existed — orphan ids that then collided with the next id the platform handed out. Every test passed. The gate failed on a duplicate key, in a step unrelated to the change.
And near the end I moved the gate one step further out: unzip the actual distributable into a clean directory and run it from there. A working tree that runs proves nothing about the archive you upload — missing files, an over-eager ignore rule, a generated file that never got committed, all of it only surfaces after extraction.
Say what it does not do, early and in the same voice
The payment gateway is mocked. There is one interface with charge and refund, a mock implementation, and documentation for swapping in a real processor including the part people skip — the webhook is not optional, events arrive twice, and you move the order to paid from the webhook rather than from the browser's return trip.
I could have buried that. Instead it is in the second paragraph of the product description, next to the memory floor and next to "this will not run on shared hosting." My reasoning is not nobility, it is arithmetic: a buyer who discovers the limit after purchase writes a refund request and a one-star review, and both cost more than the sale.
The same applies to single currency (amounts are integer cents in one currency throughout — multi-currency is a project, not a setting) and to there being no email at all. Writing those down was uncomfortable for about ten minutes and then it made the documentation better, because each limit has an obvious next question and answering it is most of a good manual.
What actually consumed the week
Not the services. The list looked like this:
- A demo mode that answers the whole app from inside itself. One build flag and the mobile app serves its own catalogue, splits its own orders and runs its own fulfilment clock. Three jobs from one implementation: a reviewer walks the product without deploying anything, the live preview becomes a static file, and screenshots come out of the same journey script the real stack uses.
- Documentation for the person who does not use a terminal. Every command explained, and a section on doing database work through pgAdmin instead. My previous product's review cycle was three rounds of "please cover this for non-technical buyers." Cheaper to write it up front.
-
The platform I had never actually built for. I had shipped four milestones on an iOS simulator. The first Android build crashed instantly: the package rename had left
MainActivityin the old Kotlin folder, so the class in the manifest did not exist. It had been broken for months. iOS has no Kotlin, so nothing ever noticed. The app name on the launcher was still the project slug, and Android has blocked plain HTTP since API 28, which no amount of iOS testing would have told me.
That last one is the lesson I would keep if I could only keep one: a platform you have never actually run is entirely unverified, and the defect density there is high. Six real defects in one afternoon, in code that had passed every gate I had.
The part that generalises
Making something shippable is mostly the work of removing yourself from the instructions. Every step that only works because of what you already know — a port that happens to be free, a JDK that happens to be 21, an emulator that happens to reach your laptop, a file that happens to exist because you made it once by hand — is a step that fails for everyone else.
The way to find those steps is not to think harder. It is to start from nothing, in a clean directory, and let a script tell you where you were leaning on yourself.
Top comments (0)