Step-by-Step: Upgrade a Go 1.25 Service from Monolith to Microservices with gRPC 1.60 and etcd 3.5
Monolithic architectures often become unwieldy as services scale, leading to slower deployment cycles and tight coupling. This guide walks through migrating a Go 1.25 monolithic service to a microservices architecture using gRPC 1.60 for high-performance inter-service communication and etcd 3.5 for distributed service discovery.
Prerequisites
- Go 1.25 installed locally
- gRPC 1.60 Go module (google.golang.org/grpc v1.60.0)
- etcd 3.5 cluster running (local or managed)
- Protocol Buffers compiler (protoc v3.25+) installed
- Basic knowledge of Go, gRPC, and etcd
Step 1: Assess and Audit the Existing Monolith
Start by mapping all business capabilities of the monolith. For example, if the monolith handles user management, order processing, and inventory tracking, these are natural microservice boundaries. Use Go's built-in profiling tools (pprof) to identify high-traffic, independent components that can be extracted first.
Create a dependency graph to document shared databases, in-memory state, and synchronous function calls between components. This will highlight coupling points to resolve before extraction.
Step 2: Define Service Boundaries and gRPC Contracts
Once boundaries are set, define gRPC service contracts using Protocol Buffers (protobuf). For a user service example, create a user.proto file:
syntax = "proto3";
package user;
option go_package = "github.com/yourorg/user-service/proto/user";
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
}
message GetUserRequest {
string user_id = 1;
}
message GetUserResponse {
string user_id = 1;
string email = 2;
string name = 3;
}
message CreateUserRequest {
string email = 1;
string name = 2;
string password = 3;
}
message CreateUserResponse {
string user_id = 1;
bool success = 2;
}
Compile the proto file to generate Go code with:
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
proto/user/user.proto
Step 3: Set Up gRPC 1.60 in Go 1.25
Initialize a new Go 1.25 module for the user service:
go mod init github.com/yourorg/user-service
go get google.golang.org/grpc@v1.60.0
go get google.golang.org/protobuf@v1.31.0
Implement the gRPC server by importing the generated proto code and registering the service:
package main
import (
"context"
"log"
"net"
pb "github.com/yourorg/user-service/proto/user"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
type userServer struct {
pb.UnimplementedUserServiceServer
}
func (s *userServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
// Fetch user from database (implementation omitted for brevity)
return &pb.GetUserResponse{
UserId: req.UserId,
Email: "user@example.com",
Name: "John Doe",
}, nil
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
s := grpc.NewServer()
pb.RegisterUserServiceServer(s, &userServer{})
log.Printf("gRPC server listening on :50051")
if err := s.Serve(lis); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}
Step 4: Integrate etcd 3.5 for Service Discovery
etcd 3.5 provides a distributed key-value store for service registration and discovery. Add the etcd client to your Go module:
go get go.etcd.io/etcd/client/v3@v3.5.0
Implement service registration on startup, with a TTL (time-to-live) lease to handle instance failures:
package main
import (
"context"
"log"
"time"
"go.etcd.io/etcd/client/v3"
)
func registerService() {
cli, err := clientv3.New(clientv3.Config{
Endpoints: []string{"localhost:2379"},
DialTimeout: 5 * time.Second,
})
if err != nil {
log.Fatalf("Failed to connect to etcd: %v", err)
}
defer cli.Close()
// Create a lease with 10 second TTL
lease, err := cli.Grant(context.Background(), 10)
if err != nil {
log.Fatalf("Failed to create lease: %v", err)
}
// Register service instance
_, err = cli.Put(context.Background(), "/services/user/instance-1", "localhost:50051", clientv3.WithLease(lease.ID))
if err != nil {
log.Fatalf("Failed to register service: %v", err)
}
// Keep lease alive
keepAlive, err := cli.KeepAlive(context.Background(), lease.ID)
if err != nil {
log.Fatalf("Failed to keep lease alive: %v", err)
}
go func() {
for range keepAlive {
// Receive keep-alive responses
}
}()
}
Call registerService() in your gRPC server's main function before starting the server.
Step 5: Extract Monolith Components Incrementally
Use the strangler fig pattern to extract components incrementally: route a small percentage of traffic to the new microservice, validate functionality, then increase traffic until the monolith component is fully replaced. For shared databases, use database per service pattern or add an API layer to the monolith to avoid direct database access from microservices.
Update the monolith to call the new user microservice via gRPC instead of local function calls. Use the etcd client in the monolith to discover the user service instance:
func discoverUserService() string {
cli, _ := clientv3.New(clientv3.Config{
Endpoints: []string{"localhost:2379"},
})
defer cli.Close()
resp, _ := cli.Get(context.Background(), "/services/user/", clientv3.WithPrefix())
if len(resp.Kvs) > 0 {
return string(resp.Kvs[0].Value)
}
return ""
}
func callUserService() {
addr := discoverUserService()
conn, _ := grpc.Dial(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
defer conn.Close()
client := pb.NewUserServiceClient(conn)
resp, _ := client.GetUser(context.Background(), &pb.GetUserRequest{UserId: "123"})
log.Printf("User: %v", resp)
}
Step 6: Test and Deploy Microservices
Test each microservice independently with unit tests and integration tests for gRPC endpoints. Use tools like grpcurl to test gRPC endpoints manually:
grpcurl -plaintext localhost:50051 user.UserService/GetUser -d '{"user_id": "123"}'
Deploy microservices using containerization (Docker) and orchestration (Kubernetes). Update etcd endpoints to point to the production etcd cluster, and configure health checks to automatically deregister unhealthy instances.
Conclusion
Migrating a Go 1.25 monolith to microservices with gRPC 1.60 and etcd 3.5 improves scalability, deployment velocity, and fault isolation. Follow incremental extraction to minimize risk, and leverage gRPC's high performance and etcd's reliable service discovery for a robust architecture.
Top comments (0)