Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 070 / 100

gRPC with Tonic: what every Rust developer should know

Typed RPC services in Rust.

Expona Admin8 min read

Introduction

Building distributed systems in Rust demands a communication layer that's both type-safe and performant. gRPC with Tonic: what every Rust developer should know represents a shift from thinking about hand-rolled HTTP endpoints to embracing Protocol Buffers and code generation. Whether you're scaling a backend service, connecting microservices, or building real-time APIs, Tonic gives you the tooling to do it right.

We've built production systems at scale where gRPC became the backbone of service-to-service communication. This guide walks through the practical steps: defining your service contract with Protocol Buffers, letting tonic-build generate type-safe client and server code, writing async handlers, and securing everything with interceptors.

Define .proto files

Your contract starts with a .proto file—a language-agnostic description of your service and message types. Unlike REST endpoints scattered across code, a .proto file is the single source of truth. Both client and server teams read the same definition, eliminating ambiguity.

Let's say we're building a user service. Create proto/user.proto:

rust
syntax = "proto3";

package user.v1;

service UserService {
  rpc GetUser (GetUserRequest) returns (UserResponse);
  rpc ListUsers (ListUsersRequest) returns (stream UserResponse);
  rpc CreateUser (CreateUserRequest) returns (UserResponse);
}

message GetUserRequest {
  int64 user_id = 1;
}

message CreateUserRequest {
  string email = 1;
  string name = 2;
}

message UserResponse {
  int64 id = 1;
  string email = 2;
  string name = 3;
  int64 created_at = 4;
}

message ListUsersRequest {
  int32 page = 1;
  int32 limit = 2;
}

Notice the stream keyword in ListUsers—this declares a server streaming RPC, perfect for paginated results or real-time feeds. Each field has a unique number; Protocol Buffers uses these numbers for wire encoding and backward compatibility.

The proto3 syntax is modern and simpler than proto2. Field numbers 1–15 use one byte to encode the field tag, so put your most frequent fields there for smaller messages.

tonic-build generates code

The magic happens when you run the build. Tonic's code generator reads your .proto definitions and outputs Rust structs, trait definitions, and client code. You don't write these by hand—you can't, and you shouldn't.

In your Cargo.toml:

rust
[dependencies]
tonic = "0.11"
prost = "0.12"
tokio = { version = "1", features = ["full"] }

[build-dependencies]
tonic-build = "0.11"

Create build.rs at the root of your crate:

rust
fn main() -> Result<(), Box> {
    tonic_build::compile_protos("proto/user.proto")?;
    Ok(())
}

When you run cargo build, tonic-build compiles your proto into Rust code, placed in the OUT_DIR. You access it via:

rust
pub mod user {
    tonic::include_proto!("user.v1");
}

Now you have type-safe message structs and a service trait to implement. The compiler enforces that your server and client agree on types. If someone changes the proto and forgets to regenerate, the build fails. This catches mismatches early.

The generated code includes UserServiceServer and UserServiceClient—the server trait you implement, and a client you instantiate. Both are async by default, which leads us to the next section.

Async servers and clients

Tonic was built for async Rust from day one. Handlers are trait methods returning boxed futures, and clients use async methods. This matters in production: you're not spawning threads per request.

Implement the server by defining a struct and implementing the generated trait:

rust
use tonic::{Request, Response, Status};
use user::user_service_server::{UserService, UserServiceServer};
use user::{GetUserRequest, UserResponse, CreateUserRequest};

#[derive(Debug)]
pub struct UserServiceImpl;

#[tonic::async_trait]
impl UserService for UserServiceImpl {
    async fn get_user(
        &self,
        request: Request,
    ) -> Result, Status> {
        let req = request.into_inner();
        
        // Fetch from database
        let user = sqlx::query_as::<_, UserRow>(
            "SELECT id, email, name, created_at FROM users WHERE id = $1"
        )
        .bind(req.user_id)
        .fetch_optional(&self.db)
        .await
        .map_err(|e| Status::internal(e.to_string()))?
        .ok_or_else(|| Status::not_found("user not found"))?;

        Ok(Response::new(UserResponse {
            id: user.id,
            email: user.email,
            name: user.name,
            created_at: user.created_at,
        }))
    }

    async fn create_user(
        &self,
        request: Request,
    ) -> Result, Status> {
        let req = request.into_inner();
        
        let user = sqlx::query_as::<_, UserRow>(
            "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id, email, name, created_at"
        )
        .bind(&req.email)
        .bind(&req.name)
        .fetch_one(&self.db)
        .await
        .map_err(|e| Status::invalid_argument(e.to_string()))?;

        Ok(Response::new(UserResponse {
            id: user.id,
            email: user.email,
            name: user.name,
            created_at: user.created_at,
        }))
    }
}

The #[tonic::async_trait] macro desugars async trait methods. Each handler receives a Request wrapper (containing metadata) and returns Result<Response<T>, Status>. Errors map to gRPC status codes that clients understand.

On the client side, instantiate a channel and client:

rust
use user::user_service_client::UserServiceClient;
use user::GetUserRequest;

#[tokio::main]
async fn main() -> Result<(), Box> {
    let mut client = UserServiceClient::connect("http://[::1]:50051").await?;

    let request = Request::new(GetUserRequest { user_id: 42 });
    let response = client.get_user(request).await?;
    
    println!("User: {:?}", response.into_inner());
    Ok(())
}

The client is a thin wrapper around a gRPC channel. Internally, Tonic manages HTTP/2 connections, multiplexes streams, and handles backpressure. You don't think about sockets or buffers.

For streaming responses, the handler yields a stream:

rust
type ListUsersStream = Pin> + Send>>;

#[tonic::async_trait]
impl UserService for UserServiceImpl {
    async fn list_users(
        &self,
        request: Request,
    ) -> Result, Status> {
        let req = request.into_inner();
        
        let stream = async_stream::stream! {
            let offset = (req.page - 1) * req.limit;
            let rows = sqlx::query_as::<_, UserRow>(
                "SELECT id, email, name, created_at FROM users LIMIT $1 OFFSET $2"
            )
            .bind(req.limit)
            .bind(offset)
            .fetch_all(&self.db)
            .await;

            match rows {
                Ok(users) => {
                    for user in users {
                        yield Ok(UserResponse {
                            id: user.id,
                            email: user.email,
                            name: user.name,
                            created_at: user.created_at,
                        });
                    }
                }
                Err(e) => yield Err(Status::internal(e.to_string())),
            }
        };

        Ok(Response::new(Box::pin(stream)))
    }
}

The client consumes the stream naturally:

rust
let mut stream = client.list_users(
    Request::new(ListUsersRequest { page: 1, limit: 10 })
).await?.into_inner();

while let Some(user) = stream.message().await? {
    println!("User: {:?}", user);
}

Interceptors for auth

Cross-cutting concerns like authentication, logging, and metrics belong in interceptors, not sprinkled through every handler. Tonic supports server and client interceptors.

A server interceptor runs before your handler. Check the request metadata, verify a token, or reject early:

rust
use tonic::metadata::MetadataValue;
use tonic::service::{Interceptor, InterceptorLayer};
use tonic::{Request, Status};

pub struct AuthInterceptor;

impl Interceptor for AuthInterceptor {
    fn call(&mut self, mut request: Request<()>) -> Result, Status> {
        let auth_header = request
            .metadata()
            .get("authorization")
            .and_then(|v| v.to_str().ok())
            .ok_or_else(|| Status::unauthenticated("missing auth token"))?;

        if !auth_header.starts_with("Bearer ") {
            return Err(Status::unauthenticated("invalid token format"));
        }

        let token = &auth_header[7..];
        // Validate token with your auth service
        validate_token(token)
            .await
            .map_err(|_| Status::unauthenticated("invalid token"))?;

        Ok(request)
    }
}

Attach it to your server when building:

rust
use tonic::transport::Server;

#[tokio::main]
async fn main() -> Result<(), Box> {
    let addr = "[::1]:50051".parse()?;
    let user_service = UserServiceImpl::new(db);

    Server::builder()
        .layer(InterceptorLayer::new(AuthInterceptor))
        .add_service(UserServiceServer::new(user_service))
        .serve(addr)
        .await?;

    Ok(())
}

Client interceptors add metadata to outgoing requests:

rust
pub struct ClientAuthInterceptor {
    token: String,
}

impl Interceptor for ClientAuthInterceptor {
    fn call(&mut self, mut request: Request<()>) -> Result, Status> {
        request.metadata_mut().insert(
            "authorization",
            MetadataValue::from_str(&format!("Bearer {}", self.token))?,
        );
        Ok(request)
    }
}

let channel = tonic::transport::Channel::from_static("http://[::1]:50051")
    .connect()
    .await?
    .interceptor(ClientAuthInterceptor { token: my_token });

let mut client = UserServiceClient::new(channel);

Common mistakes

  • Forgetting to run cargo build after proto changes. The generated code lives in OUT_DIR; if you edit the proto and don't rebuild, you're debugging stale code. Make proto changes part of your CI checklist.
  • Using blocking code in handlers. Tonic is async; if you call a blocking database library or sync I/O, you block the entire tokio executor. Always use async drivers (sqlx, redis-async, etc.). If you must block, spawn it on a dedicated thread pool.
  • Ignoring field numbers. Once deployed, changing a field number breaks compatibility. Treat proto definitions like database schemas: plan for evolution. Document your versioning strategy.
  • Not wrapping database errors. Map database errors to gRPC statuses thoughtfully. Status::internal() for server errors, Status::invalid_argument() for validation, Status::not_found() for missing records. Clients depend on the right status to retry or handle appropriately.
  • Interceptors that panic. An interceptor that panics crashes the whole server. Always return Status or handle errors cleanly.

Key takeaways

  • Proto files are your service contract. Generate code with tonic-build and never hand-write it.
  • Async all the way: handlers are async, clients are async, and blocking code has no place in handlers.
  • Use gRPC status codes correctly so clients and servers speak the same error language.
  • Interceptors isolate auth, logging, and metrics; don't scatter them through handlers.
  • Type safety is Tonic's superpower. The compiler enforces that clients and servers agree, catching versioning bugs before production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles