Go Web Expert System
Five non-negotiable rules for production-quality Go web applications. Every handler, every service, every line of code must satisfy all five.
Quick Reference
| Topic | Reference |
|---|
| Validation tags, custom validators, nested structs, error formatting | references/validation.md |
| httptest patterns, middleware testing, integration tests, fixtures |
references/testing-handlers.md |
Rules of Engagement
| # | Rule | One-Liner |
|---|
| 1 | Zero Global State | All handlers are methods on a struct; no package-level var for mutable state |
| 2 |
Explicit Error Handling | Every error is checked, wrapped with
fmt.Errorf("doing X: %w", err) |
| 3 | Validation First | All incoming JSON validated with
go-playground/validator at the boundary |
| 4 | Testability | Every handler has a
_test.go using
httptest with table-driven tests |
| 5 | Documentation | Every exported symbol has a Go doc comment starting with its name |
Rule 1: Zero Global State
All handlers must be methods on a server struct. No package-level var for databases, loggers, clients, or any mutable state.
CODEBLOCK0
What Is Allowed at Package Level
- - Constants -- INLINECODE6
- Pure functions -- functions with no side effects that depend only on their arguments
- Sentinel errors -- INLINECODE7
- Validator instance --
var validate = validator.New() (stateless after init)
What Is Forbidden at Package Level
- - Database connections (
*sql.DB, *pgxpool.Pool) - Loggers (
*slog.Logger) - HTTP clients configured with timeouts or transport
- Configuration structs read from environment
- Caches, rate limiters, or any mutable shared resource
Constructor Pattern
CODEBLOCK1
Rule 2: Explicit Error Handling
Never ignore errors. Every error must be wrapped with context describing what was being attempted when the error occurred.
CODEBLOCK2
Error Wrapping Convention
Format: "<verb>ing <noun>: %w" -- lowercase, no period, provides call-chain context.
CODEBLOCK3
Structured Error Type for HTTP APIs
CODEBLOCK4
Common Mistakes
CODEBLOCK5
Rule 3: Validation First
Use go-playground/validator for all incoming JSON. Validate at the boundary, trust internal data.
CODEBLOCK6
Validation Error Formatting
CODEBLOCK7
Validation Boundary Rule
- - Validate at the edge -- HTTP handlers, message consumers, CLI input
- Trust internal data -- service layer receives already-validated types
- Never validate twice -- if the handler validated, the service does not re-validate the same fields
See references/validation.md for custom validators, nested struct validation, slice validation, and cross-field validation.
Rule 4: Testability
Every handler must have a corresponding _test.go file using httptest. Test through the HTTP layer, not by calling handler methods directly.
CODEBLOCK8
Key Testing Principles
- - Test through HTTP -- use
httptest.NewRequest and httptest.NewRecorder, call INLINECODE18 - Interface-based mocks -- define narrow interfaces at the consumer, create mock implementations for tests
- Table-driven tests -- one
[]struct with test cases, one t.Run loop - Error paths matter -- test 400s, 404s, 422s, and 500s, not just 200s
- No global test state -- each test creates its own server with its own mocks
See references/testing-handlers.md for middleware testing, integration tests with real databases, file upload testing, and streaming response testing.
Rule 5: Documentation
Every exported function, type, method, and constant must have a Go doc comment following standard conventions.
CODEBLOCK9
Doc Comment Conventions
- - Start with the name --
// CreateUser creates... not INLINECODE22 - First sentence is the summary -- shown in
go doc listings and IDE tooltips - Mention important error returns -- callers need to know which errors to check
- Don't document the obvious --
// SetName sets the name adds no value - Document why, not what -- when behavior is non-obvious, explain the reasoning
Package Documentation
CODEBLOCK10
Cross-Cutting Concerns
The five rules reinforce each other. Here is how they interact.
Zero Global State Enables Testability
Because all dependencies are on the struct, tests can inject mocks:
CODEBLOCK11
If db were a global var, tests would need to mutate package state, causing race conditions in parallel tests.
Validation First Simplifies Error Handling
When handlers validate at the boundary, the service layer can assume valid input. This means service-layer errors are always unexpected (database failures, network issues), and error handling becomes simpler:
CODEBLOCK12
Documentation Makes Error Handling Discoverable
Doc comments that mention error returns tell callers what to handle:
CODEBLOCK13
Self-Review Checklist
Before considering any handler or service complete, verify all five rules:
Zero Global State
- - [ ] No package-level
var for mutable state (db, logger, clients) - [ ] All handlers are methods on a struct
- [ ] Dependencies injected through constructor
Explicit Error Handling
- - [ ] No
_ ignoring returned errors - [ ] All errors wrapped with INLINECODE29
- [ ]
json.NewEncoder(w).Encode(...) error checked or logged - [ ] Structured
AppError used for HTTP error responses
Validation First
- - [ ] All request structs have
validate tags - [ ]
validate.Struct(req) called before any business logic - [ ] Validation errors return 422 with field-level detail
- [ ] Service layer does not re-validate handler-validated data
Testability
- - [ ]
_test.go file exists for every handler file - [ ] Tests use
httptest.NewRequest and INLINECODE36 - [ ] Table-driven tests cover happy path and error paths
- [ ] Mocks implement narrow interfaces, not concrete types
Documentation
- - [ ] Every exported function has a doc comment starting with its name
- [ ] Error return values are documented
- [ ] Package has a doc comment
When to Load References
Load validation.md when:
- - Adding new request types with validation tags
- Creating custom validators
- Validating nested structs, slices, or maps
- Formatting validation errors for API responses
Load testing-handlers.md when:
- - Writing handler tests for the first time in a project
- Testing middleware chains or authentication
- Setting up integration tests with a real database
- Testing file uploads or streaming responses
Go Web 专家系统
生产级 Go Web 应用程序的五项不可协商规则。每个处理器、每个服务、每一行代码都必须满足全部五项要求。
快速参考
references/testing-handlers.md |
交互规则
| # | 规则 | 一句话总结 |
|---|
| 1 | 零全局状态 | 所有处理器都是结构体的方法;没有包级别的可变状态 var |
| 2 |
显式错误处理 | 每个错误都被检查,并使用 fmt.Errorf(doing X: %w, err) 包装 |
| 3 | 先验证后处理 | 所有传入的 JSON 在边界处使用 go-playground/validator 进行验证 |
| 4 | 可测试性 | 每个处理器都有使用 httptest 的 _test.go 文件,采用表格驱动测试 |
| 5 | 文档化 | 每个导出的符号都有以自身名称开头的 Go 文档注释 |
规则 1:零全局状态
所有处理器必须是服务器结构体的方法。不允许在包级别使用 var 定义数据库、日志记录器、客户端或任何可变状态。
go
// 禁止
var db *sql.DB
var logger *slog.Logger
func handleGetUser(w http.ResponseWriter, r *http.Request) {
user, err := db.QueryRow(...) // 全局状态 -- 不可测试,不安全
}
// 要求
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(...) // 显式依赖
}
包级别允许的内容
- - 常量 -- const maxPageSize = 100
- 纯函数 -- 仅依赖参数且无副作用的函数
- 哨兵错误 -- var ErrNotFound = errors.New(not found)
- 验证器实例 -- var validate = validator.New()(初始化后无状态)
包级别禁止的内容
- - 数据库连接(sql.DB、pgxpool.Pool)
- 日志记录器(*slog.Logger)
- 配置了超时或传输的 HTTP 客户端
- 从环境读取的配置结构体
- 缓存、限流器或任何可变共享资源
构造函数模式
go
func 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)
}
规则 2:显式错误处理
永远不要忽略错误。每个错误都必须用上下文包装,描述错误发生时正在尝试的操作。
go
// 禁止
result, _ := doSomething()
json.NewEncoder(w).Encode(data) // 错误被忽略
// 要求
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)
}
错误包装约定
格式:<动词>ing <名词>: %w -- 小写,无句号,提供调用链上下文。
go
// 良好的包装 -- 每层添加上下文
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)
// 糟糕的包装
return fmt.Errorf(error: %w, err) // 无上下文
return fmt.Errorf(Failed to create user: %w, err) // 大写,冗长
return err // 完全没有包装
HTTP API 的结构化错误类型
go
type 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)
}
// 在统一位置将领域错误映射为 HTTP 错误
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})
}
常见错误
go
// 错误:未检查写入器的 Close 错误
defer f.Close() // 至少,对于可写资源记录 Close 错误
// 对于可写资源更好的做法:
defer func() {
if err := f.Close(); err != nil {
s.logger.Error(closing file, err, err)
}
}()
// 对于 Close 很少失败的只读资源可以接受:
defer resp.Body.Close()
规则 3:先验证后处理
对所有传入的 JSON 使用 go-playground/validator。在边界处验证,信任内部数据。
go
import 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)}
}
// 从这里开始,req 是可信的
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
}
验证错误格式化
go
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, ; )
}
验证边界规则
- - 在边缘处验证 -- HTTP 处理器、消息消费者、CLI 输入
- 信任内部数据 -- 服务层接收已经验证的类型
- 绝不重复验证 -- 如果处理器已验证,服务层不重新验证相同字段
有关自定义验证器、嵌套结构体验证、切片验证和跨字段验证,请参见 references/validation.md。
规则 4:可测试性
每个处理器必须有对应的使用 httptest 的 _test.go 文件。通过 HTTP 层进行测试,而不是直接调用处理器方法。
go
func 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: