syntax = "proto3";
package myapi.v1;
option go_package = "github.com/myorg/myapi/gen/go/myapi/v1";
// Messages
message User {
string id = 1;
string name = 2;
string email = 3;
UserRole role = 4;
google.protobuf.Timestamp created_at = 5;
optional string bio = 6; // Explicit optional (presence tracking)
repeated string tags = 7; // List
map<string, string> metadata = 8; // Key-value map
}
// Enums (always start with 0 = UNSPECIFIED)
enum UserRole {
USER_ROLE_UNSPECIFIED = 0;
USER_ROLE_ADMIN = 1;
USER_ROLE_MEMBER = 2;
USER_ROLE_VIEWER = 3;
}
// Oneof (mutually exclusive fields)
message Notification {
string id = 1;
oneof channel {
EmailNotification email = 2;
SmsNotification sms = 3;
PushNotification push = 4;
}
}
message EmailNotification {
string subject = 1;
string body = 2;
}
message SmsNotification {
string phone = 1;
string text = 2;
}
message PushNotification {
string title = 1;
string body = 2;
}
import "google/protobuf/timestamp.proto"; // Timestamp
import "google/protobuf/duration.proto"; // Duration
import "google/protobuf/empty.proto"; // Empty (no fields)
import "google/protobuf/wrappers.proto"; // Nullable primitives
import "google/protobuf/struct.proto"; // Dynamic JSON-like
import "google/protobuf/field_mask.proto"; // Partial updates
import "google/protobuf/any.proto"; // Type-erased message
message UpdateUserRequest {
string id = 1;
User user = 2;
google.protobuf.FieldMask update_mask = 3; // Which fields to update
}
| Rule | Example |
|---|---|
| Field numbers are forever | Never reuse a deleted field number |
| Enums start at 0 = UNSPECIFIED | USER_ROLE_UNSPECIFIED = 0 |
Use optional for presence |
Distinguish "not set" from default value |
| Prefix enum values with type name | USER_ROLE_ADMIN not ADMIN |
Package = org.service.v1 |
Enables API versioning |
Avoid float/double for money |
Use int64 cents or string |
| Use FieldMask for partial updates | Explicit about which fields changed |
| Reserved deleted fields | reserved 5, 6; reserved "old_field"; |
service UserService {
// Unary - simple request/response
rpc GetUser(GetUserRequest) returns (GetUserResponse);
// Server streaming - server sends multiple responses
rpc ListUsers(ListUsersRequest) returns (stream User);
// Client streaming - client sends multiple requests
rpc UploadUserPhotos(stream UploadPhotoRequest) returns (UploadSummary);
// Bidirectional streaming - both sides stream
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
message GetUserRequest {
string id = 1;
}
message GetUserResponse {
User user = 1;
}
message ListUsersRequest {
int32 page_size = 1;
string page_token = 2;
string filter = 3;
}
// Pagination (AIP-158 style)
message ListUsersRequest {
int32 page_size = 1; // Max items per page
string page_token = 2; // Opaque token from previous response
}
message ListUsersResponse {
repeated User users = 1;
string next_page_token = 2; // Empty = no more pages
int32 total_size = 3; // Optional total count
}
// Batch operations
message BatchGetUsersRequest {
repeated string ids = 1; // Max 100
}
message BatchGetUsersResponse {
repeated User users = 1;
}
package main
import (
"context"
"log"
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/myorg/myapi/gen/go/myapi/v1"
)
type userServer struct {
pb.UnimplementedUserServiceServer // Forward compatibility
store UserStore
}
func (s *userServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
if req.GetId() == "" {
return nil, status.Error(codes.InvalidArgument, "id is required")
}
user, err := s.store.Get(ctx, req.GetId())
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "user %s not found", req.GetId())
}
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
return &pb.GetUserResponse{User: user}, nil
}
// Server streaming
func (s *userServer) ListUsers(req *pb.ListUsersRequest, stream pb.UserService_ListUsersServer) error {
users, err := s.store.List(stream.Context(), req)
if err != nil {
return status.Errorf(codes.Internal, "failed to list users: %v", err)
}
for _, user := range users {
if err := stream.Send(user); err != nil {
return err
}
}
return nil
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
server := grpc.NewServer(
grpc.UnaryInterceptor(loggingInterceptor),
grpc.ChainUnaryInterceptor(authInterceptor, loggingInterceptor),
)
pb.RegisterUserServiceServer(server, &userServer{store: NewUserStore()})
log.Println("gRPC server listening on :50051")
if err := server.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
func main() {
conn, err := grpc.Dial("localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithUnaryInterceptor(retryInterceptor),
)
if err != nil {
log.Fatalf("failed to connect: %v", err)
}
defer conn.Close()
client := pb.NewUserServiceClient(conn)
// Unary call with deadline
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "user-123"})
if err != nil {
st, ok := status.FromError(err)
if ok {
log.Printf("gRPC error: code=%s, message=%s", st.Code(), st.Message())
}
return
}
log.Printf("User: %s", resp.GetUser().GetName())
// Server streaming
stream, err := client.ListUsers(ctx, &pb.ListUsersRequest{PageSize: 100})
if err != nil {
log.Fatal(err)
}
for {
user, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
log.Fatal(err)
}
log.Printf("User: %s", user.GetName())
}
}
# Cargo.toml
[dependencies]
tonic = "0.12"
prost = "0.13"
tokio = { version = "1", features = ["full"] }
[build-dependencies]
tonic-build = "0.12"
// build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::compile_protos("proto/myapi/v1/user.proto")?;
Ok(())
}
use tonic::{Request, Response, Status};
pub mod myapi {
pub mod v1 {
tonic::include_proto!("myapi.v1");
}
}
use myapi::v1::user_service_server::{UserService, UserServiceServer};
use myapi::v1::{GetUserRequest, GetUserResponse, User};
#[derive(Default)]
pub struct MyUserService;
#[tonic::async_trait]
impl UserService for MyUserService {
async fn get_user(
&self,
request: Request<GetUserRequest>,
) -> Result<Response<GetUserResponse>, Status> {
let req = request.into_inner();
if req.id.is_empty() {
return Err(Status::invalid_argument("id is required"));
}
// Fetch user from store...
let user = User {
id: req.id,
name: "Alice".into(),
email: "alice@example.com".into(),
..Default::default()
};
Ok(Response::new(GetUserResponse { user: Some(user) }))
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr = "[::1]:50051".parse()?;
let service = MyUserService::default();
tonic::transport::Server::builder()
.add_service(UserServiceServer::new(service))
.serve(addr)
.await?;
Ok(())
}
use myapi::v1::user_service_client::UserServiceClient;
use myapi::v1::GetUserRequest;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut client = UserServiceClient::connect("http://[::1]:50051").await?;
let request = tonic::Request::new(GetUserRequest {
id: "user-123".into(),
});
let response = client.get_user(request).await?;
println!("User: {:?}", response.into_inner().user);
Ok(())
}
func loggingInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
start := time.Now()
// Extract metadata
md, _ := metadata.FromIncomingContext(ctx)
requestID := md.Get("x-request-id")
resp, err := handler(ctx, req)
st, _ := status.FromError(err)
log.Printf("method=%s duration=%s status=%s request_id=%v",
info.FullMethod, time.Since(start), st.Code(), requestID)
return resp, err
}
func authInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "no metadata")
}
tokens := md.Get("authorization")
if len(tokens) == 0 {
return nil, status.Error(codes.Unauthenticated, "no token")
}
claims, err := validateToken(tokens[0])
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid token")
}
// Add claims to context
ctx = context.WithValue(ctx, claimsKey, claims)
return handler(ctx, req)
}
server := grpc.NewServer(
grpc.ChainUnaryInterceptor(
recoveryInterceptor, // Panic recovery (outermost)
loggingInterceptor, // Request logging
metricsInterceptor, // Prometheus metrics
authInterceptor, // Authentication
validationInterceptor, // Request validation
),
grpc.ChainStreamInterceptor(
streamLoggingInterceptor,
streamAuthInterceptor,
),
)
| Code | Name | Use When |
|---|---|---|
| 0 | OK | Success |
| 1 | CANCELLED | Client cancelled |
| 2 | UNKNOWN | Unknown error (avoid - be specific) |
| 3 | INVALID_ARGUMENT | Bad request (validation) |
| 4 | DEADLINE_EXCEEDED | Timeout |
| 5 | NOT_FOUND | Resource doesn't exist |
| 6 | ALREADY_EXISTS | Conflict (duplicate) |
| 7 | PERMISSION_DENIED | Authorized but not allowed |
| 8 | RESOURCE_EXHAUSTED | Rate limit, quota |
| 9 | FAILED_PRECONDITION | State not ready (e.g., non-empty directory) |
| 10 | ABORTED | Concurrency conflict (retry) |
| 11 | OUT_OF_RANGE | Seek past end |
| 12 | UNIMPLEMENTED | Method not implemented |
| 13 | INTERNAL | Internal server error |
| 14 | UNAVAILABLE | Service down (retry with backoff) |
| 16 | UNAUTHENTICATED | No valid credentials |
import (
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/status"
)
func (s *server) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.CreateUserResponse, error) {
// Validation with rich error details
var violations []*errdetails.BadRequest_FieldViolation
if req.GetEmail() == "" {
violations = append(violations, &errdetails.BadRequest_FieldViolation{
Field: "email",
Description: "Email is required",
})
}
if len(req.GetName()) < 2 {
violations = append(violations, &errdetails.BadRequest_FieldViolation{
Field: "name",
Description: "Name must be at least 2 characters",
})
}
if len(violations) > 0 {
st := status.New(codes.InvalidArgument, "validation failed")
br := &errdetails.BadRequest{FieldViolations: violations}
st, _ = st.WithDetails(br)
return nil, st.Err()
}
// ... proceed
}
| gRPC Code | HTTP Status |
|---|---|
| OK | 200 |
| INVALID_ARGUMENT | 400 |
| UNAUTHENTICATED | 401 |
| PERMISSION_DENIED | 403 |
| NOT_FOUND | 404 |
| ALREADY_EXISTS | 409 |
| RESOURCE_EXHAUSTED | 429 |
| CANCELLED | 499 |
| INTERNAL | 500 |
| UNIMPLEMENTED | 501 |
| UNAVAILABLE | 503 |
| DEADLINE_EXCEEDED | 504 |
// Always set deadlines - never leave RPCs unbounded
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "user-123"})
if err != nil {
st, _ := status.FromError(err)
if st.Code() == codes.DeadlineExceeded {
// Handle timeout - maybe retry with longer deadline
}
}
Deadlines automatically propagate through the call chain. If service A calls service B with a 5s deadline, and A takes 2s, B gets the remaining 3s.
// Server-side: check remaining time
deadline, ok := ctx.Deadline()
if ok {
remaining := time.Until(deadline)
if remaining < 100*time.Millisecond {
return nil, status.Error(codes.DeadlineExceeded, "insufficient time remaining")
}
}
// Built-in: grpc.health.v1.Health
service Health {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}
message HealthCheckRequest {
string service = 1; // Empty = overall health
}
message HealthCheckResponse {
enum ServingStatus {
UNKNOWN = 0;
SERVING = 1;
NOT_SERVING = 2;
SERVICE_UNKNOWN = 3;
}
ServingStatus status = 1;
}
import "google.golang.org/grpc/health"
import healthpb "google.golang.org/grpc/health/grpc_health_v1"
server := grpc.NewServer()
healthServer := health.NewServer()
healthpb.RegisterHealthServer(server, healthServer)
// Set status
healthServer.SetServingStatus("myapi.v1.UserService", healthpb.HealthCheckResponse_SERVING)
// Kubernetes uses grpc_health_probe
// livenessProbe:
// exec:
// command: ["/bin/grpc_health_probe", "-addr=:50051"]
import "google.golang.org/grpc/reflection"
server := grpc.NewServer()
reflection.Register(server) // Enable for dev/staging
# List services
grpcurl -plaintext localhost:50051 list
# Describe a service
grpcurl -plaintext localhost:50051 describe myapi.v1.UserService
# Call a method
grpcurl -plaintext -d '{"id": "user-123"}' \
localhost:50051 myapi.v1.UserService/GetUser
# Server streaming
grpcurl -plaintext -d '{"page_size": 10}' \
localhost:50051 myapi.v1.UserService/ListUsers
# With metadata (headers)
grpcurl -plaintext \
-H 'authorization: Bearer token123' \
-d '{"id": "user-123"}' \
localhost:50051 myapi.v1.UserService/GetUser
# Lint proto files
buf lint
# Detect breaking changes
buf breaking --against '.git#branch=main'
# Generate code
buf generate
# buf.yaml
version: v2
lint:
use:
- STANDARD
breaking:
use:
- WIRE_JSON
Browsers cannot use gRPC natively (no HTTP/2 trailers, no bidirectional streaming). Solutions:
| Solution | Approach | Streaming | Ecosystem |
|---|---|---|---|
| gRPC-Web | Proxy (Envoy) translates | Server-streaming only | Google official |
| Connect | Native HTTP/1.1 + HTTP/2 | All patterns via HTTP/2 | Buf (connectrpc.com) |
| gRPC-Gateway | Generate REST from proto | None (REST) | grpc-ecosystem |
// Same .proto files - no changes needed
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
// TypeScript client (works in browser natively)
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { UserService } from "./gen/myapi/v1/user_connect";
const transport = createConnectTransport({
baseUrl: "https://api.example.com",
});
const client = createClient(UserService, transport);
const response = await client.getUser({ id: "user-123" });
console.log(response.user?.name);
Connect supports three protocols simultaneously:
Many production systems use both:
[Browser] --REST/GraphQL--> [API Gateway] --gRPC--> [User Service]
--gRPC--> [Order Service]
--gRPC--> [Payment Service]