Building a Minimal gRPC Server and Client in Go
Learn how to build your first gRPC application in Go by creating a simple server and client that communicate using Protocol Buffers.
Introduction
In the previous article, we learned what gRPC and Protocol Buffers are and why they are widely used in modern distributed systems.
Now it’s time to build one.
Instead of creating a complex project, we’ll implement a minimal Hello Service consisting of:
- A
.protofile - A Go gRPC server
- A Go gRPC client
By the end, you’ll understand the basic workflow used in almost every gRPC application.
You can find the complete code implementation on GitHub.
Project Structure
grpc-demo/
│
├── proto/
│ └── hello.proto
│
├── server/
│ └── main.go
│
├── client/
│ └── main.go
│
├── go.mod
└── go.sum
Step 1: Create a Go Module
Initialize a new Go project.
mkdir grpc-demo
cd grpc-demo
go mod init grpc-demo
Step 2: Install Required Packages
Install the gRPC libraries.
go get google.golang.org/grpc
go get google.golang.org/protobuf
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
Make sure your Go binary directory is available in your PATH.
Step 3: Create the Protocol Buffer File
Create proto/hello.proto.
syntax = "proto3";
package hello;
option go_package = "grpc-demo/proto";
service HelloService {
rpc SayHello(HelloRequest) returns (HelloResponse);
}
message HelloRequest {
string name = 1;
}
message HelloResponse {
string message = 1;
}
This file defines:
- A service named
HelloService - One RPC method
- Request and response messages
Step 4: Generate Go Code
Generate the Go models and gRPC stubs.
protoc \
--go_out=. \
--go-grpc_out=. \
proto/hello.proto
After running the command, you’ll see generated files similar to:
proto/
hello.pb.go
hello_grpc.pb.go
These files contain:
- Go structs
- Serialization logic
- Client stub
- Server interface
You should never edit these generated files manually.
Step 5: Implement the Server
Create server/main.go.
Implement the generated server interface.
type Server struct {
pb.UnimplementedHelloServiceServer
}
func (s *Server) SayHello(
ctx context.Context,
req *pb.HelloRequest,
) (*pb.HelloResponse, error) {
return &pb.HelloResponse{
Message: "Hello " + req.Name,
}, nil
}
Next, register the service and start the gRPC server.
listener, _ := net.Listen("tcp", ":50051")
grpcServer := grpc.NewServer()
pb.RegisterHelloServiceServer(grpcServer, &Server{})
grpcServer.Serve(listener)
Once started, the server listens for incoming RPC requests on port 50051.
Step 6: Implement the Client
Create client/main.go.
First, establish a connection.
conn, _ := grpc.Dial(
"localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
defer conn.Close()
Create a client stub.
client := pb.NewHelloServiceClient(conn)
Call the remote method.
response, err := client.SayHello(
context.Background(),
&pb.HelloRequest{
Name: "Tushar",
},
)
Print the result.
fmt.Println(response.Message)
Output:
Hello Tushar
Notice how the client simply calls a Go function. It doesn’t manually build HTTP requests or serialize data.
What Happens Behind the Scenes?
When the client executes:
client.SayHello(...)
gRPC performs several operations automatically.
Client
SayHello()
│
Client Stub
│
Serialize Request (Protobuf)
│
HTTP/2
══════════════════════
Server Stub
│
Deserialize Request
│
SayHello()
│
Business Logic
│
Serialize Response
══════════════════════
HTTP/2
│
Deserialize Response
│
Client
All networking and serialization details are hidden from your application code.
Understanding the Generated Stubs
Two important files are generated by the Protocol Buffer compiler.
hello.pb.go
Contains:
- Go structs
- Serialization methods
- Message definitions
Example:
type HelloRequest struct {
Name string
}
hello_grpc.pb.go
Contains:
- Client stub
- Server interface
- Registration functions
For example:
type HelloServiceClient interface {
SayHello(...)
}
and
type HelloServiceServer interface {
SayHello(...)
}
These generated types allow the client and server to communicate without manually implementing networking logic.
Running the Application
Start the server.
go run server/main.go
Open another terminal and run the client.
go run client/main.go
Expected output:
Hello Tushar
Congratulations! You’ve successfully built your first gRPC application.
Why This Example Matters
Although this project is intentionally simple, it demonstrates the complete gRPC workflow:
- Define your API in a
.protofile. - Generate Go code.
- Implement the server.
- Use the generated client stub.
- Exchange Protocol Buffer messages over HTTP/2.
This is the same workflow used in production systems. Whether you’re building a monitoring agent, a Kubernetes operator, or a distributed microservice architecture, the development process remains largely the same.
What’s Next?
Once you’re comfortable with unary RPCs, explore more advanced gRPC features:
- Server Streaming
- Client Streaming
- Bidirectional Streaming
- Authentication with TLS
- Metadata
- Interceptors
- Deadlines and Timeouts
- Error Handling
- Reflection
- Health Checks
These features make gRPC a powerful choice for building scalable, high-performance distributed applications.
Conclusion
Building a gRPC application in Go is surprisingly straightforward. By defining your API in a .proto file and letting the Protocol Buffer compiler generate the necessary code, you avoid writing repetitive networking logic and gain a strongly typed interface for communication.
Even though this example consists of only a single RPC method, it demonstrates the core concepts you’ll use in real-world systems. As your applications grow, you can extend the same foundation with streaming, authentication, observability, and more sophisticated service architectures while keeping the communication layer clean, efficient, and maintainable.
Common Beginner Questions
If you’re learning gRPC for the first time, two things in the example might seem confusing.
Why do we need option go_package?
In the hello.proto file, you’ll notice this line:
option go_package = "grpc-demo/proto";
At first glance, it may seem unnecessary, but it is very important for Go projects.
The go_package option tells the Protocol Buffer compiler where the generated Go package belongs and what import path should be used by other Go files.
Suppose your project looks like this:
grpc-demo/
│
├── go.mod
├── proto/
│ └── hello.proto
├── server/
└── client/
and your go.mod contains:
module grpc-demo
When you run:
protoc --go_out=. --go-grpc_out=. proto/hello.proto
the compiler generates:
proto/
├── hello.pb.go
└── hello_grpc.pb.go
Your server imports the generated package like this:
import pb "grpc-demo/proto"
How does Go know that the generated code belongs to grpc-demo/proto?
Because the .proto file explicitly specifies:
option go_package = "grpc-demo/proto";
Without this option, the Go code generator doesn’t know the correct import path and typically produces an error such as:
protoc-gen-go: unable to determine Go import path
package vs go_package
Many beginners confuse these two declarations.
package hello;
This defines the Protocol Buffers package (namespace). It helps organize protobuf messages and prevents naming conflicts between different .proto files.
On the other hand,
option go_package = "grpc-demo/proto";
is specific to Go. It tells the Go code generator which import path should be used for the generated package.
For production projects, it’s recommended to use your full Go module path:
option go_package = "github.com/yourusername/grpc-demo/proto";
Why do we import generated files using pb?
In the server and client code you’ll see:
import pb "grpc-demo/proto"
Many newcomers assume that pb is something special provided by gRPC.
It isn’t.
pb is simply a Go import alias.
The following would work exactly the same:
import "grpc-demo/proto"
Then you’d write:
client := proto.NewHelloServiceClient(conn)
or you could even choose another alias:
import hello "grpc-demo/proto"
and use:
client := hello.NewHelloServiceClient(conn)
So why does almost every gRPC project use pb?
pb stands for Protocol Buffers, and it has become the standard convention in the Go ecosystem.
Another practical reason is that many projects also import Google’s protobuf runtime library:
import "google.golang.org/protobuf/proto"
If your generated package were also referenced as proto, your imports would look like this:
import (
"grpc-demo/proto"
"google.golang.org/protobuf/proto"
)
Both packages would have the same name, making the code confusing.
Instead, developers commonly write:
import (
pb "grpc-demo/proto"
"google.golang.org/protobuf/proto"
)
Now it’s immediately clear:
req := &pb.HelloRequest{}
data, err := proto.Marshal(req)
pbrefers to your generated messages and gRPC stubs.protorefers to Google’s Protocol Buffers runtime library.
This is why you’ll see pb used in almost every Go gRPC project. It’s simply a naming convention that improves readability and avoids package name conflicts.