Adapters in Go, HTTP, gRPC, and Worker Patterns
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:
- Decodes JSON → typed request struct
- Validates and converts to use case input
- Calls use case
- Maps errors to HTTP status codes
- 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.