dotmd

Go Project — Copilot Instructions

GitHub Copilot instructions for idiomatic Go services with strong error handling and test discipline.

By dotmd TeamCC0Published Feb 19, 2026View source ↗

Install path

Use this file for each supported tool in your project.

  • GitHub Copilot: Save as copilot-instructions.md in your project at .github/copilot-instructions.md.

Configuration

copilot-instructions.md

1# Go Project — Copilot Instructions
2
3Go project following standard library conventions and idiomatic patterns. Write clear, explicit code. Accept interfaces, return structs. Handle every error. No magic.
4
5## Quick Reference
6
7| Area | Convention |
8|---|---|
9| Language | Go 1.22+ |
10| Module | Check `go.mod` for module path and Go version |
11| Linting | `golangci-lint run ./...` |
12| Formatting | `gofmt` / `goimports` (enforced by CI) |
13| Testing | `go test ./... -race -count=1` |
14| Build | `go build ./cmd/<service>` |
15| Generate | `go generate ./...` (mocks, sqlc, proto) |
16| Logging | `log/slog` (stdlib) — or `zerolog`/`zap` if already in `go.mod` |
17| Config | Environment variables parsed at startup |
18| HTTP router | Check `go.mod`: `chi`, `gin`, `echo`, or stdlib `net/http` |
19| DB driver | `pgx` (preferred) or `database/sql` |
20
21## Project Structure
22
23```
24├── cmd/
25│ └── server/ # main.go — wires dependencies, starts server
26├── internal/
27│ ├── config/ # Environment parsing, config struct
28│ ├── handler/ # HTTP handlers — thin, decode/encode only
29│ ├── service/ # Business logic — no HTTP types
30│ ├── repository/ # Data access — SQL queries, external APIs
31│ ├── model/ # Domain types, errors, value objects
32│ ├── middleware/ # HTTP middleware (auth, logging, recovery)
33│ └── platform/ # Infra: DB pools, cache clients, message queues
34├── migrations/ # SQL migration files
35├── api/ # OpenAPI specs, proto files
36├── scripts/ # Dev/CI helper scripts
37├── .golangci.yml
38└── Makefile
39```
40
41Dependency direction: handler → service → repository. Never skip layers. Services never import `net/http`.
42
43## Error Handling
44
45### Always Wrap With `%w` and Context
46
47```go
48// ✅ Wrap errors with operation context
49func (r *OrderRepo) GetByID(ctx context.Context, id string) (Order, error) {
50 var order Order
51 err := r.pool.QueryRow(ctx,
52 "SELECT id, customer_id, status, total FROM orders WHERE id = $1", id,
53 ).Scan(&order.ID, &order.CustomerID, &order.Status, &order.Total)
54 if err != nil {
55 if errors.Is(err, pgx.ErrNoRows) {
56 return Order{}, fmt.Errorf("get order %s: %w", id, ErrNotFound)
57 }
58 return Order{}, fmt.Errorf("get order %s: %w", id, err)
59 }
60 return order, nil
61}
62```
63
64### Define Domain Errors as Sentinels
65
66```go
67// internal/model/errors.go
68package model
69
70import "errors"
71
72var (
73 ErrNotFound = errors.New("not found")
74 ErrConflict = errors.New("conflict")
75 ErrUnauthorized = errors.New("unauthorized")
76 ErrForbidden = errors.New("forbidden")
77 ErrValidation = errors.New("validation error")
78)
79
80// ValidationError carries field-level details
81type ValidationError struct {
82 Field string
83 Message string
84}
85
86func (e *ValidationError) Error() string {
87 return fmt.Sprintf("validation: %s — %s", e.Field, e.Message)
88}
89
90func (e *ValidationError) Unwrap() error {
91 return ErrValidation
92}
93```
94
95### Check With `errors.Is` and `errors.As` — Never Compare Strings
96
97```go
98// ✅ In handler: map domain errors to HTTP status
99func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
100 order, err := h.service.GetByID(r.Context(), chi.URLParam(r, "id"))
101 if err != nil {
102 switch {
103 case errors.Is(err, model.ErrNotFound):
104 writeError(w, http.StatusNotFound, err)
105 case errors.Is(err, model.ErrForbidden):
106 writeError(w, http.StatusForbidden, err)
107 default:
108 slog.ErrorContext(r.Context(), "get order failed", "error", err)
109 writeError(w, http.StatusInternalServerError, errors.New("internal error"))
110 }
111 return
112 }
113 writeJSON(w, http.StatusOK, order)
114}
115```
116
117## context.Context Conventions
118
119```go
120// ✅ First parameter, always
121func (s *OrderService) Create(ctx context.Context, req CreateOrderReq) (Order, error) {
122 // Add timeout for external calls
123 dbCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
124 defer cancel()
125
126 return s.repo.Insert(dbCtx, req)
127}
128
129// ✅ Extract request-scoped values set by middleware
130func UserIDFromContext(ctx context.Context) (string, bool) {
131 id, ok := ctx.Value(ctxKeyUserID).(string)
132 return id, ok
133}
134```
135
136**Rules:**
137- `ctx context.Context` is the first parameter in every exported function.
138- Pass context through the entire chain: handler → service → repository → DB.
139- Never store `context.Context` in a struct.
140- Use `context.WithTimeout` for outbound calls (DB: 5s, external API: 10s).
141
142## Interfaces
143
144### Define Where They're Used, Keep Them Small
145
146```go
147// ✅ Defined in the service package (consumer), not the repository package (implementer)
148// internal/service/order.go
149package service
150
151type OrderStore interface {
152 GetByID(ctx context.Context, id string) (model.Order, error)
153 Insert(ctx context.Context, order model.Order) error
154 UpdateStatus(ctx context.Context, id string, status model.OrderStatus) error
155}
156
157type PaymentGateway interface {
158 Charge(ctx context.Context, amount int64, currency string) (string, error)
159}
160
161type OrderService struct {
162 store OrderStore
163 payment PaymentGateway
164 logger *slog.Logger
165}
166
167func NewOrderService(store OrderStore, payment PaymentGateway, logger *slog.Logger) *OrderService {
168 return &OrderService{store: store, payment: payment, logger: logger}
169}
170```
171
172**Rules:**
173- 1–3 methods per interface. If it has more, split it.
174- Accept interfaces, return structs.
175- Name single-method interfaces by the method + "er" (`Reader`, `Storer`, `Validator`).
176
177## Structured Logging With slog
178
179```go
180import "log/slog"
181
182// ✅ Always log with context for request tracing
183func (s *OrderService) Create(ctx context.Context, req CreateOrderReq) (Order, error) {
184 s.logger.InfoContext(ctx, "creating order",
185 "customer_id", req.CustomerID,
186 "item_count", len(req.Items),
187 )
188
189 order, err := s.store.Insert(ctx, buildOrder(req))
190 if err != nil {
191 s.logger.ErrorContext(ctx, "failed to create order",
192 "customer_id", req.CustomerID,
193 "error", err,
194 )
195 return Order{}, fmt.Errorf("create order: %w", err)
196 }
197
198 s.logger.InfoContext(ctx, "order created",
199 "order_id", order.ID,
200 "total", order.Total,
201 )
202 return order, nil
203}
204```
205
206**Rules:**
207- Use `slog.InfoContext(ctx, ...)` — always pass context.
208- Log at handler entry/exit and on errors. Do not log in hot loops.
209- Never log credentials, tokens, PII, or full request/response bodies.
210- Levels: `Debug` for dev tracing, `Info` for business events, `Warn` for recoverable issues, `Error` for failures.
211
212## Dependency Injection
213
214### Wire in `main.go` — No Framework
215
216```go
217func main() {
218 cfg, err := config.Load()
219 if err != nil {
220 slog.Error("failed to load config", "error", err)
221 os.Exit(1)
222 }
223
224 logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
225 Level: cfg.LogLevel,
226 }))
227
228 pool, err := pgxpool.New(context.Background(), cfg.DatabaseURL)
229 if err != nil {
230 logger.Error("failed to connect to database", "error", err)
231 os.Exit(1)
232 }
233 defer pool.Close()
234
235 // Wire: repo → service → handler
236 orderRepo := repository.NewOrderRepo(pool)
237 orderSvc := service.NewOrderService(orderRepo, paymentClient, logger)
238 orderHandler := handler.NewOrderHandler(orderSvc, logger)
239
240 r := chi.NewRouter()
241 r.Use(middleware.RequestID, middleware.Logger(logger), middleware.Recoverer)
242 r.Route("/api/v1/orders", func(r chi.Router) {
243 r.Post("/", orderHandler.Create)
244 r.Get("/{id}", orderHandler.Get)
245 r.Patch("/{id}/status", orderHandler.UpdateStatus)
246 })
247
248 srv := &http.Server{
249 Addr: ":" + cfg.Port,
250 Handler: r,
251 ReadTimeout: 5 * time.Second,
252 WriteTimeout: 10 * time.Second,
253 IdleTimeout: 120 * time.Second,
254 }
255
256 // Graceful shutdown
257 go func() { srv.ListenAndServe() }()
258 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
259 defer stop()
260 <-ctx.Done()
261 shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
262 defer cancel()
263 srv.Shutdown(shutdownCtx)
264}
265```
266
267## Testing
268
269### Table-Driven Tests
270
271```go
272func TestOrderService_CalculateTotal(t *testing.T) {
273 tests := []struct {
274 name string
275 items []OrderItem
276 want int64
277 }{
278 {
279 name: "single item",
280 items: []OrderItem{{ProductID: "p1", Quantity: 2, PricePerUnit: 1000}},
281 want: 2000,
282 },
283 {
284 name: "multiple items",
285 items: []OrderItem{
286 {ProductID: "p1", Quantity: 1, PricePerUnit: 1500},
287 {ProductID: "p2", Quantity: 3, PricePerUnit: 500},
288 },
289 want: 3000,
290 },
291 {
292 name: "empty items",
293 items: nil,
294 want: 0,
295 },
296 }
297 for _, tt := range tests {
298 t.Run(tt.name, func(t *testing.T) {
299 got := calculateTotal(tt.items)
300 assert.Equal(t, tt.want, got)
301 })
302 }
303}
304```
305
306### Test HTTP Handlers
307
308```go
309func TestOrderHandler_Get(t *testing.T) {
310 // Arrange
311 mockStore := &mockOrderStore{
312 getByID: func(ctx context.Context, id string) (model.Order, error) {
313 if id == "ord_123" {
314 return model.Order{ID: "ord_123", Status: "pending"}, nil
315 }
316 return model.Order{}, model.ErrNotFound
317 },
318 }
319 svc := service.NewOrderService(mockStore, nil, slog.Default())
320 h := handler.NewOrderHandler(svc, slog.Default())
321
322 // Act
323 req := httptest.NewRequest(http.MethodGet, "/api/v1/orders/ord_123", nil)
324 rctx := chi.NewRouteContext()
325 rctx.URLParams.Add("id", "ord_123")
326 req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
327 rec := httptest.NewRecorder()
328 h.Get(rec, req)
329
330 // Assert
331 assert.Equal(t, http.StatusOK, rec.Code)
332 var resp model.Order
333 require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
334 assert.Equal(t, "ord_123", resp.ID)
335}
336
337```
338
339## Common Patterns
340
341### Constructor Validation
342
343```go
344// ✅ Validate inputs at construction time
345func NewOrder(customerID string, items []OrderItem) (Order, error) {
346 if customerID == "" {
347 return Order{}, &ValidationError{Field: "customer_id", Message: "required"}
348 }
349 if len(items) == 0 {
350 return Order{}, &ValidationError{Field: "items", Message: "at least one item required"}
351 }
352 return Order{
353 ID: uuid.NewString(),
354 CustomerID: customerID,
355 Items: items,
356 Status: OrderStatusPending,
357 CreatedAt: time.Now(),
358 }, nil
359}
360```
361
362### Consistent JSON Response Helpers
363
364```go
365func writeJSON(w http.ResponseWriter, status int, data any) {
366 w.Header().Set("Content-Type", "application/json")
367 w.WriteHeader(status)
368 json.NewEncoder(w).Encode(data)
369}
370
371func writeError(w http.ResponseWriter, status int, err error) {
372 writeJSON(w, status, map[string]any{
373 "error": map[string]any{
374 "code": http.StatusText(status),
375 "message": err.Error(),
376 },
377 })
378}
379```
380
381## Code Generation Guidelines
382
3831. **Read existing code first.** Match the router, logger, and patterns already in the project. If it uses `gin`, use `gin`. Don't mix styles.
3842. **Run `go vet` and `golangci-lint` mentally.** Unused variables, unchecked errors, and incorrect printf verbs are compilation errors or lint failures — never generate them.
3853. **Don't add dependencies** without stating the reason. Suggest the package — let the developer decide.
3864. **Handle every error.** No `_` for errors unless the function literally cannot fail and the linter allows it.
3875. **Keep functions short.** If a function exceeds 40 lines, it probably does too much. Extract helper functions.
3886. **Name for clarity.** `ordersByCustomer` over `result`. `isExpired` over `check`. Receivers are 1–2 letter abbreviations of the type (`o` for `Order`, `os` for `OrderService`).
3897. **Comments explain why.** Exported types and functions get a `// TypeName ...` godoc comment. Non-obvious logic gets an inline comment. Don't comment obvious code.
390

Community feedback

0 found this helpful

Works with: