DEV Community

Cover image for Your gRPC Protobuf Types Don't Belong in Your Domain Layer
Gabriel Anhaia
Gabriel Anhaia

Posted on

Your gRPC Protobuf Types Don't Belong in Your Domain Layer


You run protoc. It generates Go structs. They have fields that match your domain. So you pass them straight into your service layer.

Three months later, someone adds an optional field to the .proto file. The CI is green. The service compiles. Production starts throwing nil pointer panics at 2 AM because your business logic assumed that field was always present.

The generated struct lied to you. It was never a domain type. It was always a wire format wearing a Go disguise.

The Mess: Proto Structs in Business Logic

Here is what happens when a gRPC handler passes generated types directly into the service layer:

// generated by protoc — lives in pb/ package
type CreateOrderRequest struct {
    CustomerId string
    Items      []*OrderItem
    DeliveryBy *timestamppb.Timestamp
    Notes      *string  // proto optional
}
Enter fullscreen mode Exit fullscreen mode

And the service that consumes it:

func (s *OrderService) CreateOrder(
    ctx context.Context,
    req *pb.CreateOrderRequest,
) (*pb.CreateOrderResponse, error) {

    deadline := req.DeliveryBy.AsTime()
    // panic if DeliveryBy is nil

    note := *req.Notes
    // panic if Notes is nil

    // business logic that now imports
    // "google.golang.org/protobuf/types/known/timestamppb"
    // and the generated pb package
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Count the problems.

Nil pointer panics. Proto optional fields and message fields are pointers. Your proto file says optional string notes = 5; and the generated Go field is *string. A client that omits the field sends nil. Your service dereferences it. Panic.

Protobuf-specific types in business logic. timestamppb.Timestamp is a serialization wrapper. Your domain cares about time.Time. The moment your service imports timestamppb, your domain depends on Google's protobuf library. That is a transport detail bleeding inward.

Breaking changes propagate unchecked. Someone renames a proto field. The generated struct changes. Every function that touched that struct breaks — including domain logic that had no reason to know about the wire format.

Testing becomes painful. To test your service, you now construct protobuf messages in test code. Your tests import pb, timestamppb, wrapperspb. They build proto objects with nil-safe checks. None of that has anything to do with the business rule you are trying to verify.

The Fix: Two Models, One Adapter

The pattern is the same one hexagonal architecture prescribes for HTTP handlers, database rows, and every other external boundary. You keep two separate models:

  1. Transport model — the generated proto structs. They live at the edge.
  2. Domain model — plain Go structs with the types your business logic needs.

An adapter converts between them.

The Domain Model

package order

import "time"

type Order struct {
    ID         string
    CustomerID string
    Items      []LineItem
    DeliveryBy time.Time
    Notes      string
    Status     string
}

type LineItem struct {
    ProductID string
    Quantity  int
    PriceCAD  int64 // cents
}
Enter fullscreen mode Exit fullscreen mode

No pointers for optional-maybe-nil fields. No timestamppb. No generated code. These are the types your service, repository, and tests all speak.

The Inbound Adapter (Proto to Domain)

package grpcadapter

import (
    "fmt"
    "time"

    pb "myapp/gen/orderpb"
    "myapp/internal/order"
)

func toOrder(
    req *pb.CreateOrderRequest,
) (order.Order, error) {
    if req.CustomerId == "" {
        return order.Order{},
            fmt.Errorf("customer_id is required")
    }

    var deliveryBy time.Time
    if req.DeliveryBy != nil {
        deliveryBy = req.DeliveryBy.AsTime()
        if deliveryBy.Before(time.Now()) {
            return order.Order{},
                fmt.Errorf("delivery_by is in the past")
        }
    }

    var notes string
    if req.Notes != nil {
        notes = *req.Notes
    }

    items, err := toLineItems(req.Items)
    if err != nil {
        return order.Order{}, err
    }

    return order.Order{
        CustomerID: req.CustomerId,
        Items:      items,
        DeliveryBy: deliveryBy,
        Notes:      notes,
        Status:     "pending",
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

The item conversion follows the same nil-guard pattern:

func toLineItems(
    pbItems []*pb.OrderItem,
) ([]order.LineItem, error) {
    items := make([]order.LineItem, 0, len(pbItems))
    for _, p := range pbItems {
        if p == nil {
            continue
        }
        if p.Quantity <= 0 {
            return nil,
                fmt.Errorf(
                    "invalid quantity for %s",
                    p.ProductId,
                )
        }
        items = append(items, order.LineItem{
            ProductID: p.ProductId,
            Quantity:  int(p.Quantity),
            PriceCAD:  p.PriceCad,
        })
    }
    if len(items) == 0 {
        return nil,
            fmt.Errorf("at least one item is required")
    }
    return items, nil
}
Enter fullscreen mode Exit fullscreen mode

Every nil check, every type conversion, every proto-to-Go translation lives here. The adapter is the only code that imports the pb package. If the proto definition changes, only this file needs to change.

The Outbound Adapter (Domain to Proto)

The response path mirrors the request path:

func toProtoOrder(
    o order.Order,
) *pb.OrderResponse {
    items := make(
        []*pb.OrderItem, 0, len(o.Items),
    )
    for _, item := range o.Items {
        items = append(items, &pb.OrderItem{
            ProductId: item.ProductID,
            Quantity:  int32(item.Quantity),
            PriceCad:  item.PriceCAD,
        })
    }

    return &pb.OrderResponse{
        Id:         o.ID,
        CustomerId: o.CustomerID,
        Items:      items,
        DeliveryBy: timestamppb.New(o.DeliveryBy),
        Notes:      &o.Notes,
        Status:     o.Status,
    }
}
Enter fullscreen mode Exit fullscreen mode

timestamppb.New() and &o.Notes appear here, at the boundary. Not inside a business rule.

The gRPC Handler (Thin Glue)

func (h *Handler) CreateOrder(
    ctx context.Context,
    req *pb.CreateOrderRequest,
) (*pb.CreateOrderResponse, error) {

    domainOrder, err := toOrder(req)
    if err != nil {
        return nil,
            status.Errorf(codes.InvalidArgument,
                "bad request: %s", err)
    }

    result, err := h.service.CreateOrder(
        ctx, domainOrder,
    )
    if err != nil {
        return nil, toGRPCError(err)
    }

    return &pb.CreateOrderResponse{
        Order: toProtoOrder(result),
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

The handler does three things: convert inbound, call the service, convert outbound. No business logic. No nil-pointer dereferencing. No proto types leaking past this function.

Where the Adapter Lives in Your Project

A layout that works:

myapp/
├── gen/
│   └── orderpb/          # protoc output — never edit
├── internal/
│   ├── order/
│   │   ├── model.go      # domain structs
│   │   ├── service.go    # business logic
│   │   └── ports.go      # repository interface
│   └── platform/
│       ├── grpcadapter/
│       │   ├── handler.go
│       │   ├── convert.go  # toOrder, toProtoOrder
│       │   └── errors.go   # domain error → gRPC status
│       └── postgres/
│           └── order_repo.go
└── cmd/
    └── server/
        └── main.go       # composition root
Enter fullscreen mode Exit fullscreen mode

gen/ is auto-generated. internal/order/ is your domain: it imports nothing from gen/. grpcadapter/ is the only package that touches both.

The Service Stays Clean

func (s *OrderService) CreateOrder(
    ctx context.Context,
    o order.Order,
) (order.Order, error) {
    o.ID = s.idGen.NewID()

    if len(o.Items) == 0 {
        return order.Order{},
            fmt.Errorf("order must have items")
    }

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

    return o, nil
}
Enter fullscreen mode Exit fullscreen mode

Check the imports. context, fmt, and your own order package. No protobuf library. No generated code. No wire-format awareness.

If you add a REST API tomorrow, the HTTP handler writes its own adapter (jsonBody → order.Order) and calls the same service. The service does not change. If you swap gRPC for Connect or Twirp, only the transport adapter changes.

Testing Without Proto Baggage

Test the adapter separately from the service.

Adapter test — verifies the conversion handles edge cases:

func TestToOrder_NilDeliveryBy(t *testing.T) {
    req := &pb.CreateOrderRequest{
        CustomerId: "cust-1",
        Items: []*pb.OrderItem{
            {
                ProductId: "prod-a",
                Quantity:  2,
                PriceCad:  1500,
            },
        },
        DeliveryBy: nil,
    }

    o, err := toOrder(req)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if !o.DeliveryBy.IsZero() {
        t.Errorf(
            "expected zero time, got %v",
            o.DeliveryBy,
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Service test — pure domain types, no proto:

func TestCreateOrder_EmptyItems(t *testing.T) {
    svc := order.NewOrderService(
        &inMemoryRepo{}, &seqIDGen{},
    )

    _, err := svc.CreateOrder(
        context.Background(),
        order.Order{CustomerID: "cust-1"},
    )
    if err == nil {
        t.Fatal("expected error for empty items")
    }
}
Enter fullscreen mode Exit fullscreen mode

The service test has no idea gRPC exists. It never will.

The Objection: "It's Just Boilerplate"

You will hear this. Someone on the team will argue that the adapter is mechanical code that adds no value.

They are right that it is mechanical. They are wrong that it adds no value.

The adapter is a firewall. The proto file will change. Clients evolve, fields get deprecated, new versions ship. When that happens, the blast radius stops at the adapter. Your service tests keep passing. Your domain model stays stable. The person fixing the proto break does not need to understand your business logic. They update two functions in convert.go and move on.

Without the adapter, a proto field rename touches every function that ever read that field. In a service with ten endpoints, that is dozens of files across multiple packages. A five-minute proto change becomes a half-day refactor.

When to Skip This

Small internal tools. Prototyping. One-RPC services where the proto is the domain (a passthrough proxy, a thin CRUD gateway). If your service has three fields and no business logic beyond validation, two separate models are overhead you do not need.

The signal is business logic complexity. Once your service applies discounts, enforces policies, and coordinates across repositories, the proto structs should stop at the door.


If this was useful

This is the core idea behind hexagonal architecture: every external boundary gets an adapter, and the domain never knows what is on the other side. gRPC, HTTP, Kafka, a CLI — the domain does not care.

I wrote a full book on building Go services this way. 22 chapters covering domain modeling, adapter design for every protocol, and the Unit of Work pattern for transactions.

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

Top comments (0)