gRPC with tonic and prost
Use this recipe for gRPC servers/clients, protobuf generation, interceptors, and tonic/prost setup.
Current Dependency Shape
Verify versions before generating. Current known pattern:
[dependencies]
tonic = "0.14"
tonic-prost = "0.14"
prost = "0.14"
[build-dependencies]
tonic-prost-build = "0.14"For protobuf compilation through prost, use tonic-prost-build rather than older tonic-build protobuf examples.
Project Layout
proto/
service.proto
build.rs
src/
main.rs
generated.rsMinimal proto (matching the server skeleton below)
syntax = "proto3";
package service.v1;
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
message GetUserRequest {
string id = 1;
}
message GetUserResponse {
string id = 1;
string display_name = 2;
}The package service.v1; line is what tonic::include_proto!("service.v1") must match.
build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_prost_build::compile_protos("proto/service.proto")?;
Ok(())
}Include Generated Code
pub mod generated {
tonic::include_proto!("service.v1");
}The string passed to include_proto! must match the protobuf package name.
Server Skeleton
use generated::user_service_server::{UserService, UserServiceServer};
use tonic::{Request, Response, Status};
#[derive(Debug, Default)]
pub struct UserServiceImpl;
#[tonic::async_trait]
impl UserService for UserServiceImpl {
async fn get_user(
&self,
request: Request<generated::GetUserRequest>,
) -> std::result::Result<Response<generated::GetUserResponse>, Status> {
let request = request.into_inner();
if request.id.trim().is_empty() {
return Err(Status::invalid_argument("id is required"));
}
Ok(Response::new(generated::GetUserResponse {
id: request.id,
display_name: "Example User".to_string(),
}))
}
}
pub async fn serve(address: std::net::SocketAddr) -> Result<()> {
tonic::transport::Server::builder()
.add_service(UserServiceServer::new(UserServiceImpl::default()))
.serve(address)
.await
.map_err(|error| ServiceError::invalid_input(format!("gRPC server failed: {error}")))?;
Ok(())
}Error Mapping
Map domain errors to gRPC status codes at the boundary:
impl From<ServiceError> for tonic::Status {
fn from(error: ServiceError) -> Self {
match error {
ServiceError::NotFound { message } => tonic::Status::not_found(message),
ServiceError::InvalidInput { message } => tonic::Status::invalid_argument(message),
_ => tonic::Status::internal("internal service error"),
}
}
}Rules:
- Do not leak internal error details to clients.
- Validate request DTOs before converting to domain commands.
- Add deadlines/timeouts on client calls.
- Use interceptors or tower layers for authentication, tracing, and request IDs.
- Keep protobuf-generated types at transport boundaries; map into domain types for core logic.