DEV Community

I split a commerce backend into 6 services for a shop with zero users. On purpose.

There is a genre of blog post where someone explains that they moved off microservices and everything got better. Those posts are usually right. If you are building a product and your only goal is to ship it, a modular monolith will beat what I am about to describe on almost every measure that matters: build time, deploy time, cognitive load, your evenings. I am doing the opposite anyway, and I want to be precise about why - because "learning" is the kind of reason people give when they have not actually thought about the trade. The four goals, in order Stallora is a multi-vendor commerce platform: a Next.js storefront, a Flutter app, an admin panel, and a backend split into a gateway plus five services. I build it about ten hours a week, alone. It has four goals, and I ranked them before writing any code: - Learn how distributed systems are actually run in production. - Write about it in public. - Leave behind a reusable starter other people can pick up. - Sell the business half as a code product. The order is the interesting part. Goal 4 pays money and goal 1 does not, and goal 1 still wins. Any time the two conflict - a shortcut that would ship faster but hide the distributed problem - the shortcut loses. That rule is written into the repository's constraints, because six weeks from now, tired on a Wednesday night, I will want to take the shortcut and call it pragmatism. If your ranking is different, most of what follows does not apply to you. That is fine. This is not advice. It is a decision record with its reasoning exposed. What you cannot learn in a monolith Here is the honest core of it. In a monolith there is no network between your modules. Every call either returns or throws, in-process, in your transaction. That is a feature for shipping and a problem for learning, because three things simply do not exist: Partial failure. In a monolith, inventoryService.reserve() cannot half-happen. In Stallora, order calls inventory over HTTP, and that call can time out after inventory has already committed the reservation. Now two services disagree about reality and nobody threw an exception. Every design decision downstream - synchronous reserve, compensating release, timeout sweep - exists to answer that one sentence. You cannot practise this against a method call. Messages that arrive twice. Once state changes travel as events, at-least-once delivery is the default and exactly-once is a marketing term. So consumers must be idempotent: the second copy of OrderPlaced has to be a no-op, not a second shipment. In-process, this problem is invisible. Over Kafka it is Tuesday. Two writes that must agree. Committing a row and publishing an event are two systems. Do them in the wrong order and you either publish a fact that was rolled back, or commit a fact nobody hears about. The fix - write the event into the same database transaction as the row, then relay it - is the transactional outbox, and it only makes sense once the boundary is real. Those three constraints produce the parts of this project I actually want on my rรฉsumรฉ: inventory reservations that fail fast under concurrency, sagas that compensate instead of a distributed lock, consumers with deduplication tables. In a monolith I would be simulating all of it, and I would know I was simulating it. The 6 GB constraint is the best thing in the design The other reason this is not a toy: someone has to be able to run it. The business half ships to buyers who will deploy it on a cheap VPS, so the whole stack - six JVMs, a broker, a database, a cache, tracing - has to fit in about 4 GB, on a 6 GB machine, and start with one command. That single number killed more architecture than any principle did. It is why there is no service registry, no config server, no separate tracing stack of its own: | Concern | Choice | Instead of | |---|---|---| | Discovery | Kubernetes Service DNS / compose service names | Eureka | | Configuration | env vars + ConfigMap/Secret, Spring profiles | Config Server | | Service calls | Spring HTTP Interface (@HttpExchange ) + RestClient | OpenFeign | | Tracing | Micrometer Tracing + OTLP โ†’ Jaeger | Brave + Zipkin | | Messaging | Kafka (KRaft mode) | RabbitMQ | | Database | PostgreSQL (SKIP LOCKED , JSONB) | MySQL | | Consistency | Saga + transactional outbox | a transaction coordinator | Every row on the right is a JVM, a container, or a dependency I would have to explain to a buyer and pay for in RAM. Every row on the left is something the platform already does. The pleasant surprise is that the application ends up with no deployment dependencies at all: because discovery is DNS and configuration is environment variables, the same images run under Docker Compose and under Helm on Kubernetes, with nothing but different env values. Compose stays the buyer's path; Helm is the production-shaped path. I did not have to choose. A follow-up post will show the measured numbers per container, including what Kafka does to your budget if you forget to cap its heap. (It defaults to a 1 GB heap. On a 6 GB box that is not a detail.) When you should not do this I would not build this shape on a team with these requirements. If Stallora were a funded product with a deadline, the right call would be a modular monolith: one deployable, clean module boundaries, extract a service only when scaling or team ownership forces it. Most teams that split early pay the operational tax for years and collect none of the benefit, because their bottleneck was never the runtime - it was the deadline. The distinction I would draw is this: optimise for learning surface or for shipping speed, and know which one you picked. A repository that optimises for learning surface should say so out loud, in a file, where future-me can be held to it. Mine says so in docs/adr/0001-deliberately-over-decomposed.md , along with the sentence "This repository optimises for learning surface, not for shipping speed." If you read that as an admission of overengineering - yes. That is the word. It is deliberate, bounded by a memory budget, and documented, which is the difference between overengineering and an accident. What's next The open-source half is stallora-cloud-starter : gateway, JWT auth with a JWKS endpoint, a transactional outbox library, and both Docker Compose and Helm deployments of the same images. It is Apache-2.0. - Repo: https://github.com/danzizhangdev/stallora-cloud-starter - Next post: the memory budget in detail - measured per container, and the four infrastructure swaps that paid for themselves. If you have run something like this on a small box, I would like to hear what your first OOM was. Mine has not happened yet, which mostly means I have not finished. Top comments (0)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.