Go Project — Copilot Instructions
GitHub Copilot instructions for idiomatic Go services with strong error handling and test discipline.
Install path
Use this file for each supported tool in your project.
- GitHub Copilot: Save as
copilot-instructions.mdin your project at.github/copilot-instructions.md.
Configuration
copilot-instructions.md
1# Go Project — Copilot Instructions23Go project following standard library conventions and idiomatic patterns. Write clear, explicit code. Accept interfaces, return structs. Handle every error. No magic.45## Quick Reference67| 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` |2021## Project Structure2223```24├── cmd/25│ └── server/ # main.go — wires dependencies, starts server26├── internal/27│ ├── config/ # Environment parsing, config struct28│ ├── handler/ # HTTP handlers — thin, decode/encode only29│ ├── service/ # Business logic — no HTTP types30│ ├── repository/ # Data access — SQL queries, external APIs31│ ├── model/ # Domain types, errors, value objects32│ ├── middleware/ # HTTP middleware (auth, logging, recovery)33│ └── platform/ # Infra: DB pools, cache clients, message queues34├── migrations/ # SQL migration files35├── api/ # OpenAPI specs, proto files36├── scripts/ # Dev/CI helper scripts37├── .golangci.yml38└── Makefile39```4041Dependency direction: handler → service → repository. Never skip layers. Services never import `net/http`.4243## Error Handling4445### Always Wrap With `%w` and Context4647```go48// ✅ Wrap errors with operation context49func (r *OrderRepo) GetByID(ctx context.Context, id string) (Order, error) {50 var order Order51 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, nil61}62```6364### Define Domain Errors as Sentinels6566```go67// internal/model/errors.go68package model6970import "errors"7172var (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)7980// ValidationError carries field-level details81type ValidationError struct {82 Field string83 Message string84}8586func (e *ValidationError) Error() string {87 return fmt.Sprintf("validation: %s — %s", e.Field, e.Message)88}8990func (e *ValidationError) Unwrap() error {91 return ErrValidation92}93```9495### Check With `errors.Is` and `errors.As` — Never Compare Strings9697```go98// ✅ In handler: map domain errors to HTTP status99func (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 return112 }113 writeJSON(w, http.StatusOK, order)114}115```116117## context.Context Conventions118119```go120// ✅ First parameter, always121func (s *OrderService) Create(ctx context.Context, req CreateOrderReq) (Order, error) {122 // Add timeout for external calls123 dbCtx, cancel := context.WithTimeout(ctx, 5*time.Second)124 defer cancel()125126 return s.repo.Insert(dbCtx, req)127}128129// ✅ Extract request-scoped values set by middleware130func UserIDFromContext(ctx context.Context) (string, bool) {131 id, ok := ctx.Value(ctxKeyUserID).(string)132 return id, ok133}134```135136**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).141142## Interfaces143144### Define Where They're Used, Keep Them Small145146```go147// ✅ Defined in the service package (consumer), not the repository package (implementer)148// internal/service/order.go149package service150151type OrderStore interface {152 GetByID(ctx context.Context, id string) (model.Order, error)153 Insert(ctx context.Context, order model.Order) error154 UpdateStatus(ctx context.Context, id string, status model.OrderStatus) error155}156157type PaymentGateway interface {158 Charge(ctx context.Context, amount int64, currency string) (string, error)159}160161type OrderService struct {162 store OrderStore163 payment PaymentGateway164 logger *slog.Logger165}166167func NewOrderService(store OrderStore, payment PaymentGateway, logger *slog.Logger) *OrderService {168 return &OrderService{store: store, payment: payment, logger: logger}169}170```171172**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`).176177## Structured Logging With slog178179```go180import "log/slog"181182// ✅ Always log with context for request tracing183func (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 )188189 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 }197198 s.logger.InfoContext(ctx, "order created",199 "order_id", order.ID,200 "total", order.Total,201 )202 return order, nil203}204```205206**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.211212## Dependency Injection213214### Wire in `main.go` — No Framework215216```go217func main() {218 cfg, err := config.Load()219 if err != nil {220 slog.Error("failed to load config", "error", err)221 os.Exit(1)222 }223224 logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{225 Level: cfg.LogLevel,226 }))227228 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()234235 // Wire: repo → service → handler236 orderRepo := repository.NewOrderRepo(pool)237 orderSvc := service.NewOrderService(orderRepo, paymentClient, logger)238 orderHandler := handler.NewOrderHandler(orderSvc, logger)239240 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 })247248 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 }255256 // Graceful shutdown257 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```266267## Testing268269### Table-Driven Tests270271```go272func TestOrderService_CalculateTotal(t *testing.T) {273 tests := []struct {274 name string275 items []OrderItem276 want int64277 }{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```305306### Test HTTP Handlers307308```go309func TestOrderHandler_Get(t *testing.T) {310 // Arrange311 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"}, nil315 }316 return model.Order{}, model.ErrNotFound317 },318 }319 svc := service.NewOrderService(mockStore, nil, slog.Default())320 h := handler.NewOrderHandler(svc, slog.Default())321322 // Act323 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)329330 // Assert331 assert.Equal(t, http.StatusOK, rec.Code)332 var resp model.Order333 require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))334 assert.Equal(t, "ord_123", resp.ID)335}336337```338339## Common Patterns340341### Constructor Validation342343```go344// ✅ Validate inputs at construction time345func 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 }, nil359}360```361362### Consistent JSON Response Helpers363364```go365func 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}370371func 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```380381## Code Generation Guidelines3823831. **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: