Right, so you’ve got your .proto file defined and your Go code generated. You’re feeling pretty good. You can make a call and get a response. Fantastic. Welcome to the appetizer. Now let’s get to the main course: the four ways you can actually structure your communication over a gRPC connection. This is where you move from a simple request-reply to having actual, meaningful conversations between your services.

Think of it like this: a Unary RPC is you asking me a single question and me giving you a single answer. It’s familiar, it’s HTTP/1.1-like, and it’s what you’ve already seen. The streaming variants are where we break from that tradition. This isn’t a single Q&A; it’s a firehose of data, a long-running conversation, or a one-sided monologue. gRPC handles the connection, framing, and flow control for all of it, which is a minor miracle we should be thankful for every day.

Unary RPC: The Old Reliable

This is the workhorse. One request message, one response message. Don’t let its simplicity fool you; it’s the backbone of most operations. The key here is that the client blocks (or awaits, depending on your style) until it gets that single response back from the server.

Let’s say we have a method to get a user. Here’s what the proto definition looks like, and more importantly, what the Go implementation feels like.

service UserService {
  rpc GetUser (GetUserRequest) returns (User) {};
}

message GetUserRequest {
  string user_id = 1;
}

message User {
  string user_id = 1;
  string name = 2;
  string email = 3;
}
// Server-side implementation
type server struct {
    pb.UnimplementedUserServiceServer
    // ... your actual data store would be here
}

func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
    // 1. Validate the request. Is user_id a valid format? Do it here, not later.
    if req.UserId == "" {
        return nil, status.Errorf(codes.InvalidArgument, "user_id cannot be empty")
    }

    // 2. Simulate some database lookup
    user, err := s.db.LookupUser(req.UserId)
    if errors.Is(err, sql.ErrNoRows) {
        // This is a classic. Tell the client it's a NotFound error, not a generic internal error.
        return nil, status.Errorf(codes.NotFound, "user with id %s not found", req.UserId)
    }
    if err != nil {
        // For a real, unexpected DB error, log it but don't send the internal details to the client.
        log.Printf("Database error: %v", err)
        return nil, status.Error(codes.Internal, "internal error")
    }

    // 3. Map your internal model to the protobuf message. Don't just send the DB struct!
    return &pb.User{
        UserId: user.ID,
        Name:   user.FullName,
        Email:  user.EmailAddress,
    }, nil
}

The client call is straightforward, but always handle the status error.

client := pb.NewUserServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

user, err := client.GetUser(ctx, &pb.GetUserRequest{UserId: "123"})
if err != nil {
    // Use the status package to gracefully unpack the error code and message.
    if s, ok := status.FromError(err); ok {
        if s.Code() == codes.NotFound {
            log.Fatalf("User doesn't exist: %s", s.Message())
        }
    }
    log.Fatalf("Could not get user: %v", err)
}
fmt.Printf("User: %v\n", user)

Server Streaming RPC: The Firehose

Here, the client sends a single request, and the server sends back a sequence of responses. You use this for things where the result is too big to send back at once (like a large dataset), or for pushing real-time updates as they happen. The client reads from the stream until it’s closed.

A classic example is sending back a list of results in chunks.

rpc ListUsers (ListUsersRequest) returns (stream User) {};
// Server-side
func (s *server) ListUsers(req *pb.ListUsersRequest, stream pb.UserService_ListUsersServer) error {
    // 1. Maybe you have a pagination cursor in the request. Use it.
    users, err := s.db.GetAllUsers(req.Cursor)
    if err != nil {
        return status.Error(codes.Internal, "failed to fetch users")
    }

    // 2. The magic: send each user as a separate message on the stream.
    for _, user := range users {
        // CRITICAL: Check if the client has given up or cancelled the request.
        // If you don't, you'll be doing work for a client that's long gone.
        select {
        case <-stream.Context().Done():
            return status.FromContextError(stream.Context().Err()).Err()
        default:
        }

        // Convert and send
        if err := stream.Send(&pb.User{
            UserId: user.ID,
            Name:   user.FullName,
            Email:  user.EmailAddress,
        }); err != nil {
            // This usually means the client broke the connection.
            return err
        }
    }

    // A clean return with a nil error signals the client that the stream is done.
    return nil
}

The client side is all about looping over Recv().

stream, err := client.ListUsers(ctx, &pb.ListUsersRequest{})
if err != nil {
    log.Fatalf("Failed to start stream: %v", err)
}

for {
    user, err := stream.Recv()
    if err == io.EOF {
        // Server has closed the stream. This is the happy path.
        break
    }
    if err != nil {
        // This is an actual error, not just the stream closing.
        log.Fatalf("Failed to receive a user: %v", err)
    }
    fmt.Printf("Got user: %s\n", user.Name)
    // Process your user here. Maybe add them to a slice, who knows.
}

Client Streaming RPC: The Dump Truck

The inverse. The client sends a sequence of messages and then waits for the server to process them all and send back a single response. Think of uploading a large file in chunks, or collecting metrics from various processes before aggregating them.

rpc CreateUsers (stream User) returns (UserSummary) {};
// Server-side
func (s *server) CreateUsers(stream pb.UserService_CreateUsersServer) error {
    var successCount int32
    var failureCount int32

    for {
        // Keep reading messages from the client until they're done sending.
        user, err := stream.Recv()
        if err == io.EOF {
            // Client is finished. Now we can send the summary response.
            return stream.SendAndClose(&pb.UserSummary{
                SuccessCount: successCount,
                FailureCount: failureCount,
            })
        }
        if err != nil {
            return err
        }

        // Process each individual user message.
        err = s.db.CreateUser(user)
        if err != nil {
            failureCount++
            log.Printf("Failed to create user %s: %v", user.Name, err)
        } else {
            successCount++
        }
    }
}

The client is now the one doing the sending.

stream, err := client.CreateUsers(ctx)
if err != nil {
    log.Fatalf("Failed to create stream: %v", err)
}

usersToCreate := []*pb.User{...} // your list of users

for _, user := range usersToCreate {
    if err := stream.Send(user); err != nil {
        log.Fatalf("Failed to send user: %v", err)
    }
}

// This call is crucial. It closes the send direction and gets the single response.
summary, err := stream.CloseAndRecv()
if err != nil {
    log.Fatalf("Failed to close and receive: %v", err)
}
fmt.Printf("Summary: %d succeeded, %d failed\n", summary.SuccessCount, summary.FailureCount)

Bidirectional Streaming RPC: The Free-for-All

This is the most flexible and, frankly, the most fun mode. Both sides send a sequence of messages, reading and writing in any order. The two streams are completely independent. You’d use this for things like a real-time chat, a game state synchronizer, or a complex negotiation protocol.

The key here is that you almost always need to handle sending and receiving on separate goroutines because neither side knows when the other is done talking.

rpc Chat (stream ChatMessage) returns (stream ChatMessage) {};
// Server-side - note the bidirectional nature
func (s *server) Chat(stream pb.ChatService_ChatServer) error {
    // Often, you'd launch a goroutine to handle reading from the client.
    // For simplicity, we'll do a loop that handles both, which is fragile.
    for {
        in, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }

        // Process the message and send a response back, maybe asynchronously.
        go func(receivedMsg *pb.ChatMessage) {
            response := processMessage(receivedMsg)
            if err := stream.Send(response); err != nil {
                log.Printf("Failed to send message: %v", err)
            }
        }(in)
    }
}

The client code is similarly complex, requiring coordination. You’re essentially building a state machine. The biggest pitfall here is not properly handling goroutine lifecycle and connection state. If your reader goroutine dies because of an error, your writer goroutine needs to know about it so it can stop trying to send into the void. Use a context.Context that you cancel on any error to coordinate this.

The beauty of bidirectional streaming is the flexibility. The horror of bidirectional streaming is that you are now responsible for managing that flexibility without creating a race condition nightmare. Welcome to the trenches. It’s messy, but it’s where the real work gets done.