Go 大型项目代码组织——Clean Architecture 在 Go 中的落地方案
Go 大型项目代码组织——Clean Architecture 在 Go 中的落地方案
适读人群:项目规模扩大、代码组织开始混乱的 Go 工程师 | 阅读时长:约18分钟 | 核心价值:一套经过实战验证的 Go 大型项目目录结构和分层方案
从 3000 行的 main.go 说起
那是我接手的第一个 Go 项目遗留代码。前任开发者是 PHP 转 Go 的,对 Go 的代码组织没什么概念,所有逻辑都在一个 main.go 里:HTTP handler、数据库操作、业务逻辑、定时任务……全部混在一起。
3000 行的 main.go,想加一个功能,要翻十几分钟才能找到位置。改了一处,不知道会不会影响其他地方。
那之后,我花了两个月把这个项目重构成了清晰的分层结构。期间读了 Uncle Bob 的 Clean Architecture,结合 Go 的特点做了一些调整。
今天把这套方案完整分享出来。
Go 项目目录结构
先看整体结构,后面再逐层解释:
myproject/
├── cmd/
│ ├── server/ # HTTP 服务入口
│ │ └── main.go
│ ├── worker/ # 后台任务入口
│ │ └── main.go
│ └── migrate/ # 数据库迁移工具
│ └── main.go
│
├── internal/ # 业务代码(外部包无法引用)
│ ├── domain/ # 核心业务逻辑(不依赖任何框架)
│ │ ├── user/
│ │ │ ├── entity.go # 实体定义
│ │ │ ├── repository.go # 仓储接口
│ │ │ ├── service.go # 业务逻辑
│ │ │ └── errors.go # 领域错误
│ │ └── order/
│ │ ├── entity.go
│ │ ├── repository.go
│ │ └── service.go
│ │
│ ├── infrastructure/ # 基础设施(数据库、缓存、消息队列)
│ │ ├── persistence/ # 数据库实现
│ │ │ ├── user_repo.go
│ │ │ └── order_repo.go
│ │ ├── cache/ # 缓存实现
│ │ └── mq/ # 消息队列
│ │
│ ├── application/ # 应用服务(协调领域服务)
│ │ ├── user_app.go
│ │ └── order_app.go
│ │
│ └── interfaces/ # 接口层(HTTP/gRPC handlers)
│ ├── http/
│ │ ├── handler/
│ │ │ ├── user_handler.go
│ │ │ └── order_handler.go
│ │ ├── middleware/
│ │ └── router.go
│ └── grpc/
│
├── pkg/ # 可以被外部引用的公共库
│ ├── logger/
│ ├── errors/
│ └── validator/
│
├── config/ # 配置文件(非代码)
│ ├── default.yaml
│ └── production.yaml
│
├── migrations/ # 数据库迁移文件
│
├── go.mod
└── go.sum分层详解
Domain 层:核心业务,零依赖
这一层是整个项目的核心,不依赖任何框架或基础设施。
// internal/domain/user/entity.go
package user
import (
"errors"
"time"
)
// User 用户实体
type User struct {
ID string
Username string
Email string
Password string // 哈希后的密码
Roles []string
Active bool
CreatedAt time.Time
UpdatedAt time.Time
}
// 业务规则:直接在实体上
func (u *User) HasRole(role string) bool {
for _, r := range u.Roles {
if r == role {
return true
}
}
return false
}
func (u *User) Deactivate() error {
if !u.Active {
return ErrUserAlreadyInactive
}
u.Active = false
u.UpdatedAt = time.Now()
return nil
}
// 领域错误:有意义的业务错误,不是技术错误
var (
ErrUserNotFound = errors.New("用户不存在")
ErrUserAlreadyExists = errors.New("用户名已被使用")
ErrUserAlreadyInactive = errors.New("用户已经是禁用状态")
ErrInvalidCredentials = errors.New("用户名或密码错误")
)// internal/domain/user/repository.go
package user
import "context"
// Repository 是仓储接口(定义在 domain 层,实现在 infrastructure 层)
type Repository interface {
FindByID(ctx context.Context, id string) (*User, error)
FindByUsername(ctx context.Context, username string) (*User, error)
FindByEmail(ctx context.Context, email string) (*User, error)
Create(ctx context.Context, user *User) error
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, filter Filter, page Page) ([]*User, int64, error)
}
type Filter struct {
Active *bool
Role string
}
type Page struct {
Number int
Size int
}// internal/domain/user/service.go
package user
import (
"context"
"time"
"golang.org/x/crypto/bcrypt"
)
// Service 领域服务(处理涉及多个实体或复杂规则的业务逻辑)
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
// Register 注册新用户
func (s *Service) Register(ctx context.Context, username, email, password string) (*User, error) {
// 检查用户名是否已存在
existing, err := s.repo.FindByUsername(ctx, username)
if err != nil && err != ErrUserNotFound {
return nil, err
}
if existing != nil {
return nil, ErrUserAlreadyExists
}
// 密码哈希
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
user := &User{
ID: generateID(), // UUID
Username: username,
Email: email,
Password: string(hashedPwd),
Roles: []string{"user"},
Active: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.repo.Create(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// Authenticate 用户认证
func (s *Service) Authenticate(ctx context.Context, username, password string) (*User, error) {
user, err := s.repo.FindByUsername(ctx, username)
if err != nil {
if err == ErrUserNotFound {
return nil, ErrInvalidCredentials
}
return nil, err
}
if !user.Active {
return nil, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
return user, nil
}Infrastructure 层:实现接口
// internal/infrastructure/persistence/user_repo.go
package persistence
import (
"context"
"database/sql"
"errors"
"your-project/internal/domain/user"
)
// UserRepositoryMySQL 是 user.Repository 的 MySQL 实现
type UserRepositoryMySQL struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) user.Repository {
return &UserRepositoryMySQL{db: db}
}
func (r *UserRepositoryMySQL) FindByID(ctx context.Context, id string) (*user.User, error) {
query := `SELECT id, username, email, password, roles, active, created_at, updated_at
FROM users WHERE id = ? AND deleted_at IS NULL`
row := r.db.QueryRowContext(ctx, query, id)
u := &user.User{}
var rolesJSON string
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Password,
&rolesJSON, &u.Active, &u.CreatedAt, &u.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, user.ErrUserNotFound
}
if err != nil {
return nil, err
}
// 解析 JSON 格式的 roles
// json.Unmarshal([]byte(rolesJSON), &u.Roles)
return u, nil
}
// ... 其他方法实现Application 层:协调多个领域服务
// internal/application/user_app.go
package application
import (
"context"
"your-project/internal/domain/user"
"your-project/pkg/logger"
)
// UserAppService 应用服务:协调多个领域服务,处理跨切面关注点(日志、事务、事件)
type UserAppService struct {
userService *user.Service
logger logger.Logger
// emailService *notification.EmailService // 发邮件
// eventBus *event.Bus // 发布领域事件
}
func NewUserAppService(userService *user.Service, logger logger.Logger) *UserAppService {
return &UserAppService{
userService: userService,
logger: logger,
}
}
type RegisterRequest struct {
Username string
Email string
Password string
}
type RegisterResponse struct {
UserID string
Username string
}
func (s *UserAppService) Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) {
s.logger.Info("开始注册用户", "username", req.Username)
u, err := s.userService.Register(ctx, req.Username, req.Email, req.Password)
if err != nil {
s.logger.Warn("注册失败", "username", req.Username, "error", err)
return nil, err
}
s.logger.Info("注册成功", "user_id", u.ID, "username", u.Username)
// 发布注册事件(通知邮件服务发送欢迎邮件)
// s.eventBus.Publish(UserRegisteredEvent{UserID: u.ID})
return &RegisterResponse{
UserID: u.ID,
Username: u.Username,
}, nil
}Interfaces 层:HTTP Handler
// internal/interfaces/http/handler/user_handler.go
package handler
import (
"encoding/json"
"errors"
"net/http"
"your-project/internal/application"
"your-project/internal/domain/user"
)
type UserHandler struct {
userApp *application.UserAppService
}
func NewUserHandler(userApp *application.UserAppService) *UserHandler {
return &UserHandler{userApp: userApp}
}
type registerRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
}
type registerResponse struct {
UserID string `json:"user_id"`
Username string `json:"username"`
}
func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "请求格式错误")
return
}
// 基本验证(字段存在性检查,格式验证)
if req.Username == "" || req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "用户名、邮箱、密码不能为空")
return
}
result, err := h.userApp.Register(r.Context(), application.RegisterRequest{
Username: req.Username,
Email: req.Email,
Password: req.Password,
})
if err != nil {
// 把领域错误映射到 HTTP 状态码
switch {
case errors.Is(err, user.ErrUserAlreadyExists):
writeError(w, http.StatusConflict, "用户名已被使用")
default:
writeError(w, http.StatusInternalServerError, "服务器内部错误")
}
return
}
writeJSON(w, http.StatusCreated, registerResponse{
UserID: result.UserID,
Username: result.Username,
})
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}三个踩坑实录
坑一:循环依赖
现象:domain 包引用了 infrastructure 包,infrastructure 包又引用了 domain 包,编译报循环依赖错误。
原因:把 Repository 的接口和实现放在了同一个包里,或者 domain 层直接调用了 infrastructure 层的具体类型。
解法:Repository 接口定义在 domain 层,实现在 infrastructure 层。infrastructure 层依赖 domain 层,但 domain 层不依赖 infrastructure 层——这是依赖倒置原则的核心。
坑二:internal 目录的访问限制
现象:想在另一个项目里复用 internal 目录下的代码,import 时编译报错。
原因:Go 的 internal 目录有访问限制——只有其父目录及其子目录下的包才能引用它。这是有意设计的,强制内部实现细节不被外部引用。
解法:真正可以被外部复用的代码放到 pkg 目录下;只给本项目用的代码放 internal 下。这种区分让项目的公共 API 和内部实现一目了然。
坑三:过度设计
现象:项目只有三个 API,但强行搞了 domain/application/infrastructure/interfaces 四层,每加一个字段要改四个文件,开发效率极低。
原因:把大型项目的架构套到了小项目上。
解法:Clean Architecture 适合有复杂业务规则、多人协作的中大型项目。小项目直接 handler → service → repository 三层就够了。不要为了"架构好看"而架构。
Java 对比
Java 的 DDD(Domain-Driven Design)和 Clean Architecture 有很完整的生态——Hibernate、Spring Data 处理持久层,Spring MVC 处理接口层,各层之间用接口隔离。
Go 里做同样的事,工具更简单(没有复杂的 Spring 体系),但代码更显式。Java 开发者转 Go 时,通常会带着 Java 的分层思维,但要做一些调整:
- 不需要 Spring IoC,用手动依赖注入(或者 wire 工具)
- 接口比 Java 更隐式(structural typing),不需要显式 implements
- Go 没有注解,横切关注点用中间件和高阶函数处理
小结
Clean Architecture 在 Go 里的落地要点:
internal强制封装:内部实现不被外部引用,边界清晰- 依赖方向从外到内:interfaces → application → domain ← infrastructure
- 接口定义在 domain:实现在 infrastructure,满足依赖倒置
- cmd 目录管理多入口:server/worker/migrate 各有独立入口
- 不要过度设计:架构要服务于业务复杂度,而不是反过来
