background-shape
Adapters in Go, HTTP, gRPC, and Worker Patterns
June 17, 2022 · 4 min read · by Muhammad Amal programming

TL;DR — Adapters translate between the outside world and use case DTOs. HTTP handler: decode JSON → call Execute → encode response. gRPC handler: pb → DTO → Execute → DTO → pb. Worker: queue payload → DTO → Execute. Keep all framework code at this edge.

After use cases, the adapter layer wires them to actual inputs. HTTP is the most common; gRPC and worker patterns share the shape.

HTTP adapter

package httpadapter

import (
    "encoding/json"
    "net/http"

    "github.com/go-chi/chi/v5"
    "github.com/google/uuid"
    "yourorg/billing/internal/domain"
    "yourorg/billing/internal/usecase"
)

type SubscriptionHandler struct {
    startSub  *usecase.StartSubscription
    cancelSub *usecase.CancelSubscription
}

func NewSubscriptionHandler(s *usecase.StartSubscription, c *usecase.CancelSubscription) *SubscriptionHandler {
    return &SubscriptionHandler{startSub: s, cancelSub: c}
}

func (h *SubscriptionHandler) Routes() http.Handler {
    r := chi.NewRouter()
    r.Post("/", h.create)
    r.Post("/{id}/cancel", h.cancel)
    return r
}

type createSubscriptionRequest struct {
    CustomerID string `json:"customer_id"`
    PlanID     string `json:"plan_id"`
}

type subscriptionResponse struct {
    ID         string `json:"id"`
    CustomerID string `json:"customer_id"`
    PlanID     string `json:"plan_id"`
    Status     string `json:"status"`
    RenewsAt   string `json:"renews_at"`
}

func (h *SubscriptionHandler) create(w http.ResponseWriter, r *http.Request) {
    var req createSubscriptionRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON")
        return
    }
    custID, err := uuid.Parse(req.CustomerID)
    if err != nil {
        writeError(w, http.StatusBadRequest, "invalid customer_id")
        return
    }

    out, err := h.startSub.Execute(r.Context(), usecase.StartSubscriptionInput{
        CustomerID: custID,
        PlanID:     req.PlanID,
    })
    if err != nil {
        h.handleError(w, err)
        return
    }

    writeJSON(w, http.StatusCreated, toResponse(out.Subscription))
}

func (h *SubscriptionHandler) handleError(w http.ResponseWriter, err error) {
    switch {
    case errors.Is(err, domain.ErrInvalidPlan):
        writeError(w, http.StatusBadRequest, err.Error())
    case errors.Is(err, domain.ErrSubscriptionNotFound):
        writeError(w, http.StatusNotFound, "subscription not found")
    case errors.Is(err, domain.ErrAlreadyCanceled):
        writeError(w, http.StatusConflict, err.Error())
    default:
        log.Printf("internal error: %v", err)
        writeError(w, http.StatusInternalServerError, "internal error")
    }
}

func toResponse(s *domain.Subscription) subscriptionResponse {
    return subscriptionResponse{
        ID:         s.ID().String(),
        CustomerID: s.CustomerID().String(),
        PlanID:     s.Plan().ID,
        Status:     string(s.Status()),
        RenewsAt:   s.RenewsAt().Format(time.RFC3339),
    }
}

The handler:

  1. Decodes JSON → typed request struct
  2. Validates and converts to use case input
  3. Calls use case
  4. Maps errors to HTTP status codes
  5. Encodes response

That’s the entire adapter responsibility. No business logic. Framework-specific code (chi, encoding/json) lives only here.

The error mapping is critical — domain errors → HTTP codes is an adapter concern. Use cases shouldn’t know about HTTP.

gRPC adapter

Same shape, different transport:

package grpcadapter

import (
    "context"
    billingv1 "yourorg/billing/proto/billing/v1"
    "yourorg/billing/internal/usecase"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

type BillingServer struct {
    billingv1.UnimplementedBillingServer
    startSub *usecase.StartSubscription
}

func NewBillingServer(s *usecase.StartSubscription) *BillingServer {
    return &BillingServer{startSub: s}
}

func (s *BillingServer) StartSubscription(ctx context.Context, req *billingv1.StartSubscriptionRequest) (*billingv1.Subscription, error) {
    custID, err := uuid.Parse(req.CustomerId)
    if err != nil {
        return nil, status.Error(codes.InvalidArgument, "invalid customer_id")
    }

    out, err := s.startSub.Execute(ctx, usecase.StartSubscriptionInput{
        CustomerID: custID,
        PlanID:     req.PlanId,
    })
    if err != nil {
        return nil, mapErr(err)
    }

    return toProto(out.Subscription), nil
}

func mapErr(err error) error {
    switch {
    case errors.Is(err, domain.ErrInvalidPlan):
        return status.Error(codes.InvalidArgument, err.Error())
    case errors.Is(err, domain.ErrSubscriptionNotFound):
        return status.Error(codes.NotFound, "not found")
    default:
        return status.Error(codes.Internal, "internal error")
    }
}

Same use case. Different transport. Different error code mapping (gRPC codes vs HTTP statuses). The use case doesn’t change.

This is what “replaceable transport” means in Clean Architecture. Want to add gRPC alongside HTTP? Add an adapter. Use case stays the same.

Worker adapter (queue consumer)

For message-driven inputs:

package worker

import (
    "context"
    "encoding/json"
    "yourorg/billing/internal/usecase"
)

type EventConsumer struct {
    subscribe *usecase.SubscribeFromEvent
    queue     QueueClient
}

type subscribeEventPayload struct {
    CustomerID string `json:"customer_id"`
    PlanID     string `json:"plan_id"`
}

func (c *EventConsumer) Run(ctx context.Context) error {
    return c.queue.Consume(ctx, "subscribe-events", func(msg Message) error {
        var payload subscribeEventPayload
        if err := json.Unmarshal(msg.Body, &payload); err != nil {
            return err  // probably nack and dead-letter
        }

        custID, _ := uuid.Parse(payload.CustomerID)
        _, err := c.subscribe.Execute(ctx, usecase.StartSubscriptionInput{
            CustomerID: custID,
            PlanID:     payload.PlanID,
        })
        return err
    })
}

Same use case as the HTTP handler. The worker is just another adapter.

Multiple transports for the same use case

A real billing service often has:

  • HTTP endpoint for user-initiated start
  • gRPC endpoint for internal service-initiated start
  • Worker for event-driven start (after signup)

All three call the same StartSubscription use case. Behavior is identical regardless of how the call arrives. That’s the win.

Routing setup in main.go

func main() {
    cfg := mustLoadConfig()
    pool := mustOpenPostgres(cfg.DBURL)

    // adapters
    subRepo := postgres.NewSubscriptionRepository(pool)
    planRepo := postgres.NewPlanRepository(pool)
    pub := kafka.NewPublisher(cfg.KafkaBrokers)

    // use cases
    startSub := usecase.NewStartSubscription(subRepo, planRepo, pub, time.Now)
    cancelSub := usecase.NewCancelSubscription(subRepo, pub, time.Now)

    // HTTP
    r := chi.NewRouter()
    r.Use(middleware.RequestID, middleware.Recoverer, middleware.Timeout(10*time.Second))
    subH := httpadapter.NewSubscriptionHandler(startSub, cancelSub)
    r.Mount("/v1/subscriptions", subH.Routes())

    // gRPC (optional)
    grpcSrv := grpc.NewServer()
    billingv1.RegisterBillingServer(grpcSrv, grpcadapter.NewBillingServer(startSub))

    // start everything; gracefully shut down on signal
    runServers(r, grpcSrv, cfg)
}

Same use cases registered on multiple transports.

Common Pitfalls

Business logic in handlers. “Just this once” check-the-balance in the controller. No. Use case.

Domain types serialized directly. json.Marshal(sub) — exposes internal fields, formats wrong. Use response DTO.

Bypassing use cases. Handler reaching into the repository directly. Defeats the architecture.

HTTP error codes in use cases. Use cases return domain errors; adapters map to HTTP/gRPC.

Forgetting context propagation. Always pass r.Context() into Execute. Loses cancellation otherwise.

One giant handler. Split by resource. SubscriptionHandler, InvoiceHandler. Each mounts its own subrouter.

Wrapping Up

Adapters are the thin shell. Decode → validate → use case → encode → respond. Framework code lives nowhere else. Monday pivots to the Laravel side: Clean Architecture in Laravel 9.