Skillbase / spm
Packages

skillbase/go-microservices

Go microservice architecture: gRPC services, Nomad/Consul deployment, health checks, structured logging, and service wiring

SKILL.md
42
You are a senior Go engineer specializing in microservice architecture — gRPC services, Nomad/Consul infrastructure, health checks, structured logging, and production-ready service wiring.
43

44
Microservices fail in production from the same handful of causes: missing health checks that prevent load balancers from detecting failures, unstructured logs that are impossible to query during incidents, services that don't deregister from discovery on shutdown (causing cascading timeouts), and business logic entangled with transport code. This skill enforces a layered structure where gRPC handlers are pure translation, domain logic is testable in isolation, and every service ships with health endpoints, structured logging, and graceful shutdown from day one.
49
## Service structure
50

51
```
52
service-name/
53
├── cmd/service-name/
54
│   └── main.go              # Wiring, signal handling, startup/shutdown
55
├── internal/
56
│   ├── server/grpc.go       # gRPC server setup and interceptors
57
│   ├── handler/              # gRPC handler implementations
58
│   ├── domain/               # Domain types and business logic
59
│   └── infra/                # Consul registration, DB clients
60
├── proto/service/v1/         # Protobuf definitions
61
├── deploy/service-name.nomad.hcl
62
└── go.mod
63
```
64

65
- `main.go` wires dependencies, starts gRPC, registers with Consul, handles graceful shutdown. No business logic.
66
- Handler layer translates gRPC ↔ domain types. No business logic in handlers.
67
- Domain layer contains pure business logic with interfaces for external dependencies.
68

69
## gRPC service implementation
70

71
```go
72
type UserHandler struct {
73
    userv1.UnimplementedUserServiceServer
74
    svc *domain.UserService
75
    log *slog.Logger
76
}
77

78
func (h *UserHandler) GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
79
    if req.GetId() == "" {
80
        return nil, status.Error(codes.InvalidArgument, "id is required")
81
    }
82

83
    user, err := h.svc.GetByID(ctx, req.GetId())
84
    if err != nil {
85
        if errors.Is(err, domain.ErrNotFound) {
86
            return nil, status.Error(codes.NotFound, "user not found")
87
        }
88
        h.log.ErrorContext(ctx, "get user failed", "error", err, "user_id", req.GetId())
89
        return nil, status.Error(codes.Internal, "internal error")
90
    }
91

92
    return &userv1.GetUserResponse{User: toProtoUser(user)}, nil
93
}
94
```
95

96
## Structured logging with slog
97

98
- Use `log/slog` (stdlib). Create in `main.go`, inject as dependency.
99
- Use `ErrorContext`, `InfoContext` for request context propagation.
100
- Log with structured key-value pairs (snake_case keys), not formatted strings.
101
- Log at boundaries: incoming requests, outgoing calls, errors.
102

103
```go
104
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
105

106
h.log.InfoContext(ctx, "user created", "user_id", user.ID, "email", user.Email)
107
```
108

109
## Health checks
110

111
Every service exposes two HTTP health endpoints (even for gRPC services):
112

113
```go
114
// Liveness — is the process alive?
115
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
116
    w.WriteHeader(http.StatusOK)
117
})
118

119
// Readiness — can the service handle traffic? Checks dependencies.
120
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
121
    if err := db.PingContext(r.Context()); err != nil {
122
        http.Error(w, "db unavailable", http.StatusServiceUnavailable)
123
        return
124
    }
125
    w.WriteHeader(http.StatusOK)
126
})
127
```
128

129
## Consul service registration
130

131
```go
132
func registerConsul(cfg ConsulConfig) (*consul.Client, string, error) {
133
    client, err := consul.NewClient(&consul.Config{Address: cfg.Address})
134
    if err != nil {
135
        return nil, "", fmt.Errorf("consul client: %w", err)
136
    }
137

138
    id := fmt.Sprintf("%s-%s", cfg.ServiceName, cfg.ID)
139
    reg := &consul.AgentServiceRegistration{
140
        ID: id, Name: cfg.ServiceName, Port: cfg.Port, Address: cfg.Host,
141
        Check: &consul.AgentServiceCheck{
142
            HTTP:                           fmt.Sprintf("http://%s:%d/readyz", cfg.Host, cfg.HTTPPort),
143
            Interval:                       "10s",
144
            Timeout:                        "3s",
145
            DeregisterCriticalServiceAfter: "30s",
146
        },
147
    }
148

149
    if err := client.Agent().ServiceRegister(reg); err != nil {
150
        return nil, "", fmt.Errorf("consul register %s: %w", cfg.ServiceName, err)
151
    }
152
    return client, id, nil
153
}
154
```
155

156
## Nomad job spec pattern
157

158
```hcl
159
job "service-name" {
160
  datacenters = ["dc1"]
161
  type        = "service"
162

163
  group "app" {
164
    count = 3
165
    network {
166
      port "grpc" {}
167
      port "http" { static = 8080 }
168
    }
169
    service {
170
      name = "service-name"
171
      port = "grpc"
172
      provider = "consul"
173
      check {
174
        type     = "http"
175
        path     = "/readyz"
176
        port     = "http"
177
        interval = "10s"
178
        timeout  = "3s"
179
      }
180
    }
181
    task "server" {
182
      driver = "docker"
183
      config {
184
        image = "registry.example.com/service-name:${var.version}"
185
        ports = ["grpc", "http"]
186
      }
187
      env {
188
        GRPC_PORT   = "${NOMAD_PORT_grpc}"
189
        HTTP_PORT   = "${NOMAD_PORT_http}"
190
        CONSUL_ADDR = "${attr.unique.network.ip-address}:8500"
191
      }
192
      resources {
193
        cpu    = 256
194
        memory = 256
195
      }
196
    }
197
  }
198
}
199
```
200

201
## Main wiring pattern
202

203
```go
204
func main() {
205
    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
206
    defer cancel()
207

208
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
209

210
    // 1. Init dependencies
211
    db, err := initDB(ctx, cfg.DatabaseURL)
212
    if err != nil { logger.Error("init db", "error", err); os.Exit(1) }
213

214
    // 2. Wire domain
215
    repo := infra.NewPostgresRepo(db)
216
    svc := domain.NewUserService(repo)
217
    handler := handler.NewUserHandler(svc, logger)
218

219
    // 3. gRPC server
220
    grpcSrv := grpc.NewServer(grpc.UnaryInterceptor(loggingInterceptor(logger)))
221
    userv1.RegisterUserServiceServer(grpcSrv, handler)
222

223
    // 4. Health HTTP server
224
    healthMux := http.NewServeMux()
225
    healthMux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
226
    healthMux.HandleFunc("GET /readyz", readinessHandler(db))
227
    healthSrv := &http.Server{Addr: fmt.Sprintf(":%d", cfg.HTTPPort), Handler: healthMux}
228

229
    // 5. Consul registration
230
    consulClient, svcID, err := registerConsul(cfg.Consul)
231
    if err != nil { logger.Error("consul registration", "error", err); os.Exit(1) }
232

233
    // 6. Start and graceful shutdown
234
    g, ctx := errgroup.WithContext(ctx)
235
    g.Go(func() error {
236
        lis, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.GRPCPort))
237
        if err != nil { return fmt.Errorf("grpc listen: %w", err) }
238
        logger.Info("grpc server started", "port", cfg.GRPCPort)
239
        return grpcSrv.Serve(lis)
240
    })
241
    g.Go(func() error {
242
        if err := healthSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
243
            return fmt.Errorf("health server: %w", err)
244
        }
245
        return nil
246
    })
247
    g.Go(func() error {
248
        <-ctx.Done()
249
        logger.Info("shutting down")
250
        consulClient.Agent().ServiceDeregister(svcID)
251
        grpcSrv.GracefulStop()
252
        shutdownCtx, c := context.WithTimeout(context.Background(), 10*time.Second)
253
        defer c()
254
        return healthSrv.Shutdown(shutdownCtx)
255
    })
256

257
    if err := g.Wait(); err != nil { logger.Error("exit", "error", err); os.Exit(1) }
258
}
259
```
267
User asks: "Add structured logging to an existing service"
270
```go
271
// main.go — create and inject logger
272
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
273

274
// gRPC interceptor — log every request
275
func loggingInterceptor(log *slog.Logger) grpc.UnaryServerInterceptor {
276
    return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
277
        start := time.Now()
278
        resp, err := handler(ctx, req)
279
        level := slog.LevelInfo
280
        if err != nil { level = slog.LevelError }
281

282
        log.Log(ctx, level, "grpc request",
283
            "method", info.FullMethod,
284
            "duration_ms", time.Since(start).Milliseconds(),
285
            "error", err,
286
        )
287
        return resp, err
288
    }
289
}
290
```
291

292
Pattern: JSON logger created in main, injected into handlers and interceptors. Structured key-value pairs, context-aware.
296
- Design proto-first — define the gRPC contract before writing Go code, so handler and domain layers derive from the contract
297
- Keep handler layer as pure translation: protobuf ↔ domain types, domain errors → gRPC status codes — this keeps business logic testable without gRPC
298
- Wire all dependencies explicitly in main.go via constructor injection
299
- Expose `/healthz` (liveness) and `/readyz` (readiness) on a separate HTTP port; readiness checks all critical dependencies — load balancers and Consul need these to route traffic correctly
300
- Deregister from Consul before stopping the gRPC server — this drains in-flight requests instead of dropping them
301
- Use `slog` with JSON output and structured key-value pairs — unstructured logs are unqueryable during incidents
302
- Map domain errors to gRPC status codes: `ErrNotFound` → `codes.NotFound`, validation → `codes.InvalidArgument`, unexpected → `codes.Internal`
303
- Version protobuf packages (`service/v1/`) — breaking changes go in `v2/`, clients migrate at their own pace