DEV Community

Cover image for Your Go HTTP Handler Is 400 Lines. That's the Whole Problem
Gabriel Anhaia
Gabriel Anhaia

Posted on

Your Go HTTP Handler Is 400 Lines. That's the Whole Problem


You open a pull request. The diff is one file: handler.go. It's 400 lines. Inside that single function you find JSON parsing, three SQL queries, a discount calculation, an email notification, two retry loops, and a metrics push. The tests require a running PostgreSQL, a fake SMTP server, and a mock metrics endpoint. The review has nine comments. None of them are about business logic, because nobody can find it.

An engineer I work with described their onboarding experience at a previous company: spending the first week trying to figure out where the pricing rules lived, only to discover they were split across four HTTP handlers and a middleware. That's not unusual. That's the default outcome when Go services grow without a plan.

The handler isn't the problem. The handler is the symptom. The problem is that there's no architecture separating what your service does from how it talks to the outside world.

The 400-Line Handler, Annotated

Here's a simplified version of what these handlers look like. You've seen this shape before, even if the specifics were different.

func CreateOrderHandler(
  db *sql.DB,
  mailer *smtp.Client,
) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    // 1. Parse and validate the request
    var req struct {
      UserID string `json:"user_id"`
      Items  []struct {
        ProductID string `json:"product_id"`
        Qty       int    `json:"qty"`
      } `json:"items"`
      CouponCode string `json:"coupon_code"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
      http.Error(w, "bad request", 400)
      return
    }
    if req.UserID == "" || len(req.Items) == 0 {
      http.Error(w, "missing fields", 422)
      return
    }
Enter fullscreen mode Exit fullscreen mode

The request is parsed and validated. Now the handler reaches into the database to confirm the user exists and start pricing the order.

    // 2. Check the user exists
    var userName, userEmail string
    err := db.QueryRowContext(
      r.Context(),
      "SELECT name, email FROM users WHERE id = $1",
      req.UserID,
    ).Scan(&userName, &userEmail)
    if err == sql.ErrNoRows {
      http.Error(w, "user not found", 404)
      return
    }
    if err != nil {
      http.Error(w, "internal error", 500)
      return
    }
Enter fullscreen mode Exit fullscreen mode

User confirmed. The handler moves on to pricing, looping over every line item and querying the products table individually.

    // 3. Look up product prices
    total := 0.0
    for _, item := range req.Items {
      var price float64
      err := db.QueryRowContext(
        r.Context(),
        "SELECT price FROM products WHERE id = $1",
        item.ProductID,
      ).Scan(&price)
      if err != nil {
        http.Error(w, "product not found", 404)
        return
      }
      total += price * float64(item.Qty)
    }
Enter fullscreen mode Exit fullscreen mode

At this point the handler has already touched two database tables and built a running total. It's not done. The coupon logic, the insert, and the email notification are still ahead.

    // 4. Apply coupon discount
    if req.CouponCode != "" {
      var discount float64
      err := db.QueryRowContext(
        r.Context(),
        `SELECT discount_pct FROM coupons
         WHERE code = $1
         AND expires_at > NOW()`,
        req.CouponCode,
      ).Scan(&discount)
      if err == nil {
        total = total * (1 - discount/100)
      }
    }

    // 5. Insert the order
    orderID := uuid.New().String()
    _, err = db.ExecContext(
      r.Context(),
      `INSERT INTO orders (id, user_id, total)
       VALUES ($1, $2, $3)`,
      orderID, req.UserID, total,
    )
    if err != nil {
      http.Error(w, "internal error", 500)
      return
    }
Enter fullscreen mode Exit fullscreen mode

The order is persisted. The handler still has two jobs left: fire a confirmation email and serialize the response.

    // 6. Send confirmation email
    msg := fmt.Sprintf(
      "To: %s\r\nSubject: Order %s\r\n\r\n"+
        "Hi %s, your order total is $%.2f",
      userEmail, orderID, userName, total,
    )
    // ... smtp send logic, retry on transient failure

    // 7. Return the response
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]any{
      "order_id": orderID,
      "total":    total,
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

That's a trimmed-down version. The real ones are worse. They have logging between every step, context-deadline checks, transaction management, audit trail inserts, and feature-flag conditionals scattered throughout.

Count the responsibilities:

  1. HTTP request parsing and validation
  2. User lookup (database)
  3. Price calculation (database + arithmetic)
  4. Coupon application (database + business rule)
  5. Order persistence (database)
  6. Email notification (external service)
  7. HTTP response serialization

Seven responsibilities in one function, seven reasons to change it. And they're all tested as one monolith.

Why This Happens in Go Specifically

Go makes this easy to fall into. The language ships no framework forcing a directory structure, no mandatory Controller base class nudging you toward a service layer. The standard library gives you http.HandlerFunc, and that function signature accepts a response writer and a request. Everything fits inside.

The language is working as designed. Go trusts you to impose structure. When you don't, the handler becomes the junk drawer.

In Rails, the framework pushes you toward models, controllers, and service objects by convention. In Spring Boot, you annotate your way into layered architecture whether you want it or not. In Go, the only thing between you and a 400-line handler is discipline.

Or a pattern.

The Thin Adapter Pattern

The fix is a structural decision, not a refactor. You split the handler into two things:

  1. A domain service that contains the business logic. It knows nothing about HTTP. It accepts typed arguments and returns typed results or errors.
  2. A handler (adapter) that translates between HTTP and the domain. Parse the request. Call the service. Serialize the response. That's it.

Here's the domain service:

type Service struct {
  users   UserRepository
  products ProductRepository
  coupons CouponRepository
  orders  OrderRepository
  notifier Notifier
}

func NewService(
  users UserRepository,
  products ProductRepository,
  coupons CouponRepository,
  orders OrderRepository,
  notifier Notifier,
) *Service {
  return &Service{
    users:    users,
    products: products,
    coupons:  coupons,
    orders:   orders,
    notifier: notifier,
  }
}
Enter fullscreen mode Exit fullscreen mode

The service depends on interfaces, not implementations. Those interfaces are the ports:

type UserRepository interface {
  FindByID(ctx context.Context, id string) (User, error)
}

type ProductRepository interface {
  FindByID(ctx context.Context, id string) (Product, error)
}

type CouponRepository interface {
  FindActiveByCode(
    ctx context.Context,
    code string,
  ) (Coupon, error)
}

type OrderRepository interface {
  Save(ctx context.Context, o Order) error
}

type Notifier interface {
  OrderConfirmed(
    ctx context.Context,
    o Order,
    u User,
  ) error
}
Enter fullscreen mode Exit fullscreen mode

Each interface declares the minimum the domain needs. The domain doesn't know if UserRepository talks to PostgreSQL, DynamoDB, or a test double. It doesn't care.

With those interfaces in place, the business logic reads like what it actually is:

func (s *Service) Place(
  ctx context.Context,
  cmd PlaceOrderCmd,
) (Order, error) {
  user, err := s.users.FindByID(ctx, cmd.UserID)
  if err != nil {
    return Order{}, fmt.Errorf(
      "finding user %s: %w", cmd.UserID, err,
    )
  }

  var total float64
  for _, item := range cmd.Items {
    product, err := s.products.FindByID(
      ctx, item.ProductID,
    )
    if err != nil {
      return Order{}, fmt.Errorf(
        "finding product %s: %w",
        item.ProductID, err,
      )
    }
    total += product.Price * float64(item.Qty)
  }
Enter fullscreen mode Exit fullscreen mode

Pricing is done. The method applies the coupon (if any), persists the order, and fires the notification:

  if cmd.CouponCode != "" {
    coupon, err := s.coupons.FindActiveByCode(
      ctx, cmd.CouponCode,
    )
    if err == nil {
      total = coupon.Apply(total)
    }
  }

  o := Order{
    ID:     NewOrderID(),
    UserID: cmd.UserID,
    Total:  total,
  }

  if err := s.orders.Save(ctx, o); err != nil {
    return Order{}, fmt.Errorf(
      "saving order: %w", err,
    )
  }

  // Fire-and-forget in production; synchronous in tests.
  _ = s.notifier.OrderConfirmed(ctx, o, user)

  return o, nil
}
Enter fullscreen mode Exit fullscreen mode

The PlaceOrderCmd is a plain struct:

type PlaceOrderCmd struct {
  UserID     string
  Items      []OrderItem
  CouponCode string
}

type OrderItem struct {
  ProductID string
  Qty       int
}
Enter fullscreen mode Exit fullscreen mode

No JSON tags. No HTTP concepts. This is a domain command that could come from an HTTP request, a gRPC call, a CLI tool, or a Kafka consumer.

The Handler Becomes Ten Lines

The HTTP handler shrinks to this:

func PlaceOrder(svc *order.Service) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    var req placeOrderRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
      writeError(w, "invalid JSON", http.StatusBadRequest)
      return
    }

    cmd := req.toCmd()

    result, err := svc.Place(r.Context(), cmd)
    if err != nil {
      writeServiceError(w, err)
      return
    }

    writeJSON(w, http.StatusCreated, placeOrderResponse{
      OrderID: result.ID,
      Total:   result.Total,
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

The request and response types handle the HTTP-specific mapping:

type placeOrderRequest struct {
  UserID     string              `json:"user_id"`
  Items      []orderItemRequest  `json:"items"`
  CouponCode string             `json:"coupon_code"`
}

type orderItemRequest struct {
  ProductID string `json:"product_id"`
  Qty       int    `json:"qty"`
}
Enter fullscreen mode Exit fullscreen mode

The toCmd method bridges the HTTP layer and the domain layer. It maps JSON-tagged fields to the domain command struct:

func (r placeOrderRequest) toCmd() order.PlaceOrderCmd {
  items := make([]order.OrderItem, len(r.Items))
  for i, it := range r.Items {
    items[i] = order.OrderItem{
      ProductID: it.ProductID,
      Qty:       it.Qty,
    }
  }
  return order.PlaceOrderCmd{
    UserID:     r.UserID,
    Items:      items,
    CouponCode: r.CouponCode,
  }
}

type placeOrderResponse struct {
  OrderID string  `json:"order_id"`
  Total   float64 `json:"total"`
}
Enter fullscreen mode Exit fullscreen mode

The small helpers keep error formatting consistent across all handlers:

func writeJSON(
  w http.ResponseWriter,
  status int,
  v any,
) {
  w.Header().Set("Content-Type", "application/json")
  w.WriteHeader(status)
  json.NewEncoder(w).Encode(v)
}

func writeError(
  w http.ResponseWriter,
  msg string,
  status int,
) {
  writeJSON(w, status, map[string]string{
    "error": msg,
  })
}
Enter fullscreen mode Exit fullscreen mode

That's the entire adapter. Parse. Map. Call. Map. Write. The handler has no idea what a coupon is, whether there's a database behind the call, or that emails get sent. It translates HTTP into a domain call and a domain result back into HTTP.

What You Actually Gain

Testability without infrastructure. The domain service tests look like this:

func TestPlaceOrder_AppliesCoupon(t *testing.T) {
  svc := order.NewService(
    &fakeUsers{user: order.User{ID: "u1", Name: "Ada", Email: "ada@test.com"}},
    &fakeProducts{price: 100.0},
    &fakeCoupons{discount: 20},
    &fakeOrders{},
    &fakeNotifier{},
  )

  result, err := svc.Place(context.Background(), order.PlaceOrderCmd{
    UserID:     "u1",
    Items:      []order.OrderItem{{ProductID: "p1", Qty: 2}},
    CouponCode: "SAVE20",
  })
  if err != nil {
    t.Fatal(err)
  }

  // 2 * $100 = $200, minus 20% = $160
  if result.Total != 160.0 {
    t.Errorf("got %.2f, want 160.00", result.Total)
  }
}
Enter fullscreen mode Exit fullscreen mode

No Docker, no PostgreSQL, no SMTP server. The test runs in microseconds and verifies the actual business rule you care about. The fakes are five-line structs that implement the interfaces:

type fakeProducts struct {
  price float64
}

func (f *fakeProducts) FindByID(
  _ context.Context, _ string,
) (order.Product, error) {
  return order.Product{Price: f.price}, nil
}
Enter fullscreen mode Exit fullscreen mode

Multiple entry points, zero duplication. Need a gRPC adapter? Write another ten-line handler that calls the same svc.Place. Need a CLI tool that places test orders? Same service, different adapter. The business logic lives in one place.

Readable diffs. When pricing rules change, the diff touches order/service.go. When the JSON contract changes, the diff touches httphandler/order.go. When you switch from PostgreSQL to CockroachDB, the diff touches postgres/order.go. Reviewers know where to look.

Onboarding speed. New developers read main() and see how everything is wired. They read the domain package and understand the business rules without decoding HTTP or SQL noise. The learning curve drops from weeks to hours.

The Decision Rule

Not every handler needs this treatment. A health check endpoint that returns {"status":"ok"} does not need a domain service. A metrics endpoint that reads from a global registry does not need ports and adapters.

The line is straightforward. If the handler contains business logic (decisions, calculations, rules a product manager would recognize), extract it. If it's pure infrastructure glue: parse, call, serialize, done.

A practical heuristic: if you can't describe what the handler does without mentioning HTTP, JSON, SQL, or any specific technology, the business logic is trapped inside the adapter.

The Common Pushback

"This is over-engineering for a small service."

Maybe. If you have three endpoints and one developer, the 400-line handler is survivable. But "small service" is a temporary state. Every service that became unmaintainable was small once. The thin-adapter pattern costs you one extra package and a few interfaces. That's less effort than one round of "let's refactor the handler" six months from now when there are 40 endpoints and three teams touching the same code.

"Go isn't supposed to have layers. That's Java thinking."

Layers for the sake of layers are Java thinking. A domain service that encapsulates business rules isn't a layer — it's separation of concerns. The Go standard library does this everywhere. net/http doesn't contain TLS logic. encoding/json doesn't know about HTTP. Each package owns one thing and depends on interfaces for the rest.

"But I'd need to define so many interfaces."

Yes. Small, focused interfaces. That's the Go way. The standard library has io.Reader, io.Writer, io.Closer, fmt.Stringer — each one a single method. Your UserRepository with one FindByID method is exactly in that spirit.

Start With the Next Handler You Write

You don't need to refactor your entire codebase. The next time you're about to add a new endpoint, start with the domain service. Define the command struct. Write the business logic. Then write the adapter that calls it. Total extra effort: fifteen minutes. Total reduction in future pain: significant.

The handler is a translator. The moment it starts making decisions, you've lost the boundary. Keep it thin, and the architecture takes care of itself.

This thin-adapter pattern is one chapter of a larger story. My book Hexagonal Architecture in Go walks through the full pattern: ports, adapters, domain services, dependency injection in main(), testing at every layer, error handling across boundaries, and migrating existing services incrementally. Twenty-two chapters, from a tangled handler.go to a production-ready architecture.

If you write Go services for a living, it might save you the refactor you've been putting off.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)