There's a certain kind of hubris that comes with early-stage engineering. You've read the Uber post-mortems and the Netflix engineering blog. You know what scales. So you build microservices, even when your user base is exactly zero.
That's how Tixort started. Event ticketing platform, five services, Kafka in the middle, k3s for orchestration. It looked serious on architecture diagrams. It made even the simplest feature a distributed systems problem.
I rewrote it as a single Go binary. Here's what I learned.
Why Microservices Were the Wrong Call at This Stage
The original stack had five services: order-service, payment-service, notification-service, analytics-service, and an api-gateway. Each communicated through Kafka topics.
The payment flow looked like this:
User → Gateway → order-service
↓
Create order (status: PENDING)
Create pending transaction record
↓
payment-service → Stripe (initiate charge)
↓
Stripe processes charge
↓
Stripe Webhook → webhook-handler
↓
Update transaction (status: CONFIRMED)
Update order (status: CONFIRMED)
↓
Kafka: ticket.confirmed
↙ ↘
notification-service analytics-service
(email + SMS) (revenue tracking)
If Stripe fired a payment_failed webhook, the handler had to update both the transaction and the order status, then produce to a separate order.failed topic. Two services, two consumers, two potential failure points for what should have been a single state machine transition.
A ticket purchase required an HTTP call to the gateway, order and transaction creation, a Stripe charge, a Stripe webhook round-trip back to our server, a Kafka produce to ticket.confirmed, two independent consumer groups reading that topic, and two downstream side effects that could fail independently. That's six async hops for one user action. The webhook retry window from Stripe meant our idempotency logic had to be bulletproof across all of them.
What the Go Monolith Actually Looks Like
The rewrite is a single binary. All domains live as packages inside one module:
backend/
├── api/routes/
│ ├── adminRoutes.go
│ ├── authRoutes.go
│ ├── couponRoutes.go
│ ├── customerRoutes.go
│ ├── eventsRoutes.go
│ ├── notificationRoutes.go
│ ├── organizerRoutes.go
│ ├── paymentRoutes.go
│ ├── ticketTypeRoutes.go
│ ├── ticketsRoutes.go
│ └── userRoutes.go
├── cmd/api/
├── internal/
│ ├── config/
│ ├── dto/
│ ├── handlers/
│ ├── middlewares/
│ ├── models/
│ ├── repository/
│ ├── service/
│ └── tasks/
├── migrations/
├── pkg/
├── Dockerfile
└── docker-compose.yaml
Each domain has its own handlers, service layer, repository, and DTOs inside internal/. They share a database connection from pkg/ and communicate through direct function calls, not network hops.
For background work like notifications and analytics, I replaced Kafka with asynq, a Redis-backed task queue for Go:
// Enqueue after Stripe webhook confirms payment
func (h *WebhookHandler) HandlePaymentSuccess(w http.ResponseWriter, r *http.Request) {
// verify Stripe signature, parse event
task, err := tasks.NewOrderConfirmedTask(orderID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if _, err := h.queue.Enqueue(task, asynq.MaxRetry(3)); err != nil {
log.Printf("enqueue order confirmed: %v", err)
}
w.WriteHeader(http.StatusOK)
}
// Worker handles notification + analytics in one place
func HandleOrderConfirmed(ctx context.Context, t *asynq.Task) error {
var p tasks.OrderConfirmedPayload
if err := json.Unmarshal(t.Payload(), &p); err != nil {
return fmt.Errorf("unmarshal: %w", err)
}
if err := notificationSvc.SendOrderConfirmation(ctx, p.OrderID); err != nil {
return err
}
analyticsSvc.TrackRevenue(ctx, p.OrderID)
return nil
}
Worth noting: asynq ships with
asynqmon, a web UI for queue inspection, retry visibility, and dead-letter management. With Kafka I was tailing consumer lag logs and manually reprocessing failed messages. With asynq it's onedocker runcommand.
The Performance Numbers
These are the metrics we actually own. Checkout latency is Stripe's domain, not ours.
| Metric | Microservices | Go Monolith + asynq |
|---|---|---|
| QR scan validation | ~280ms | ~40ms |
| Webhook to notification | ~1,200ms | ~110ms |
| Order creation API | ~175ms | ~55ms |
| Throughput (req/s) | ~180 | ~1,400 |
| RSS memory (idle) | ~620MB (5 containers) | ~38MB |
| Deploy time | ~4 min (rolling k3s) | ~12 seconds |
The QR scan improvement matters most at a live event. With 300 people at the gate, 280ms per scan creates a real queue. At 40ms it's invisible. The gain comes from cutting two network hops: ticket.Validate() is now a direct function call to a PostgreSQL query.
Where Go Specifically Helps
The compiler enforces your architecture. In a Go monolith with proper package boundaries, circular imports are a compile error. You get architectural discipline without a service mesh.
asynq fits naturally into Go's concurrency model. Each worker is a goroutine, and the task processor is a plain interface:
type OrderConfirmedProcessor struct {
notif *notification.Service
metrics *analytics.Service
}
func (p *OrderConfirmedProcessor) ProcessTask(ctx context.Context, t *asynq.Task) error {
// ...
}
No schema registry, no consumer group management, no offset commits. Redis handles durability. asynq handles retries, timeouts, and scheduling.
When Microservices Actually Make Sense
This isn't an argument against microservices. It's an argument against using them before you have a reason to.
If you have multiple teams owning separate domains independently, services with different scaling characteristics, or compliance requirements that demand data isolation, then the overhead pays for itself. Tixort has one team, homogeneous load, and no compliance requirements. The monolith is the right tool for where the project is right now.
Shopify runs a Rails monolith at significant scale. Stack Overflow serves billions of requests from a handful of servers. The "you'll need to split it eventually" argument only holds if you reach a scale that most products never hit.
What I'd Do Differently
Add module boundaries from day one: separate Go modules per domain inside a monorepo. You get monolith performance now and a clear migration path to independent services if scaling actually requires it later. Don't design for the infrastructure problems you might have. Solve the ones you have now.