Protocol Buffers: La Fuente de Verdad de Tu API

El contrato de API reemplaza al YAML de OpenAPI y a los editores Swagger. Tu contrato de API es un archivo .proto, y todo lo demás se genera de ahí.

// proto/blog/v1/blog.proto
syntax = "proto3";

package blog.v1;

import "google/protobuf/timestamp.proto";
import "google/protobuf/field_mask.proto";
import "buf/validate/validate.proto";

message Post {
 string id = 1;
 string title = 2 [(buf.validate.field).string.min_len = 1];
 string content = 3;
 string author_id = 4;
 repeated string tags = 5;
 PostStatus status = 6;
 google.protobuf.Timestamp created_at = 7;
 google.protobuf.Timestamp updated_at = 8;
 optional string featured_image_url = 9;
}

enum PostStatus {
 POST_STATUS_UNSPECIFIED = 0;
 POST_STATUS_DRAFT = 1;
 POST_STATUS_PUBLISHED = 2;
 POST_STATUS_ARCHIVED = 3;
}

message CreatePostRequest {
 string title = 1 [(buf.validate.field).string = {min_len: 1, max_len: 200}];
 string content = 2 [(buf.validate.field).string.min_len = 1];
 repeated string tags = 3 [(buf.validate.field).repeated.max_items = 10];
 optional string featured_image_url = 4;
}

message CreatePostResponse {
 Post post = 1;
}

message ListPostsRequest {
 int32 page_size = 1 [(buf.validate.field).int32 = {gte: 1, lte: 100}];
 string page_token = 2;
 optional PostStatus status_filter = 3;
}

message ListPostsResponse {
 repeated Post posts = 1;
 string next_page_token = 2;
 int32 total_count = 3;
}

message StreamPostsRequest {
 repeated string author_ids = 1;
 repeated PostStatus status_filter = 2;
}

service BlogService {
 rpc CreatePost(CreatePostRequest) returns (CreatePostResponse);
 rpc ListPosts(ListPostsRequest) returns (ListPostsResponse);
 rpc StreamPosts(StreamPostsRequest) returns (stream Post);
}

Observar en algunas cosas. El keyword optional hace trabajo real aquí, en proto3, todos los campos tienen defaults de zero-value. Un string defaultea a "", un int32 a 0. Sin optional, no es posible saber si el cliente mandó un string vacío o no mandó el campo directamente. Para operaciones de update, esa distinción importa muchísimo.

El FieldMask en el request de update es la forma proto de hacer updates parciales. En vez del debate REST sobre semántica de PATCH, el cliente dice explícitamente "estoy actualizando estos campos" y el servidor lo respeta.

Generación de Código: Automatización del Contrato

Usando buf (el toolchain moderno de protobuf):

# buf.gen.yaml
version: v2
plugins:
 - remote: buf.build/protocolbuffers/go
 out: gen/go
 opt: paths=source_relative
 - remote: buf.build/connectrpc/go
 out: gen/go
 opt: paths=source_relative
 - remote: buf.build/connectrpc/es
 out: gen/ts
 opt: target=ts
 - remote: buf.build/bufbuild/es
 out: gen/ts
 opt: target=ts
buf generate

Eso genera interfaces tipadas de servidor en Go, stubs de cliente en Go, stubs de cliente en TypeScript, y helpers de validación. De un solo archivo .proto. Cuando el proto cambia, regenerar muestra cada ubicación que se rompió en tiempo de compilación. Difícil lograr eso con una spec OpenAPI.

El Servidor Go: ConnectRPC en la Práctica

type BlogServer struct {
 db *Database
}

func (s *BlogServer) CreatePost(
 ctx context.Context,
 req *connect.Request[blogv1.CreatePostRequest],
) (*connect.Response[blogv1.CreatePostResponse], error) {
 post := &blogv1.Post{
 Id: generateID(),
 Title: req.Msg.Title,
 Content: req.Msg.Content,
 AuthorId: getUserID(ctx),
 Tags: req.Msg.Tags,
 Status: blogv1.PostStatus_POST_STATUS_DRAFT,
 CreatedAt: timestamppb.Now(),
 }

 if err := s.db.Posts.Create(ctx, post); err != nil {
 return nil, connect.NewError(connect.CodeInternal, err)
 }

 return connect.NewResponse(&blogv1.CreatePostResponse{
 Post: post,
 }), nil
}

func (s *BlogServer) StreamPosts(
 ctx context.Context,
 req *connect.Request[blogv1.StreamPostsRequest],
 stream *connect.ServerStream[blogv1.Post],
) error {
 ch, cleanup := s.db.Posts.Subscribe(ctx, req.Msg.AuthorIds)
 defer cleanup()

 ticker := time.NewTicker(30 * time.Second)
 defer ticker.Stop()

 for {
 select {
 case <-ctx.Done():
 return nil
 case post, ok := <-ch:
 if !ok {
 return nil
 }
 if err := stream.Send(post); err != nil {
 return err
 }
 case <-ticker.C:
 // Keepalive para que el LB no corte el stream
 }
 }
}

ConnectRPC es simplemente un handler HTTP. Se monta en el mux estándar de net/http. Puede montarlo junto a tus endpoints REST, tus health checks, tu endpoint de métricas. Esa componibilidad es una ventaja importante sobre gRPC-Go crudo, que quiere adueñarse de todo el ciclo de vida del servidor.

El Cliente TypeScript: Browser + Node.js

import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { BlogService } from "./gen/ts/blog/v1/blog_pb";

const transport = createConnectTransport({
 baseUrl: "https://api.miapp.com",
});

const client = createClient(BlogService, transport);

// Llamada unaria, completamente tipada
async function createPost(title: string, content: string) {
 const response = await client.createPost({
 title,
 content,
 tags: ["engineering"],
 });
 console.log("Creado:", response.post?.id);
 return response.post;
}

// Server streaming, feed en tiempo real
async function watchPosts(authorIds: string[]) {
 for await (const post of client.streamPosts({ authorIds })) {
 console.log("Post nuevo:", post.title);
 updateUI(post);
 }
}

Todo los campos están tipados. Todos los métodos están tipados. La llamada a createPost no compila si pasás un número como title o te olvidás un campo requerido.

La Comparación Honesta: gRPC vs REST vs tRPC

Basándose en experiencia de producción con los tres, la verdad sin barniz:

Type safety a través del cable: gRPC/ConnectRPC gana. El contrato se define una vez en .proto y el código generado está garantizado de matchear. REST con codegen de OpenAPI se acerca pero las specs se desincronan. tRPC tiene excelente type safety pero solo dentro de TypeScript.

Performance: Protobuf binario es 3-10x más pequeño que JSON para payloads típicos. En calls server-to-server de alto throughput, esto importa. Para un browser cargando una sola página, la diferencia es negligible.

Streaming: gRPC tiene streaming de primera clase. REST requiere WebSockets o SSE, que son protocolos atornillados. tRPC soporta subscriptions vía WebSockets pero se siente como un afterthought.

Ecosistema: REST gana abrumadoramente. Toda herramienta, todo proxy, todo sistema de monitoreo entiende HTTP/JSON. gRPC requiere infraestructura aware de HTTP/2. ConnectRPC puentea esta brecha soportando ambos protocolos.

Load Balancing: El Gotcha que Destroza Deploys

gRPC usa HTTP/2, que multiplexa muchos requests sobre una sola conexión TCP. Genial para performance pero terrible para load balancing.

Un load balancer L4 estándar (como AWS NLB) balancea a nivel de conexión. Si tu cliente gRPC abre una conexión al load balancer, todos los requests van al mismo backend. Tu servicio "balanceado" tiene todo el tráfico pegándole a un pod.

Soluciones:

Load balancing L7: Utilizar un proxy L7 que entienda frames HTTP/2, Envoy, nginx (con módulo gRPC), o LBs cloud-native como AWS ALB o el LB gRPC-aware de GCP.

Balanceo client-side: El cliente gRPC mantiene conexiones a múltiples backends y distribuye requests. Este es el approach estándar en Kubernetes con servicios headless.

Service mesh: Si se está en Kubernetes, Linkerd o Istio manejan el load balancing de gRPC transparentemente.

Cuándo Usar Este Stack

gRPC + ConnectRPC es la opción correcta cuando:

  • Se construye comunicación service-to-service en un sistema polyglot
  • Se necesita streaming (feeds real-time, operaciones de larga duración)
  • La superficie de API está bien definida y no va a cambiar de forma frecuentemente
  • Quiero un solo contrato que genere clientes para web, mobile y server

REST sigue siendo mejor opción cuando:

  • La API es pública y necesita máxima compatibilidad
  • El equipo es pequeño y no quiere aprender protobuf
  • La API es heavy en CRUD con patrones simples de request/response

tRPC encaja mejor cuando:

  • Todo el stack es TypeScript (monorepo Next.js)
  • La velocidad de desarrollo importa más que el soporte cross-language
  • La API es interna y el equipo es pequeño

El tooling maduró lo suficiente como para que el argumento de "gRPC es demasiado complejo" ya no se sostenga. buf hace que el manejo de protos sea tan fácil como npm. ConnectRPC hace que los clientes browser sean triviales. El payoff de type safety y code generation es real y se compone con el tiempo.

Comenzar con un archivo .proto. Todo lo demás sigue de ahí.