07 - Building a gRPC Server

📋 Jump to Takeaways

Time to write actual server code. We'll implement the LinkService we defined in the proto file, register it with a gRPC server, and run it.

Generated Code Recap

In lesson 03, protoc generated link_grpc.pb.go. That file contains:

  • LinkServiceServer — a Go interface declaring method signatures like CreateLink(...) and GetLink(...)
  • UnimplementedLinkServiceServer — a default struct that returns "unimplemented" for every method
  • RegisterLinkServiceServer(...) — a function to wire your implementation to the gRPC server
  • linkServiceClient — a generated client that handles serialization and network calls (covered in lesson 08)

The generated code defines the contract (what methods must exist). This lesson is where you write the implementation (what those methods actually do). The generated client is the other side — it calls your server over the network. You don't write it, you just use it.

The Server Struct

Create a struct that embeds the unimplemented server:

type linkServer struct {
    pb.UnimplementedLinkServiceServer
    mu     sync.RWMutex
    links  map[string]*pb.Link
    nextID atomic.Int64
}

func newLinkServer() *linkServer {
    return &linkServer{
        links: make(map[string]*pb.Link),
    }
}

Why Embed UnimplementedLinkServiceServer?

The generated UnimplementedLinkServiceServer has every method already defined, but each one just returns an "unimplemented" error:

// Inside link_grpc.pb.go (generated, don't edit)
type UnimplementedLinkServiceServer struct{}

func (UnimplementedLinkServiceServer) CreateLink(context.Context, *CreateLinkRequest) (*CreateLinkResponse, error) {
    return nil, status.Errorf(codes.Unimplemented, "method CreateLink not implemented")
}

By embedding it, your linkServer satisfies the LinkServiceServer interface immediately — even before you write any methods. When you define your own CreateLink on linkServer, it overrides the embedded version. Methods you haven't written yet still return "unimplemented" gracefully instead of failing to compile.

You might notice mustEmbedUnimplementedLinkServiceServer() in the interface definition. It's an unexported method that forces you to embed the struct — you can't satisfy the interface without it. This is a compile-time safety net: without the embed, adding a new RPC to the .proto file would break your server (it no longer satisfies the interface). With the embed, new RPCs get a default "unimplemented" response automatically.

func (s *linkServer) CreateLink(ctx context.Context, req *pb.CreateLinkRequest) (*pb.CreateLinkResponse, error) {
    id := s.nextID.Add(1)
    code := shortCode()

    link := &pb.Link{
        Id:        id,
        Url:       req.GetUrl(),
        ShortCode: code,
        Clicks:    0,
    }

    s.mu.Lock()
    s.links[code] = link
    s.mu.Unlock()

    return &pb.CreateLinkResponse{Link: link}, nil
}

func shortCode() string {
    const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
    b := make([]byte, 6)
    for i := range b {
        n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
        b[i] = chars[n.Int64()]
    }
    return string(b) // e.g. "k7m2px", "a3f9wq", "zt04bn"
}

Every gRPC method takes a context.Context and a request message, and returns a response message and an error. If you return a non-nil error, gRPC sends it to the client as a status code. We'll cover error handling properly in lesson 12.

crypto/rand.Int requires *big.Int for both input and output — it's designed for cryptographic use cases with arbitrarily large numbers. For a 36-character alphabet it's overkill, but it's the only standard library way to get unbiased cryptographically secure random integers.

func (s *linkServer) GetLink(ctx context.Context, req *pb.GetLinkRequest) (*pb.GetLinkResponse, error) {
    s.mu.RLock()
    link, ok := s.links[req.GetShortCode()]
    s.mu.RUnlock()

    if !ok {
        return nil, status.Errorf(codes.NotFound, "link not found: %s", req.GetShortCode())
    }

    return &pb.GetLinkResponse{Link: link}, nil
}

req.GetShortCode() returns whatever short code the client sent in the request — it's not random. The client already has this code from a previous CreateLink call and is now looking it up. The server checks the map: if the code exists, return the link; if not, return a NotFound error.

status.Errorf with codes.NotFound is how you return proper gRPC errors. Don't return plain Go errors — the client won't get a meaningful status code. Import these:

import (
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)
func (s *linkServer) DeleteLink(ctx context.Context, req *pb.DeleteLinkRequest) (*pb.DeleteLinkResponse, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    if _, ok := s.links[req.GetShortCode()]; !ok {
        return nil, status.Errorf(codes.NotFound, "link not found: %s", req.GetShortCode())
    }

    delete(s.links, req.GetShortCode())
    return &pb.DeleteLinkResponse{}, nil
}

Server Startup

Wire everything together in main():

func main() {
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }

    srv := grpc.NewServer()
    pb.RegisterLinkServiceServer(srv, newLinkServer())

    // Enable reflection for debugging tools like grpcurl
    reflection.Register(srv)

    fmt.Println("gRPC server listening on :50051")
    if err := srv.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

RegisterLinkServiceServer connects your implementation to the gRPC server. Port 50051 is the conventional default for gRPC. You can use any port.

Complete Server File

Here's the complete main.go with all imports:

package main

import (
    "context"
    "crypto/rand"
    "fmt"
    "log"
    "math/big"
    "net"
    "sync"
    "sync/atomic"

    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/reflection"
    "google.golang.org/grpc/status"

    "shortener/pb"
)

type linkServer struct {
    pb.UnimplementedLinkServiceServer
    mu     sync.RWMutex
    links  map[string]*pb.Link
    nextID atomic.Int64
}

func newLinkServer() *linkServer {
    return &linkServer{
        links: make(map[string]*pb.Link),
    }
}

func (s *linkServer) CreateLink(ctx context.Context, req *pb.CreateLinkRequest) (*pb.CreateLinkResponse, error) {
    id := s.nextID.Add(1)
    code := shortCode()

    link := &pb.Link{
        Id:        id,
        Url:       req.GetUrl(),
        ShortCode: code,
        Clicks:    0,
    }

    s.mu.Lock()
    s.links[code] = link
    s.mu.Unlock()

    return &pb.CreateLinkResponse{Link: link}, nil
}

func (s *linkServer) GetLink(ctx context.Context, req *pb.GetLinkRequest) (*pb.GetLinkResponse, error) {
    s.mu.RLock()
    link, ok := s.links[req.GetShortCode()]
    s.mu.RUnlock()

    if !ok {
        return nil, status.Errorf(codes.NotFound, "link not found: %s", req.GetShortCode())
    }

    return &pb.GetLinkResponse{Link: link}, nil
}

func (s *linkServer) DeleteLink(ctx context.Context, req *pb.DeleteLinkRequest) (*pb.DeleteLinkResponse, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    if _, ok := s.links[req.GetShortCode()]; !ok {
        return nil, status.Errorf(codes.NotFound, "link not found: %s", req.GetShortCode())
    }

    delete(s.links, req.GetShortCode())
    return &pb.DeleteLinkResponse{}, nil
}

func shortCode() string {
    const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
    b := make([]byte, 6)
    for i := range b {
        n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
        b[i] = chars[n.Int64()]
    }
    return string(b)
}

func main() {
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }

    srv := grpc.NewServer()
    pb.RegisterLinkServiceServer(srv, newLinkServer())
    reflection.Register(srv)

    fmt.Println("gRPC server listening on :50051")
    if err := srv.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

Server Reflection

That reflection.Register(srv) line enables server reflection. This lets tools like grpcurl and grpcui discover your services and methods without having the .proto files. Think of it like a self-describing API.

# Install grpcurl
go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest

# List services
grpcurl -plaintext localhost:50051 list

# Describe a service
grpcurl -plaintext localhost:50051 describe shortener.LinkService

# Call a method
grpcurl -plaintext -d '{"url": "https://example.com"}' \
  localhost:50051 shortener.LinkService/CreateLink

The -plaintext flag disables TLS. In production you'd use TLS, but for local development plaintext is fine.

Running the Server

go run .
# gRPC server listening on :50051

In another terminal:

grpcurl -plaintext -d '{"url": "https://golang.org"}' \
  localhost:50051 shortener.LinkService/CreateLink

You should get back a response with the created link.

Key Takeaways

  • Embed UnimplementedLinkServiceServer so new RPCs don't break your server
  • mustEmbedUnimplementedLinkServiceServer() forces the embed at compile time
  • Every RPC method takes context.Context and a request, returns a response and error
  • Use status.Errorf with codes.* for proper gRPC errors, not plain Go errors
  • Register your service with pb.RegisterLinkServiceServer
  • Enable reflection for debugging with grpcurl
  • Port 50051 is the conventional gRPC default

💻 Examples

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue

© 2026 ByteLearn.dev. Free courses for developers. · Privacy