gRPC in Go: Building High-Performance Service-to-Service APIs
REST is great for public APIs. For internal service-to-service communication, gRPC offers strongly-typed contracts, bidirectional streaming, and significantly better performance.
Why gRPC Over REST for Internal Services
| Feature | REST+JSON | gRPC+Protobuf |
|---|---|---|
| Type safety | Runtime | Compile-time |
| Serialization | ~5-10x slower | Binary, fast |
| Streaming | SSE/WebSocket | Built-in (4 modes) |
| Code generation | Optional | First-class |
Define Your Contract First
syntax = "proto3";
package order.v1;
service OrderService {
rpc CreateOrder(CreateOrderRequest) returns (CreateOrderResponse);
rpc GetOrder(GetOrderRequest) returns (Order);
rpc WatchOrderStatus(WatchRequest) returns (stream OrderStatusEvent);
}
message Order {
string id = 1;
string user_id = 2;
OrderStatus status = 3;
double total = 4;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_PENDING = 1;
ORDER_STATUS_CONFIRMED = 2;
ORDER_STATUS_SHIPPED = 3;
}
Implementing the Server
type orderServer struct {
orderv1.UnimplementedOrderServiceServer
svc OrderService
}
func (s *orderServer) CreateOrder(ctx context.Context, req *orderv1.CreateOrderRequest) (*orderv1.CreateOrderResponse, error) {
order, err := s.svc.PlaceOrder(ctx, mapRequestToDomain(req))
if err != nil {
if errors.Is(err, ErrInsufficientInventory) {
return nil, status.Errorf(codes.FailedPrecondition, "insufficient inventory")
}
return nil, status.Errorf(codes.Internal, "internal error")
}
return &orderv1.CreateOrderResponse{Order: mapDomainToProto(order)}, nil
}
// Server streaming: push status updates to the client
func (s *orderServer) WatchOrderStatus(req *orderv1.WatchRequest, stream orderv1.OrderService_WatchOrderStatusServer) error {
ch, cancel := s.svc.SubscribeToOrderStatus(stream.Context(), req.OrderId)
defer cancel()
for {
select {
case <-stream.Context().Done():
return nil
case event, ok := <-ch:
if !ok {
return nil
}
if err := stream.Send(&orderv1.OrderStatusEvent{
OrderId: event.OrderID,
NewStatus: orderv1.OrderStatus(event.Status),
}); err != nil {
return err
}
}
}
}
Server with Interceptors
func NewGRPCServer(svc OrderService) *grpc.Server {
srv := grpc.NewServer(
grpc.ChainUnaryInterceptor(
otelgrpc.UnaryServerInterceptor(),
grpcRecovery.UnaryServerInterceptor(),
),
grpc.KeepaliveParams(keepalive.ServerParameters{
MaxConnectionIdle: 15 * time.Second,
Time: 5 * time.Second,
}),
)
orderv1.RegisterOrderServiceServer(srv, &orderServer{svc: svc})
reflection.Register(srv) // enables grpcurl in dev
return srv
}
Client with Retry Policy
func NewOrderClient(addr string) (orderv1.OrderServiceClient, error) {
conn, err := grpc.NewClient(addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultServiceConfig(`{
"loadBalancingPolicy": "round_robin",
"retryPolicy": {
"MaxAttempts": 3,
"InitialBackoff": "0.1s",
"MaxBackoff": "2s",
"BackoffMultiplier": 2,
"RetryableStatusCodes": ["UNAVAILABLE"]
}
}`),
)
if err != nil { return nil, err }
return orderv1.NewOrderServiceClient(conn), nil
}
Error Mapping
func toGRPCError(err error) error {
var domainErr *DomainError
if !errors.As(err, &domainErr) {
return status.Errorf(codes.Internal, "unexpected error")
}
switch domainErr.Code {
case ErrNotFound:
return status.Errorf(codes.NotFound, domainErr.Message)
case ErrInvalidInput:
return status.Errorf(codes.InvalidArgument, domainErr.Message)
case ErrConflict:
return status.Errorf(codes.AlreadyExists, domainErr.Message)
default:
return status.Errorf(codes.Internal, "internal error")
}
}
gRPC with Go gives you compile-time type safety across service boundaries — an entire class of runtime bugs simply disappears.