GoBackendMicroservicesArchitecturePerformance

Why I Replaced a Kafka-Backed Microservice Architecture with a Go Monolith

After running Tixort on a microservice stack with Kafka, k3s, and five independently deployed services, I rewrote it as a Go monolith. Latency dropped. Throughput went up. Ops burden collapsed. Here's the full technical breakdown.

BP
Bimal Pandey
·June 11, 2026·5 min read

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 one docker run command.

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.