AI Tools Compared

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

// 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)
        })
    }
}