Skip to content

Defining Services

A service is declared with the service keyword, and each method with rpc. The shape is always the same: a name, one request message in parentheses, and returns with one response message.

syntax = "proto3";
package user.v1;
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc CreateUser(CreateUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
}

Read it as a menu of calls: this service offers three methods, each with a typed input and a typed output. That’s the entire API surface — no URLs, no verbs, no query strings.

One request and one response message per method

Section titled “One request and one response message per method”

Notice that every method takes its own request message and returns its own response message — even GetUser, which conceptually just needs an id. This is deliberate, not verbose for its own sake:

// Do this — dedicated messages
rpc GetUser(GetUserRequest) returns (User);
message GetUserRequest {
int64 id = 1;
}

If tomorrow you need to add “include deleted users” or “which fields to return”, you add a field to GetUserRequest — a safe, backward-compatible change. Had the method taken a bare int64, you’d have no place to grow, and changing the signature would break every client. Wrapper messages give every method room to evolve. The Designing Methods lesson returns to this rule in depth.

The package declaration namespaces your types so they don’t collide with types from other .proto files. It also maps to a namespace in the generated code (a Go package, a Python module prefix, and so on).

package user.v1;

Two conventions matter:

  • Reverse-domain or product-scoped names keep packages globally unique: acme.user.v1, payments.billing.v2.
  • Service names use PascalCase (UserService), methods use PascalCase (GetUser), and fields use snake_case (user_id). The code generators translate these into each language’s idioms automatically.

This is the single most important design habit in this lesson: the API version lives in the package path, as v1, v2, and so on.

flowchart TB
  v1["package user.v1
UserService"] --> old["existing clients
keep calling v1"]
  v2["package user.v2
UserService (redesigned)"] --> new["new clients
adopt v2"]
  note["both run side by side
until v1 is retired"]
Versioned packages let v1 and v2 coexist during a migration

Because user.v1.UserService and user.v2.UserService are distinct types with distinct wire identities, you can ship a breaking redesign as v2 while v1 keeps serving existing clients. There is no forced flag-day migration. When every client has moved, you retire v1. Bake the version in from day one — retrofitting it later is painful.

What is the fixed shape of an rpc declaration?
Why give even a simple GetUser its own request message instead of a bare int64?
Where should the API version live?
What does versioning in the package enable?