Loading...
Loading...
Comprehensive Go web development persona enforcing zero global state, explicit error handling, input validation, testability, and documentation conventions. Use when building Go web applications to ensure production-quality code from the start.
npx skill4agent add existential-birds/beagle go-web-expert| Topic | Reference |
|---|---|
| Validation tags, custom validators, nested structs, error formatting | references/validation.md |
| httptest patterns, middleware testing, integration tests, fixtures | references/testing-handlers.md |
| # | Rule | One-Liner |
|---|---|---|
| 1 | Zero Global State | All handlers are methods on a struct; no package-level |
| 2 | Explicit Error Handling | Every error is checked, wrapped with |
| 3 | Validation First | All incoming JSON validated with |
| 4 | Testability | Every handler has a |
| 5 | Documentation | Every exported symbol has a Go doc comment starting with its name |
var// FORBIDDEN
var db *sql.DB
var logger *slog.Logger
func handleGetUser(w http.ResponseWriter, r *http.Request) {
user, err := db.QueryRow(...) // global state -- untestable, unsafe
}
// REQUIRED
type Server struct {
db *sql.DB
logger *slog.Logger
router *http.ServeMux
}
func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
user, err := s.db.QueryRow(...) // explicit dependency
}const maxPageSize = 100var ErrNotFound = errors.New("not found")var validate = validator.New()*sql.DB*pgxpool.Pool*slog.Loggerfunc NewServer(db *sql.DB, logger *slog.Logger) *Server {
s := &Server{
db: db,
logger: logger,
router: http.NewServeMux(),
}
s.routes()
return s
}
func (s *Server) routes() {
s.router.HandleFunc("GET /api/users/{id}", s.handleGetUser)
s.router.HandleFunc("POST /api/users", s.handleCreateUser)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}// FORBIDDEN
result, _ := doSomething()
json.NewEncoder(w).Encode(data) // error ignored
// REQUIRED
result, err := doSomething()
if err != nil {
return fmt.Errorf("doing something for user %s: %w", userID, err)
}
if err := json.NewEncoder(w).Encode(data); err != nil {
s.logger.Error("encoding response", "err", err, "request_id", reqID)
}"<verb>ing <noun>: %w"// Good wrapping -- each layer adds context
return fmt.Errorf("creating user: %w", err)
return fmt.Errorf("inserting user into database: %w", err)
return fmt.Errorf("hashing password for user %s: %w", email, err)
// Bad wrapping
return fmt.Errorf("error: %w", err) // no context
return fmt.Errorf("Failed to create user: %w", err) // uppercase, verbose
return err // no wrapping at alltype AppError struct {
Code int `json:"-"`
Message string `json:"error"`
Detail string `json:"detail,omitempty"`
}
func (e *AppError) Error() string {
return fmt.Sprintf("%d: %s", e.Code, e.Message)
}
// Map domain errors to HTTP errors in one place
func handleError(w http.ResponseWriter, r *http.Request, err error) {
var appErr *AppError
if errors.As(err, &appErr) {
writeJSON(w, appErr.Code, appErr)
return
}
slog.Error("unhandled error",
"err", err,
"path", r.URL.Path,
)
writeJSON(w, 500, map[string]string{"error": "internal server error"})
}// MISTAKE: not checking Close errors on writers
defer f.Close() // at minimum, log Close errors for writable resources
// BETTER for writable resources:
defer func() {
if err := f.Close(); err != nil {
s.logger.Error("closing file", "err", err)
}
}()
// OK for read-only resources where Close rarely fails:
defer resp.Body.Close()go-playground/validatorimport "github.com/go-playground/validator/v10"
var validate = validator.New()
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"omitempty,gte=0,lte=150"`
}
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) error {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return &AppError{Code: 400, Message: "invalid JSON", Detail: err.Error()}
}
if err := validate.Struct(req); err != nil {
return &AppError{Code: 422, Message: "validation failed", Detail: formatValidationErrors(err)}
}
// From here, req is trusted
user, err := s.userService.Create(r.Context(), req.Name, req.Email)
if err != nil {
return fmt.Errorf("creating user: %w", err)
}
writeJSON(w, http.StatusCreated, user)
return nil
}func formatValidationErrors(err error) string {
var msgs []string
for _, e := range err.(validator.ValidationErrors) {
msgs = append(msgs, fmt.Sprintf("field '%s' failed on '%s'", e.Field(), e.Tag()))
}
return strings.Join(msgs, "; ")
}_test.gohttptestfunc TestServer_handleGetUser(t *testing.T) {
mockStore := &MockUserStore{
GetUserFunc: func(ctx context.Context, id string) (*User, error) {
if id == "123" {
return &User{ID: "123", Name: "Alice"}, nil
}
return nil, ErrNotFound
},
}
srv := NewServer(mockStore, slog.Default())
tests := []struct {
name string
path string
wantStatus int
wantBody string
}{
{
name: "existing user",
path: "/api/users/123",
wantStatus: http.StatusOK,
wantBody: `"name":"Alice"`,
},
{
name: "not found",
path: "/api/users/999",
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.path, nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
}
if tt.wantBody != "" && !strings.Contains(w.Body.String(), tt.wantBody) {
t.Errorf("body = %q, want to contain %q", w.Body.String(), tt.wantBody)
}
})
}
}httptest.NewRequesthttptest.NewRecordersrv.ServeHTTP[]structt.Run// CreateUser creates a new user with the given name and email.
// It returns ErrDuplicateEmail if a user with the same email already exists.
func (s *UserService) CreateUser(ctx context.Context, name, email string) (*User, error) {
// ...
}
// Server handles HTTP requests for the user API.
type Server struct {
// ...
}
// NewServer creates a Server with the given dependencies.
// The logger must not be nil.
func NewServer(store UserStore, logger *slog.Logger) *Server {
// ...
}
// ErrNotFound is returned when a requested resource does not exist.
var ErrNotFound = errors.New("not found")// CreateUser creates...// This function creates...go doc// SetName sets the name// Package user provides user management for the application.
// It handles creation, retrieval, and deletion of user accounts,
// with email uniqueness enforced at the database level.
package user// Production
srv := NewServer(realDB, prodLogger)
// Test
srv := NewServer(mockStore, slog.Default())dbvarfunc (s *UserService) Create(ctx context.Context, name, email string) (*User, error) {
// No need to check if name is empty -- handler already validated
user := &User{Name: name, Email: email}
if err := s.store.Insert(ctx, user); err != nil {
return nil, fmt.Errorf("inserting user: %w", err)
}
return user, nil
}// Delete removes a user by ID.
// It returns ErrNotFound if the user does not exist.
// It returns ErrHasActiveOrders if the user has unfinished orders.
func (s *UserService) Delete(ctx context.Context, id string) error {var_fmt.Errorf("doing X: %w", err)json.NewEncoder(w).Encode(...)AppErrorvalidatevalidate.Struct(req)_test.gohttptest.NewRequesthttptest.NewRecorder