Go gRPC TODO API: From Proto Definition to Running Server — Native CRUD
Build a complete CRUD TODO API in Go 1.25 using gRPC and protobuf from scratch. Define the proto contract, generate code, apply Hexagonal Architecture, and test with grpcurl and bufconn.
Imagine two restaurants on the same block. The first one has a menu, you read it, you say what you want in plain language, and the waiter writes it down on a notepad. The second one uses a laminated order form with numbered items — you circle what you want, the form goes straight to the kitchen, no interpretation, no ambiguity, no translation.
HTTP REST is the first restaurant. The menu is documentation (if it exists). The conversation is JSON — flexible, human-readable, but unvalidated until the server decides to validate it. gRPC is the second restaurant. The order form is a .proto file. The format is binary. Both sides agree on the contract at compile time, not at runtime.
The previous two posts in this series built the same TODO API twice: once with Go’s standard library, once with popular packages like Gin and GORM. Both used HTTP and JSON. This post builds the same API a third time — same domain, same business rules, same SQLite persistence — but with gRPC as the transport instead of HTTP.
The architecture will not change. Only the transport adapter will.
Why gRPC and When to Choose It
gRPC solves a specific set of problems that REST does not solve well.
Strict contracts. With REST, the only contract between client and server is documentation or convention. With gRPC, the .proto file is the contract. It is versioned, it is code, and both the client and server generate their types from it. A field added to one side does not compile on the other until the proto is updated.
Performance. gRPC uses Protocol Buffers as its serialization format: binary, compact, faster to encode and decode than JSON. Over the wire, a serialized protobuf message is typically 3–10x smaller than the equivalent JSON. For high-throughput internal services, this matters.
Multiplexing and streaming. gRPC is built on HTTP/2. A single TCP connection multiplexes many concurrent calls. Streaming RPCs (server-side, client-side, bidirectional) are first-class features. This post only covers unary RPCs — one request, one response — but the architecture supports streaming naturally.
Code generation. The protoc compiler generates client stubs and server interfaces from the proto file. Any language that compiles from proto gets a type-safe client. If your mobile app is in Kotlin, your backend is in Go, and your data processor is in Python, all three generate from the same .proto and speak the same protocol without writing adapter code by hand.
When REST is the better choice. REST shines for public APIs consumed by browsers, for teams that need HTTP debugging tools (curl, browser devtools), and for systems where the client is unknown. gRPC requires HTTP/2 and binary awareness — not appropriate for every context. Internal microservices, mobile-to-backend, and system-to-system communication are where gRPC earns its weight.
What You Will Build
The same five CRUD operations from the previous posts, expressed as gRPC service methods:
| RPC Method | Role | REST equivalent |
|---|---|---|
CreateTodo | Insert a new todo | POST /todos |
GetTodo | Fetch one by ID | GET /todos/:id |
ListTodos | Fetch all todos | GET /todos |
UpdateTodo | Modify an existing | PUT /todos/:id |
DeleteTodo | Remove by ID | DELETE /todos/:id |
All five are unary RPCs. The client sends one message, the server replies with one message.
How This Post Relates to the Previous Posts
If you followed the native REST API post you already have the domain, ports, and application layers. The architecture’s promise was that those layers would never need to change if you swap the transport. This post is that proof.
The layers you copy unchanged:
internal/domain/—Todoentity,Titlevalue object, sentinel errorsinternal/ports/input/—TodoServiceinterfaceinternal/ports/output/—TodoRepositoryinterfaceinternal/application/—TodoServiceuse case implementationsinternal/adapters/sqlite/— SQLite repository viadatabase/sql
The layer you write new:
internal/adapters/grpc/— the gRPC server that maps proto messages to domain calls
The Full Project Structure
todo-grpc/
├── cmd/
│ └── server/
│ └── main.go
├── proto/
│ └── todo/
│ └── v1/
│ └── todo.proto
├── gen/
│ └── todo/
│ └── v1/
│ ├── todo.pb.go ← generated, do not edit
│ └── todo_grpc.pb.go ← generated, do not edit
├── internal/
│ ├── domain/
│ │ ├── todo.go
│ │ ├── todo_test.go
│ │ └── errors.go
│ ├── ports/
│ │ ├── input/
│ │ │ └── todo_service.go
│ │ └── output/
│ │ └── todo_repository.go
│ ├── application/
│ │ ├── todo_service.go
│ │ └── todo_service_test.go
│ └── adapters/
│ ├── grpc/
│ │ ├── server.go
│ │ └── server_test.go
│ └── sqlite/
│ └── repository.go
├── go.mod
└── go.sum
Two conventions to note. The proto/ directory stores the source .proto files. The gen/ directory stores the generated Go code. Generated code goes in a separate directory so it is easy to exclude from code reviews, linting, and test coverage — it is not your code.
The path todo/v1/ inside both directories reflects API versioning. Your service is v1. If you later create a v2 with breaking changes, the two coexist in the same repository. Clients migrate on their own schedule.
Step 1 — Install the Toolchain
gRPC code generation requires three tools outside of Go:
# 1. The protobuf compiler
# Debian/Ubuntu:
sudo apt install -y protobuf-compiler
# macOS with Homebrew:
brew install protobuf
# Verify: should print "libprotoc X.Y.Z"
protoc --version
# 2. The Go protobuf plugin (generates *pb.go files — messages)
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
# 3. The Go gRPC plugin (generates *_grpc.pb.go files — service stubs)
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
After installation, verify both plugins are on your PATH. The go install command places binaries in $GOPATH/bin (usually ~/go/bin). If your shell cannot find them, add that directory to $PATH:
export PATH="$PATH:$(go env GOPATH)/bin"
Step 2 — Initialize the Module
mkdir todo-grpc
cd todo-grpc
go mod init github.com/sazardev/todo-grpc
Create every directory you will need:
mkdir -p cmd/server
mkdir -p proto/todo/v1
mkdir -p gen/todo/v1
mkdir -p internal/domain
mkdir -p internal/ports/input
mkdir -p internal/ports/output
mkdir -p internal/application
mkdir -p internal/adapters/grpc
mkdir -p internal/adapters/sqlite
Install the Go runtime dependencies. You do not need a web framework — gRPC has its own transport layer. The SQLite driver is the same one you used in the native REST post.
go get google.golang.org/grpc
go get google.golang.org/protobuf
go get modernc.org/sqlite
go mod tidy
Step 3 — Write the Proto File
The .proto file is the most important file in a gRPC project. It is the contract between client and server. Every message type, every service method, every field is declared here before a single line of Go is written.
Protocol Buffers use a schema language designed for clarity and forward compatibility. Fields have numbers, not just names — the number is what gets serialized on the wire. If a field is renamed, the number stays the same and old clients continue to work. If a field is added, old clients ignore it. This is how protobuf enables non-breaking evolution of APIs.
touch proto/todo/v1/todo.proto
// proto/todo/v1/todo.proto
//
// The proto3 syntax declaration is mandatory. proto3 is the current stable version.
// The main differences from proto2: all fields are optional by default, no required
// keyword, no default values for scalar types — zero values are used instead.
syntax = "proto3";
// The package name maps to Go package imports.
// option go_package sets the Go import path for the generated code.
// The format is "module/path/to/gen/package;packagealias".
// The part before the semicolon is the Go import path.
// The part after is the package name used in the generated file.
package todo.v1;
option go_package = "github.com/sazardev/todo-grpc/gen/todo/v1;todov1";
// Import the well-known Timestamp type from Google's protobuf library.
// This is the standard way to represent time in proto3.
// You do NOT write your own time message — always use google.protobuf.Timestamp.
import "google/protobuf/timestamp.proto";
// ─── Messages ─────────────────────────────────────────────────────────────────
//
// Messages are the data structures. Think of them as the JSON request/response
// bodies, except they are typed, versioned, and binary-serialized.
// Todo represents a single todo item as seen by external consumers (clients).
// These fields are what the client sends back and receives from the server.
message Todo {
uint64 id = 1; // field number 1 — never reuse a number even if you remove the field
string title = 2;
string description = 3;
bool completed = 4;
// google.protobuf.Timestamp is the canonical time type.
// It serializes as seconds + nanos since Unix epoch.
google.protobuf.Timestamp created_at = 5;
google.protobuf.Timestamp updated_at = 6;
}
// ─── Request and Response Messages ────────────────────────────────────────────
//
// A common proto design convention: every RPC gets its own request and response
// message type, even if it currently wraps only one field. This reserves space
// for forward-compatible additions. Adding a field to a request message is
// non-breaking. Adding a new parameter to an RPC signature is impossible.
message CreateTodoRequest {
string title = 1;
string description = 2;
}
message CreateTodoResponse {
Todo todo = 1;
}
message GetTodoRequest {
uint64 id = 1;
}
message GetTodoResponse {
Todo todo = 1;
}
message ListTodosRequest {
// Empty for now. Future versions might add pagination: page_size, page_token.
// Because this is its own message type, adding those fields is non-breaking.
}
message ListTodosResponse {
repeated Todo todos = 1; // "repeated" means a list — equivalent to []Todo in Go
}
message UpdateTodoRequest {
uint64 id = 1;
string title = 2;
string description = 3;
bool completed = 4;
}
message UpdateTodoResponse {
Todo todo = 1;
}
message DeleteTodoRequest {
uint64 id = 1;
}
message DeleteTodoResponse {
// Empty response signals success. The gRPC status code carries the error.
// An empty response message is correct here — do not omit the message type
// itself, because future versions may need to add fields (soft-delete timestamp, etc.)
}
// ─── Service Definition ────────────────────────────────────────────────────────
//
// The service maps method names to their request and response types.
// Each method here produces one function in the generated Go server interface
// and one function in the generated Go client stub.
//
// The "rpc" keyword defines a single method. The format is:
// rpc MethodName(RequestMessage) returns (ResponseMessage) {}
//
// All five here are unary: one request in, one response out.
// Server-streaming would look like: returns (stream ResponseMessage)
service TodoService {
rpc CreateTodo(CreateTodoRequest) returns (CreateTodoResponse) {}
rpc GetTodo(GetTodoRequest) returns (GetTodoResponse) {}
rpc ListTodos(ListTodosRequest) returns (ListTodosResponse) {}
rpc UpdateTodo(UpdateTodoRequest) returns (UpdateTodoResponse) {}
rpc DeleteTodo(DeleteTodoRequest) returns (DeleteTodoResponse) {}
}
Step 4 — Generate Go Code
The protoc compiler reads your .proto file and generates Go files using the plugins you installed earlier. Run this command from the project root:
protoc \
--go_out=gen \
--go_opt=paths=source_relative \
--go-grpc_out=gen \
--go-grpc_opt=paths=source_relative \
-I proto \
proto/todo/v1/todo.proto
What each flag does:
| Flag | Purpose |
|---|---|
--go_out=gen | Write message types to gen/ |
--go_opt=paths=source_relative | Keep the todo/v1/ directory structure |
--go-grpc_out=gen | Write service stubs to gen/ |
--go-grpc_opt=paths=source_relative | Same path convention for stubs |
-I proto | Include search path for imports |
proto/todo/v1/todo.proto | The file to compile |
After running this, you should see two generated files:
gen/todo/v1/todo.pb.go ← message types: Todo, CreateTodoRequest, etc.
gen/todo/v1/todo_grpc.pb.go ← service interface: TodoServiceServer, TodoServiceClient
Open gen/todo/v1/todo_grpc.pb.go and look for the TodoServiceServer interface. This is what you will implement:
// TodoServiceServer is the server API for TodoService service.
type TodoServiceServer interface {
CreateTodo(context.Context, *CreateTodoRequest) (*CreateTodoResponse, error)
GetTodo(context.Context, *GetTodoRequest) (*GetTodoResponse, error)
ListTodos(context.Context, *ListTodosRequest) (*ListTodosResponse, error)
UpdateTodo(context.Context, *UpdateTodoRequest) (*UpdateTodoResponse, error)
DeleteTodo(context.Context, *DeleteTodoRequest) (*DeleteTodoResponse, error)
mustEmbedUnimplementedTodoServiceServer()
}
The mustEmbedUnimplementedTodoServiceServer() method is a forward-compatibility mechanism. By embedding UnimplementedTodoServiceServer in your struct, your code compiles even when new RPC methods are added to the service later — they get default “unimplemented” responses instead of compile errors. Do not implement this method yourself.
Step 5 — Copy the Domain, Ports, and Application Layers
If you followed the native REST API post, copy the following files unchanged:
# Assuming you are inside todo-grpc/ and have todo-grpc-rest/ as a sibling:
cp ../todo-api-native/internal/domain/*.go internal/domain/
cp ../todo-api-native/internal/ports/input/*.go internal/ports/input/
cp ../todo-api-native/internal/ports/output/*.go internal/ports/output/
cp ../todo-api-native/internal/application/*.go internal/application/
cp ../todo-api-native/internal/adapters/sqlite/*.go internal/adapters/sqlite/
Only one import path changes: the module name in each file’s package import paths must reference github.com/sazardev/todo-grpc instead of github.com/sazardev/todo-api-native.
If you are starting fresh, here are the essential domain types you need at minimum. The full implementation with tests is covered in the first post of this series.
// internal/domain/errors.go
package domain
import "errors"
var (
ErrNotFound = errors.New("todo: not found")
ErrEmptyTitle = errors.New("todo: title cannot be empty")
ErrTitleTooLong = errors.New("todo: title exceeds 255 characters")
ErrAlreadyCompleted = errors.New("todo: already marked as completed")
)
// internal/domain/todo.go
// (abbreviated — see the native REST API post for the full implementation)
package domain
import "time"
type ID uint64
type Title struct{ value string }
func NewTitle(s string) (Title, error) {
if s == "" {
return Title{}, ErrEmptyTitle
}
if len(s) > 255 {
return Title{}, ErrTitleTooLong
}
return Title{value: s}, nil
}
func (t Title) String() string { return t.value }
type Todo struct {
id ID
title Title
description string
completed bool
createdAt time.Time
updatedAt time.Time
}
func NewTodo(title Title, description string) Todo {
now := time.Now().UTC()
return Todo{title: title, description: description, createdAt: now, updatedAt: now}
}
func ReconstituteTodo(id ID, title Title, description string, completed bool, createdAt, updatedAt time.Time) Todo {
return Todo{id: id, title: title, description: description, completed: completed, createdAt: createdAt, updatedAt: updatedAt}
}
func (t Todo) ID() ID { return t.id }
func (t Todo) Title() Title { return t.title }
func (t Todo) Description() string { return t.description }
func (t Todo) IsCompleted() bool { return t.completed }
func (t Todo) CreatedAt() time.Time { return t.createdAt }
func (t Todo) UpdatedAt() time.Time { return t.updatedAt }
Step 6 — Implement the gRPC Adapter
The gRPC adapter is the most important new file in this project. It bridges two worlds: the gRPC transport layer (proto messages, status codes, context propagation) and the domain layer (domain types, sentinel errors, ports).
Notice the structure of this problem. The TodoServiceServer interface produced by protoc is, architecturally, an input port. It describes what operations the transport layer can invoke on your application. In the Hexagonal Architecture vocabulary, this generated interface plays the same role as the input.TodoService interface you wrote by hand in the REST post — except here it was generated from the contract file.
The adapter’s job is exactly the same as the HTTP handler’s job was in the REST post: translate between the transport’s language (proto messages, gRPC status codes) and the domain’s language (domain types, sentinel errors). Neither layer changes. Only the translation changes.
touch internal/adapters/grpc/server.go
// Package grpcadapter is the input (driving) adapter that implements
// the gRPC TodoServiceServer interface using the application use cases.
//
// Its responsibilities:
// 1. Receive proto-typed requests from the gRPC transport layer.
// 2. Validate and convert them to domain types.
// 3. Call the input.TodoService port (the application core).
// 4. Convert domain results back to proto-typed responses.
// 5. Translate domain errors to gRPC status codes.
//
// This file imports proto-generated types and domain types.
// It does NOT import net/http, JSON, or any other transport concern.
package grpcadapter
import (
"context"
"errors"
"fmt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
todov1 "github.com/sazardev/todo-grpc/gen/todo/v1"
"github.com/sazardev/todo-grpc/internal/domain"
"github.com/sazardev/todo-grpc/internal/ports/input"
)
// TodoServer is the gRPC server struct. It holds a reference to the application
// service through the input port interface.
//
// Embedding todov1.UnimplementedTodoServiceServer is required. It provides
// default "unimplemented" implementations of any future RPC methods added
// to the proto file. Without this embedding, adding a new RPC to the proto
// would require updating EVERY server implementation before it compiles.
// With it, new RPCs fail gracefully until you are ready to implement them.
type TodoServer struct {
todov1.UnimplementedTodoServiceServer
svc input.TodoService
}
// NewTodoServer creates the gRPC server with its application service dependency.
// The constructor accepts the port interface, not the concrete type — same
// dependency inversion principle as the HTTP handler in the REST version.
func NewTodoServer(svc input.TodoService) *TodoServer {
return &TodoServer{svc: svc}
}
// CreateTodo implements the CreateTodo RPC.
//
// The flow:
// proto CreateTodoRequest → application.CreateTodoRequest → domain.Todo
// domain.Todo → proto CreateTodoResponse
//
// gRPC error handling uses status.Errorf, not fmt.Errorf.
// codes.InvalidArgument is the gRPC equivalent of HTTP 422.
// codes.Internal is the gRPC equivalent of HTTP 500.
func (s *TodoServer) CreateTodo(ctx context.Context, req *todov1.CreateTodoRequest) (*todov1.CreateTodoResponse, error) {
todo, err := s.svc.Create(ctx, input.CreateTodoRequest{
Title: req.GetTitle(),
Description: req.GetDescription(),
})
if err != nil {
return nil, toGRPCError(err)
}
return &todov1.CreateTodoResponse{
Todo: toProto(todo),
}, nil
}
// GetTodo implements the GetTodo RPC.
//
// req.GetId() uses the generated getter method rather than reading the field
// directly. Getters return zero values safely on nil messages — a defensive
// practice worth adopting.
func (s *TodoServer) GetTodo(ctx context.Context, req *todov1.GetTodoRequest) (*todov1.GetTodoResponse, error) {
todo, err := s.svc.GetByID(ctx, domain.ID(req.GetId()))
if err != nil {
return nil, toGRPCError(err)
}
return &todov1.GetTodoResponse{
Todo: toProto(todo),
}, nil
}
// ListTodos implements the ListTodos RPC.
// The request message is empty but still passed as a parameter — future
// versions will add pagination fields without changing the method signature.
func (s *TodoServer) ListTodos(ctx context.Context, _ *todov1.ListTodosRequest) (*todov1.ListTodosResponse, error) {
todos, err := s.svc.GetAll(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list todos: %v", err)
}
protos := make([]*todov1.Todo, len(todos))
for i, t := range todos {
protos[i] = toProto(t)
}
return &todov1.ListTodosResponse{Todos: protos}, nil
}
// UpdateTodo implements the UpdateTodo RPC.
func (s *TodoServer) UpdateTodo(ctx context.Context, req *todov1.UpdateTodoRequest) (*todov1.UpdateTodoResponse, error) {
todo, err := s.svc.Update(ctx, input.UpdateTodoRequest{
ID: domain.ID(req.GetId()),
Title: req.GetTitle(),
Description: req.GetDescription(),
Completed: req.GetCompleted(),
})
if err != nil {
return nil, toGRPCError(err)
}
return &todov1.UpdateTodoResponse{
Todo: toProto(todo),
}, nil
}
// DeleteTodo implements the DeleteTodo RPC.
// Returns an empty response on success — the gRPC status OK signals success.
func (s *TodoServer) DeleteTodo(ctx context.Context, req *todov1.DeleteTodoRequest) (*todov1.DeleteTodoResponse, error) {
if err := s.svc.Delete(ctx, domain.ID(req.GetId())); err != nil {
return nil, toGRPCError(err)
}
return &todov1.DeleteTodoResponse{}, nil
}
// ─── Internal Helpers ─────────────────────────────────────────────────────────
// toProto converts a domain.Todo into its proto representation.
//
// The critical conversion is time.Time → *timestamppb.Timestamp.
// timestamppb.New() handles this correctly, including UTC normalization.
// Never pass time.Time directly to a proto field — the types are incompatible.
func toProto(t domain.Todo) *todov1.Todo {
return &todov1.Todo{
Id: uint64(t.ID()),
Title: t.Title().String(),
Description: t.Description(),
Completed: t.IsCompleted(),
CreatedAt: timestamppb.New(t.CreatedAt()),
UpdatedAt: timestamppb.New(t.UpdatedAt()),
}
}
// toGRPCError converts a domain error into a gRPC status error.
//
// This function is the error translation layer. Domain errors must never leak
// across the gRPC boundary as raw Go errors — gRPC clients cannot interpret
// them. Instead, every domain error maps to a canonical gRPC status code.
//
// The table below mirrors what HTTP handlers do with status codes:
// domain.ErrNotFound → codes.NotFound (HTTP 404)
// domain.ErrEmptyTitle → codes.InvalidArgument (HTTP 422)
// domain.ErrTitleTooLong → codes.InvalidArgument (HTTP 422)
// domain.ErrAlreadyCompleted → codes.FailedPrecondition (HTTP 409)
// any other error → codes.Internal (HTTP 500)
//
// codes.FailedPrecondition is the correct code for "the operation is valid but
// cannot be performed in the current state" — exactly what trying to complete
// an already-completed todo means.
func toGRPCError(err error) error {
switch {
case errors.Is(err, domain.ErrNotFound):
return status.Errorf(codes.NotFound, "todo not found")
case errors.Is(err, domain.ErrEmptyTitle):
return status.Errorf(codes.InvalidArgument, "title cannot be empty")
case errors.Is(err, domain.ErrTitleTooLong):
return status.Errorf(codes.InvalidArgument, "title exceeds 255 characters")
case errors.Is(err, domain.ErrAlreadyCompleted):
return status.Errorf(codes.FailedPrecondition, "todo is already completed")
default:
return status.Errorf(codes.Internal, "internal error: %v", err)
}
}
// Compile-time assertion: verify that TodoServer implements TodoServiceServer.
// If you forget to implement a required method, the compilation error appears
// here with a clear message instead of in an obscure generated-code call site.
var _ todov1.TodoServiceServer = (*TodoServer)(nil)
The var _ todov1.TodoServiceServer = (*TodoServer)(nil) line at the bottom deserves a moment. When you embed UnimplementedTodoServiceServer, Go does not force you to implement any method — the embedded struct provides default implementations. The compile-time assertion does. If you add a new RPC to the proto and regenerate, this line breaks at compile time until you implement the new method. This is the correct behavior for a server that owns its contract.
Step 7 — Write a Logging Interceptor
Interceptors in gRPC play the role that middleware plays in Gin or http.Handler chains in the stdlib. They wrap every RPC call without modifying the handler functions themselves. A logging interceptor is the most common first interceptor in any production gRPC service.
touch internal/adapters/grpc/interceptor.go
package grpcadapter
import (
"context"
"log/slog"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/status"
)
// UnaryLoggingInterceptor is a server-side unary interceptor that logs
// every incoming RPC call: the method name, duration, and outcome.
//
// The function signature is dictated by the grpc.UnaryServerInterceptor type.
// You do not choose it — the framework defines it, you implement it.
//
// Parameters:
// ctx — the RPC context (deadlines, cancellations, metadata)
// req — the decoded proto request message (any type at this layer)
// info — metadata about the RPC: method name, service name
// handler — the actual RPC implementation; call it to execute the method
func UnaryLoggingInterceptor(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
start := time.Now()
// Call the actual handler. Everything before this line runs on the way in.
// Everything after runs on the way out — after the response is produced.
resp, err := handler(ctx, req)
// Extract the gRPC status code from the error.
// If err is nil, the code is OK. If it is a *status.Status, extract it.
// This is safer than inspecting err.Error() directly.
code := status.Code(err)
slog.InfoContext(ctx, "grpc call",
slog.String("method", info.FullMethod),
slog.String("code", code.String()),
slog.Duration("duration", time.Since(start)),
)
return resp, err
}
Step 8 — Wire the Server in main.go
The composition root for a gRPC server is familiar in structure: create dependencies in the right order, stitch them together, then start the transport. The only new parts are the gRPC-specific server setup.
Server reflection is registered here too. Reflection lets grpcurl — the command-line gRPC client — discover your service’s methods without knowing the proto file in advance. In production you may want to disable reflection for security (it exposes your service schema), but during development it is invaluable.
touch cmd/server/main.go
// Package main is the composition root. It creates and wires all the concrete
// implementations and starts the gRPC server.
//
// Manual dependency injection — the same explicit constructor-call pattern from
// the native REST API post. gRPC does not change this: the architecture is
// framework-agnostic at every layer except the transport adapter.
package main
import (
"database/sql"
"fmt"
"log/slog"
"net"
"os"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
todov1 "github.com/sazardev/todo-grpc/gen/todo/v1"
grpcadapter "github.com/sazardev/todo-grpc/internal/adapters/grpc"
sqliteadapter "github.com/sazardev/todo-grpc/internal/adapters/sqlite"
"github.com/sazardev/todo-grpc/internal/application"
_ "modernc.org/sqlite" // Pure-Go SQLite driver. The blank import registers
// the "sqlite" driver with database/sql.
)
func main() {
// ── Database ──────────────────────────────────────────────────────────────
//
// Open the SQLite database. The file "todos.db" is created if it does not exist.
// The same schema initialization from the native REST post applies here.
db, err := sql.Open("sqlite", "todos.db")
if err != nil {
slog.Error("failed to open database", slog.Any("error", err))
os.Exit(1)
}
defer db.Close()
if err := db.Ping(); err != nil {
slog.Error("database not reachable", slog.Any("error", err))
os.Exit(1)
}
if err := ensureSchema(db); err != nil {
slog.Error("schema initialization failed", slog.Any("error", err))
os.Exit(1)
}
// ── Dependency Chain ─────────────────────────────────────────────────────
//
// Innermost first, outermost last.
// The domain is the center. The gRPC server is the outermost shell.
repo := sqliteadapter.NewTodoRepository(db)
svc := application.NewTodoService(repo)
srv := grpcadapter.NewTodoServer(svc)
// ── gRPC Server ──────────────────────────────────────────────────────────
//
// grpc.NewServer accepts ServerOption values. A unary interceptor is the
// most common option to add: it applies to every unary RPC call.
// For multiple interceptors, use grpc.ChainUnaryInterceptor.
grpcServer := grpc.NewServer(
grpc.UnaryInterceptor(grpcadapter.UnaryLoggingInterceptor),
)
// Register the TodoService implementation.
// This is how gRPC knows which struct handles incoming TodoService calls.
todov1.RegisterTodoServiceServer(grpcServer, srv)
// Register server reflection. This allows grpcurl and other clients to
// discover available services and methods at runtime. Useful in development.
// Remove or gate behind an environment variable in production if needed.
reflection.Register(grpcServer)
// ── Listen ───────────────────────────────────────────────────────────────
addr := ":50051"
lis, err := net.Listen("tcp", addr)
if err != nil {
slog.Error("failed to listen", slog.String("addr", addr), slog.Any("error", err))
os.Exit(1)
}
slog.Info("gRPC server starting", slog.String("addr", addr))
// Serve blocks until the server is stopped or an error occurs.
if err := grpcServer.Serve(lis); err != nil {
slog.Error("server stopped", slog.Any("error", err))
os.Exit(1)
}
}
// ensureSchema creates the todos table if it does not exist.
// This is appropriate for development and simple deployments.
// For production with schema migrations, use Goose (covered in the packages post).
func ensureSchema(db *sql.DB) error {
const schema = `
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
completed INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);`
_, err := db.Exec(schema)
if err != nil {
return fmt.Errorf("create schema: %w", err)
}
return nil
}
Step 9 — Build and Run
go mod tidy
go run ./cmd/server
Expected output:
2026-04-03T10:00:00.000Z INFO gRPC server starting addr=:50051
The server is listening on TCP port 50051. gRPC’s default port convention is 50051, though you can use any port.
Step 10 — Test with grpcurl
grpcurl is the curl for gRPC. Install it:
# macOS
brew install grpcurl
# Linux — download binary from https://github.com/fullstorydev/grpcurl/releases
# Or build from source:
go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
Because we registered server reflection, grpcurl can describe the service without the proto file:
# List all available services
grpcurl -plaintext localhost:50051 list
# Describe the TodoService (shows all methods)
grpcurl -plaintext localhost:50051 describe todo.v1.TodoService
# Create a todo
grpcurl -plaintext \
-d '{"title": "Learn gRPC", "description": "Build something real with it"}' \
localhost:50051 \
todo.v1.TodoService/CreateTodo
# List all todos
grpcurl -plaintext \
-d '{}' \
localhost:50051 \
todo.v1.TodoService/ListTodos
# Get one todo
grpcurl -plaintext \
-d '{"id": 1}' \
localhost:50051 \
todo.v1.TodoService/GetTodo
# Update a todo
grpcurl -plaintext \
-d '{"id": 1, "title": "Learn gRPC", "completed": true}' \
localhost:50051 \
todo.v1.TodoService/UpdateTodo
# Delete a todo
grpcurl -plaintext \
-d '{"id": 1}' \
localhost:50051 \
todo.v1.TodoService/DeleteTodo
# Test a validation error (empty title)
grpcurl -plaintext \
-d '{"title": ""}' \
localhost:50051 \
todo.v1.TodoService/CreateTodo
The last command should return:
{
"code": "INVALID_ARGUMENT",
"message": "title cannot be empty"
}
The -plaintext flag is required because the server is not running TLS. In production, gRPC uses mTLS or channel credentials — remove -plaintext and provide certificate paths.
Step 11 — Write Tests with bufconn
Testing gRPC handlers conventionally means starting a real server on a port, which carries risks: port conflicts in CI, ordering dependencies, test isolation problems. The bufconn package from the gRPC library solves this elegantly.
bufconn creates an in-memory TCP listener. The server and client connect over memory, not network sockets. There are no ports, no kernel involvement, and the connection is as fast as a function call. Integration tests run in parallel without interfering with each other.
touch internal/adapters/grpc/server_test.go
package grpcadapter_test
import (
"context"
"errors"
"net"
"testing"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
todov1 "github.com/sazardev/todo-grpc/gen/todo/v1"
grpcadapter "github.com/sazardev/todo-grpc/internal/adapters/grpc"
"github.com/sazardev/todo-grpc/internal/application"
"github.com/sazardev/todo-grpc/internal/domain"
"github.com/sazardev/todo-grpc/internal/ports/input"
"github.com/sazardev/todo-grpc/internal/ports/output"
)
// ─── Mock Repository ──────────────────────────────────────────────────────────
//
// A minimal in-memory repository for testing. The same mock used in the
// application layer tests from the native REST post applies here unchanged.
// This is the architecture benefit: the test double works across transport layers.
type mockRepo struct {
todos map[domain.ID]domain.Todo
nextID domain.ID
}
func newMockRepo() *mockRepo {
return &mockRepo{todos: make(map[domain.ID]domain.Todo), nextID: 1}
}
func (r *mockRepo) Save(_ context.Context, todo domain.Todo) (domain.Todo, error) {
id := r.nextID
r.nextID++
saved := domain.ReconstituteTodo(id, todo.Title(), todo.Description(), todo.IsCompleted(), todo.CreatedAt(), todo.UpdatedAt())
r.todos[id] = saved
return saved, nil
}
func (r *mockRepo) FindByID(_ context.Context, id domain.ID) (domain.Todo, error) {
t, ok := r.todos[id]
if !ok {
return domain.Todo{}, domain.ErrNotFound
}
return t, nil
}
func (r *mockRepo) FindAll(_ context.Context) ([]domain.Todo, error) {
result := make([]domain.Todo, 0, len(r.todos))
for _, t := range r.todos {
result = append(result, t)
}
return result, nil
}
func (r *mockRepo) Update(_ context.Context, todo domain.Todo) (domain.Todo, error) {
if _, ok := r.todos[todo.ID()]; !ok {
return domain.Todo{}, domain.ErrNotFound
}
r.todos[todo.ID()] = todo
return todo, nil
}
func (r *mockRepo) Delete(_ context.Context, id domain.ID) error {
if _, ok := r.todos[id]; !ok {
return domain.ErrNotFound
}
delete(r.todos, id)
return nil
}
var _ output.TodoRepository = (*mockRepo)(nil)
// ─── Test Server Setup ────────────────────────────────────────────────────────
// bufSize is the in-memory buffer size for the bufconn listener.
// 1 MB is enough for test messages.
const bufSize = 1 << 20 // 1MB
// newTestServer creates a gRPC server backed by an in-memory repository
// and returns a client connected to it via bufconn.
//
// The cleanup function returned by this helper stops the server and closes
// the connection. Call it with defer in each test.
func newTestServer(t *testing.T) (todov1.TodoServiceClient, func()) {
t.Helper()
// Create an in-memory listener. No TCP ports involved.
lis := bufconn.Listen(bufSize)
// Build the dependency chain exactly as main.go does.
repo := newMockRepo()
svc := application.NewTodoService(repo)
srv := grpcadapter.NewTodoServer(svc)
grpcServer := grpc.NewServer()
todov1.RegisterTodoServiceServer(grpcServer, srv)
// Start the server in a goroutine. Serve will block until Stop is called.
go func() {
if err := grpcServer.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
t.Errorf("gRPC server stopped unexpectedly: %v", err)
}
}()
// Create a client that dials the bufconn listener.
// grpc.WithContextDialer injects the bufconn dialer so the client
// bypasses the OS network stack and connects in memory.
conn, err := grpc.NewClient(
"passthrough:///bufconn",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return lis.DialContext(ctx)
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("failed to dial bufconn: %v", err)
}
cleanup := func() {
conn.Close()
grpcServer.Stop()
}
return todov1.NewTodoServiceClient(conn), cleanup
}
// ─── Tests ────────────────────────────────────────────────────────────────────
func TestTodoServer_CreateTodo_GivenValidTitle_ReturnsTodoWithID(t *testing.T) {
client, cleanup := newTestServer(t)
defer cleanup()
resp, err := client.CreateTodo(context.Background(), &todov1.CreateTodoRequest{
Title: "Write gRPC tests",
Description: "Use bufconn",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetTodo().GetId() == 0 {
t.Error("expected non-zero ID in response")
}
if resp.GetTodo().GetTitle() != "Write gRPC tests" {
t.Errorf("expected title %q, got %q", "Write gRPC tests", resp.GetTodo().GetTitle())
}
}
func TestTodoServer_CreateTodo_GivenEmptyTitle_ReturnsInvalidArgument(t *testing.T) {
client, cleanup := newTestServer(t)
defer cleanup()
_, err := client.CreateTodo(context.Background(), &todov1.CreateTodoRequest{
Title: "",
})
if err == nil {
t.Fatal("expected error, got nil")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("error is not a gRPC status: %v", err)
}
if st.Code() != codes.InvalidArgument {
t.Errorf("expected code InvalidArgument, got %s", st.Code())
}
}
func TestTodoServer_GetTodo_GivenMissingID_ReturnsNotFound(t *testing.T) {
client, cleanup := newTestServer(t)
defer cleanup()
_, err := client.GetTodo(context.Background(), &todov1.GetTodoRequest{Id: 9999})
if err == nil {
t.Fatal("expected error, got nil")
}
st, _ := status.FromError(err)
if st.Code() != codes.NotFound {
t.Errorf("expected code NotFound, got %s", st.Code())
}
}
func TestTodoServer_ListTodos_GivenTwoCreated_ReturnsBoth(t *testing.T) {
client, cleanup := newTestServer(t)
defer cleanup()
client.CreateTodo(context.Background(), &todov1.CreateTodoRequest{Title: "First"})
client.CreateTodo(context.Background(), &todov1.CreateTodoRequest{Title: "Second"})
resp, err := client.ListTodos(context.Background(), &todov1.ListTodosRequest{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.GetTodos()) != 2 {
t.Errorf("expected 2 todos, got %d", len(resp.GetTodos()))
}
}
func TestTodoServer_DeleteTodo_GivenExisting_Succeeds(t *testing.T) {
client, cleanup := newTestServer(t)
defer cleanup()
created, _ := client.CreateTodo(context.Background(), &todov1.CreateTodoRequest{Title: "Delete me"})
id := created.GetTodo().GetId()
_, err := client.DeleteTodo(context.Background(), &todov1.DeleteTodoRequest{Id: id})
if err != nil {
t.Fatalf("unexpected delete error: %v", err)
}
_, err = client.GetTodo(context.Background(), &todov1.GetTodoRequest{Id: id})
st, _ := status.FromError(err)
if st.Code() != codes.NotFound {
t.Errorf("expected NotFound after delete, got %s", st.Code())
}
}
Add the bufconn package to your dependencies:
go get google.golang.org/grpc/test/bufconn
go mod tidy
Run the tests:
go test ./...
All tests run in memory, in parallel, without starting a real TCP server. The test for NotFound creates no state. The test for Delete creates exactly one todo and removes it. Each test is isolated.
Step 12 — Compare with the REST and Packages Versions
Now that all three implementations exist, the architecture argument has evidence.
What Changed Between Versions
| Component | REST (stdlib) | REST (packages) | gRPC |
|---|---|---|---|
internal/domain/ | written once | copied unchanged | copied unchanged |
internal/ports/ | written once | copied unchanged | copied unchanged |
internal/application/ | written once | copied unchanged | copied unchanged |
internal/adapters/sqlite/ | written once | copied unchanged | copied unchanged |
| Transport adapter | net/http handler | Gin handler | gRPC server |
| Contract format | none / docs | none / docs | .proto file |
| Serialization | JSON | JSON | Protocol Buffers |
| Client generation | none | none | auto from proto |
| Error vocabulary | HTTP status codes | HTTP status codes | gRPC codes |
| Testing tool | curl | curl | grpcurl / bufconn |
Four layers written once. Three different transport adapters. The domain never changed.
Where gRPC Adds Real Value
The .proto file is a machine-readable contract. From it you generate:
- Go server and client (this post)
- TypeScript client for a web frontend (
protoc-gen-ts) - Kotlin client for an Android app (
protoc-gen-kotlin) - Swift client for an iOS app (
protoc-gen-swift) - Python client for a data processing service (
protoc-gen-python)
All five clients speak the same binary protocol to the same server. When you add a field to a message, you add it in one place — the .proto file — and regenerate all five clients. No manual sync, no format drift, no documentation to update.
For an internal service consumed by multiple clients in multiple languages, this is a substantial operational advantage over hand-written REST.
Where gRPC Adds Friction
Every new developer on the project needs to understand protoc and the generation workflow. The toolchain (compiler, plugins, PATH) must be set up on every development machine and every CI runner. protoc versions can produce slightly different output — locking the version in a Makefile or buf.gen.yaml (the buf tool from Buf Technologies) eliminates this variation.
Debugging is harder. You cannot curl a gRPC endpoint without grpcurl. You cannot inspect requests in a browser. Binary Protocol Buffers do not appear in plain text in network inspectors. If your team is used to HTTP debugging workflows, the initial friction is real.
Browser support for gRPC requires a proxy layer (gRPC-Web + Envoy or Nginx). Native gRPC over HTTP/2 is not directly accessible from browser JavaScript without that proxy. REST does not have this constraint.
Closing
Three posts. One domain. Three transports.
The domain layer wrote domain.ErrNotFound once. The SQL code wrote one scanRow function. The application layer wrote one GetByID use case. All three servers — the stdlib HTTP server, the Gin server, and the gRPC server — call the same svc.GetByID. When the function finds nothing, every transport translates domain.ErrNotFound into its own vocabulary: a 404, a 404, and a codes.NotFound. The domain does not care what was sent back. It only knows what the business rule is.
gRPC changes the conversation from “what format should the body be in?” to “what is the contract?”. A .proto file is a contract that the compiler enforces. REST APIs drift. Proto contracts compile or they do not.
Choose gRPC when your clients are typed systems that can generate from a proto, when you need streaming, or when binary serialization performance is a real requirement. Keep REST for public APIs, browser-facing services, and anywhere the JSON debuggability matters more than protocol efficiency.
Both are legitimate answers. The architecture gives you the freedom to choose.
The best transport layer is the one your clients can speak fluently. The best domain layer is the one that does not care which transport layer you chose.
Related articles
By relevance
Go 1.25 Native REST API: Hexagonal Architecture, DDD, TDD & BDD from Zero
Build a fully functional TODO REST API in Go 1.25 using only the standard library and SQLite. Step-by-step: Hexagonal Architecture, DDD, TDD, BDD, dependency injection, clean code.
Go TODO API with Real Packages: Gin, GORM, Zap, Validator & Fx
Build the same TODO REST API from the native stdlib post — now with Gin, GORM, uber-go/zap, go-playground/validator, and uber-go/fx. See exactly where packages save code and why.
Go 1.25 Medical API: DDD, TDD, BDD, PostgreSQL, Redis — Complete Clinical System
Build a production-ready medical records API in Go 1.25 with DDD, TDD, BDD, Gherkin scenarios, PostgreSQL, Redis. Full patient history, clinical boards, and every file explained.
Go 1.25 Production API: DDD, TDD, BDD, PostgreSQL, Redis — Complete Project Setup
Build a production-ready Go 1.25 REST API from scratch. Full DDD architecture, TDD/BDD testing, PostgreSQL, Redis caching, Chi router, and every file explained with clean code patterns.