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)}, nil93
}
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