Every new backend starts with the same fork in the road, and both paths hurt.
- Start with microservices and you pay for distributed systems on day one — network hops, eventual consistency, five things to deploy — before you have the traffic or the team to justify any of it.
- Start with a monolith and you move fast, right up until the day you need to split it and discover the boundaries were never real. The "modules" all reach into each other's tables, and the "quick extraction" becomes a quarter-long rewrite.
I got tired of choosing wrong, so I built a starter that refuses to make me choose: Go-SimpleScale — three example services (users, products, orders) that deploy as one binary or three independent services from the same code. You switch by how you run it, not by editing anything.
This post is about the one design decision that makes that possible, plus the "boring" fundamentals I think a starter has no excuse to get wrong.
The third path: a modular monolith with real boundaries
The trick isn't clever. It's a discipline the language makes easy:
A service depends on the interfaces it needs — never on other services.
Take the order service. It needs to know whether a user exists, what a product costs, and it needs to reserve stock. So it defines its own interfaces describing exactly that — and nothing else:
// services/order/application/deps.go
package application
// The order service owns these interfaces. It does not import the user or
// product packages — it imports the *shape* of what it needs from them.
type UserService interface {
Exists(ctx context.Context, userID string) (bool, error)
}
type ProductService interface {
Price(ctx context.Context, productID string) (Money, error)
ReserveStock(ctx context.Context, productID string, qty int) error
}
Now there are two implementations of those interfaces, and the entry point picks one at startup:
// Monolith: wire the real in-process services — a direct method call.
orderApp := application.New(
userService, // *user.Service
productService, // *product.Service
)
// Microservices: wire HTTP clients that satisfy the same interfaces —
// now it's a network call, and the order code never knows the difference.
orderApp := application.New(
userclient.New(cfg.UserServiceURL, cfg.APIKey),
productclient.New(cfg.ProductServiceURL, cfg.APIKey),
)
That's the whole idea:
Monolith: order ──(Go interface)──▶ in-process user/product services
Microservices: order ──(Go interface)──▶ HTTP clients ──▶ user/product APIs
Because the transport is the only thing that changes, the monolith and the standalone services expose the same routes with the same authentication. Clients don't change when you split. Moving a service out becomes an operational decision — a Compose profile, a Deployment — instead of a rewrite.
One codebase, two ways to run it
# Start here: one binary, all services, in-memory event bus, one database
make secrets # generate .env — the app refuses to boot without it
make docker-up-monolith
# Later, when you actually need it: user :8081, product :8082, order :8083, Kafka
make docker-up-services
Same /api/v1 routes, same auth, either way. The event bus follows the same pattern as the service calls — one interface, backed by an in-memory bus in the monolith and Kafka in microservices mode, so the publish/subscribe code is identical on both sides.
The unglamorous stuff a starter shouldn't punt on
Anyone can wire up three services. What separates a starter you can build on from a toy is whether the boring, critical things are correct by default. I treated that as the whole point:
Auth in every mode. Every API route requires a valid API key or JWT — in the monolith and in each standalone service. Secrets are required at startup with no fallback: the binaries won't boot without a real
JWT_SECRET. That kills the single most common way public templates get their deployments forged — the well-known dev secret nobody replaced.Authorization, not just authentication. Orders belong to the authenticated caller. You can't read or cancel someone else's order, and prices come from the product catalog, never the request body:
// Client-supplied prices are ignored; the server prices from the catalog.
price, err := products.Price(ctx, item.ProductID)
- Correct concurrency. Stock is reserved with a single atomic statement, so two people racing for the last unit can't both win — the classic oversell bug, closed at the database:
UPDATE products SET stock = stock + $1
WHERE id = $2 AND stock + $1 >= 0; -- affects 0 rows if it would go negative
Real middleware, not stubs. Rate limiting, request logging, panic recovery, CORS with
Vary: Origin, timeouts, and body-size limits are implemented and tested — not placeholder functions that quietly do nothing.Tests where the risk is. Coverage concentrates on the parts that bite — auth, the event bus (race-detector clean), order pricing/stock/ownership — rather than chasing a coverage number.
Honest about the trade-offs
A starter that pretends it's production-perfect is lying to you. The README ships a "Honest Limitations" section on purpose. A few:
-
Money is
float64— fine for a demo, wrong for real payments (switch to integer minor units first). - Stock compensation is best-effort — a crash mid-order can leak a reservation; the real fix is an outbox/saga, deliberately out of scope.
- The in-memory rate limiter and event bus are per-process — multiple replicas need Redis/Kafka equivalents.
Those aren't bugs; they're the seams where your production hardening goes. Naming them is part of the design.
What I wanted it to demonstrate
Pragmatic architecture judgment: most teams don't need microservices yet, but you can build the seams so they're a config change away when they do — and get auth, secrets, and concurrency right before piling on features.
Try it
-
Code (start here): github.com/hung1m/go-simplescale — clone it, run
make secrets && make docker-up-monolith, and hithttp://localhost:8080. - The design write-up with the architecture diagram: my projects page.
- Companion project that takes the async side all the way (saga, outbox, idempotency, dead-letter queues): event-driven-order-saga.
If you've lived through a painful monolith-to-microservices split, I'd love to hear what actually broke for you — the boundaries, the data, or the org. That's the part no starter can solve for you.
Top comments (1)
Check more: event-driven-order-saga.