Go is the dominant language for microservices, and AI tools vary significantly in their Go idiom knowledge. The gap between “compiles” and “idiomatic Go” is where most AI tools fall short — returning errors with fmt.Errorf wrapping vs errors.As, using goroutines without proper context cancellation, or generating middleware that doesn’t follow the standard http.Handler pattern.
This guide tests Claude Code, Copilot, and Cursor on four real Go microservice tasks.
Table of Contents
- Task 1: gRPC Service with Interceptors
- Task 2: HTTP Middleware Chain
- Task 3: Error Wrapping and Handling
- Task 4: Table-Driven Tests
- Summary
- Related Reading
Task 1: gRPC Service with Interceptors
// proto/user.proto
syntax = "proto3";
package user.v1;
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc CreateUser(CreateUserRequest) returns (User);
}
message GetUserRequest { string user_id = 1; }
message CreateUserRequest {
string name = 1;
string email = 2;
}
message User {
string id = 1;
string name = 2;
string email = 3;
int64 created_at = 4;
}
Prompt: “Implement the gRPC server with a logging interceptor and request validation.”
Copilot’s server:
// Copilot generated (simplified)
type UserServer struct{}
func (s *UserServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
return db.GetUser(req.UserId)
}
No validation, no interceptors, and no context propagation from the interceptor.
Claude’s server:
package main
import (
"context"
"fmt"
"log/slog"
"net"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/grpc/metadata"
pb "example.com/user/v1"
)
type UserServer struct {
pb.UnimplementedUserServiceServer
repo UserRepository
}
// Logging interceptor — logs method, duration, and error code
func loggingInterceptor(logger *slog.Logger) grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
start := time.Now()
resp, err := handler(ctx, req)
duration := time.Since(start)
code := codes.OK
if err != nil {
code = status.Code(err)
}
logger.InfoContext(ctx, "grpc request",
"method", info.FullMethod,
"duration_ms", duration.Milliseconds(),
"code", code.String(),
)
return resp, err
}
}
// Validation interceptor — returns InvalidArgument for bad requests
func validationInterceptor() grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
if v, ok := req.(interface{ Validate() error }); ok {
if err := v.Validate(); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "validation failed: %v", err)
}
}
return handler(ctx, req)
}
}
func (s *UserServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
if req.UserId == "" {
return nil, status.Error(codes.InvalidArgument, "user_id is required")
}
user, err := s.repo.Get(ctx, req.UserId)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "user %s not found", req.UserId)
}
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
return userToProto(user), nil
}
func (s *UserServer) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.User, error) {
if req.Name == "" || req.Email == "" {
return nil, status.Error(codes.InvalidArgument, "name and email are required")
}
user, err := s.repo.Create(ctx, req.Name, req.Email)
if err != nil {
if errors.Is(err, ErrDuplicate) {
return nil, status.Errorf(codes.AlreadyExists, "email %s already registered", req.Email)
}
return nil, status.Errorf(codes.Internal, "failed to create user: %v", err)
}
return userToProto(user), nil
}
func main() {
logger := slog.Default()
lis, _ := net.Listen("tcp", ":50051")
srv := grpc.NewServer(
grpc.ChainUnaryInterceptor(
loggingInterceptor(logger),
validationInterceptor(),
),
)
pb.RegisterUserServiceServer(srv, &UserServer{})
srv.Serve(lis)
}
Claude used grpc.ChainUnaryInterceptor, proper gRPC status codes for each error type, and log/slog (the modern Go logger).
Task 2: HTTP Middleware Chain
Prompt: “Write a middleware chain for an HTTP service with request ID injection, CORS, and rate limiting.”
// Claude's middleware (idiomatic Go)
package middleware
import (
"context"
"net/http"
"sync"
"time"
"github.com/google/uuid"
"golang.org/x/time/rate"
)
type contextKey string
const RequestIDKey contextKey = "request_id"
// RequestID injects a unique ID into every request context and response header.
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = uuid.New().String()
}
ctx := context.WithValue(r.Context(), RequestIDKey, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// CORS handles preflight and adds CORS headers.
func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
allowed := make(map[string]bool, len(allowedOrigins))
for _, o := range allowedOrigins {
allowed[o] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if allowed[origin] || allowed["*"] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// RateLimiter limits requests per IP using a token bucket algorithm.
func RateLimiter(rps float64, burst int) func(http.Handler) http.Handler {
limiters := sync.Map{}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
val, _ := limiters.LoadOrStore(ip, &rateLimiterEntry{
limiter: rate.NewLimiter(rate.Limit(rps), burst),
lastSeen: time.Now(),
})
entry := val.(*rateLimiterEntry)
entry.lastSeen = time.Now()
if !entry.limiter.Allow() {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
type rateLimiterEntry struct {
limiter *rate.Limiter
lastSeen time.Time
}
// Chain applies middleware in order (outermost first).
func Chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
h = middlewares[i](h)
}
return h
}
// Usage:
// handler := middleware.Chain(
// myHandler,
// middleware.RequestID,
// middleware.CORS([]string{"https://example.com"}),
// middleware.RateLimiter(10, 20),
// )
Cursor and Copilot both produced middleware that worked but was less idiomatic: Cursor stored request IDs as strings in context (not typed keys), and Copilot’s rate limiter stored limiters in a non-concurrent-safe map.
Task 3: Error Wrapping and Handling
// Prompt: "Show idiomatic Go error handling for a multi-layer
// service (handler → service → repository)"
// Claude's approach — all three layers
package main
import (
"errors"
"fmt"
)
// --- Repository layer errors ---
var (
ErrNotFound = errors.New("not found")
ErrDuplicate = errors.New("duplicate key")
ErrConnection = errors.New("connection error")
)
type repoError struct {
Op string
Err error
}
func (e *repoError) Error() string {
return fmt.Sprintf("repository.%s: %v", e.Op, e.Err)
}
func (e *repoError) Unwrap() error { return e.Err }
// --- Service layer ---
type serviceError struct {
Code string
Message string
Err error
}
func (e *serviceError) Error() string {
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Err)
}
func (e *serviceError) Unwrap() error { return e.Err }
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, &serviceError{
Code: "USER_NOT_FOUND",
Message: fmt.Sprintf("user %s does not exist", id),
Err: err,
}
}
// Wrap unexpected errors without exposing internal details
return nil, fmt.Errorf("GetUser(%s): %w", id, err)
}
return user, nil
}
// --- HTTP handler layer ---
func (h *Handler) GetUserHandler(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
user, err := h.svc.GetUser(r.Context(), id)
if err != nil {
var svcErr *serviceError
if errors.As(err, &svcErr) {
switch svcErr.Code {
case "USER_NOT_FOUND":
http.Error(w, svcErr.Message, http.StatusNotFound)
default:
http.Error(w, svcErr.Message, http.StatusBadRequest)
}
return
}
// Unknown error — don't leak internals
slog.ErrorContext(r.Context(), "unexpected error", "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(user)
}
Task 4: Table-Driven Tests
// Claude's test pattern for the UserService
func TestUserService_GetUser(t *testing.T) {
tests := []struct {
name string
userID string
repoUser *User
repoErr error
wantUser *User
wantErr bool
wantCode string
}{
{
name: "returns user when found",
userID: "usr_123",
repoUser: &User{ID: "usr_123", Name: "Alice"},
wantUser: &User{ID: "usr_123", Name: "Alice"},
},
{
name: "returns USER_NOT_FOUND when repo returns ErrNotFound",
userID: "usr_missing",
repoErr: ErrNotFound,
wantErr: true,
wantCode: "USER_NOT_FOUND",
},
{
name: "propagates unexpected errors",
userID: "usr_123",
repoErr: fmt.Errorf("db connection lost"),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := &mockUserRepo{
user: tt.repoUser,
err: tt.repoErr,
}
svc := NewUserService(repo)
got, err := svc.GetUser(context.Background(), tt.userID)
if tt.wantErr {
require.Error(t, err)
if tt.wantCode != "" {
var svcErr *serviceError
require.True(t, errors.As(err, &svcErr))
assert.Equal(t, tt.wantCode, svcErr.Code)
}
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantUser, got)
})
}
}