把HTTP处理、数据库访问、业务判断全部写在一个main包或单个handler包中,是Go项目起步时常见的做法。这种写法在接口数量不多时的确高效,可一旦业务开始增长,订单、支付、库存、营销等逻辑互相穿插,一个函数动辄上百行,修改一处就需要全局搜索。想把代码维护成本降下来,第一件事往往不是引入更多框架,而是先把包结构拆对。

一、先明确分层目标:让依赖单向流动
Go的package不是简单的目录分类,它同时承担着可见性和编译单元两个职责。同包内小写标识符互相可见,跨包只能使用大写标识符;同时Go编译器不允许包之间出现循环导入。这个限制看似麻烦,实际上能逼迫开发者把依赖方向设计清楚。常见的分层做法是把代码分成传输层、业务层、数据层和模型层。
典型目录可以这样规划:
shop/ cmd/api/main.go internal/handler/ internal/service/ internal/repository/ internal/model/ go.mod
其中handler只负责解析HTTP参数、调用service并写响应;service承载核心业务规则和事务编排;repository负责数据库、缓存等外部存储访问;model存放结构体和接口。依赖方向是handler依赖service,service依赖repository和model,model不依赖任何业务包。把方向固定下来后,后续新增功能时就不会出现从repository反向调用service的奇怪代码。
这里需要说明的是,internal目录是Go官方推荐的私有包保护机制。放在internal下的包只能被其父目录树内的代码导入,外部模块无法引用,对应用服务来说非常合适。cmd目录则存放可执行程序入口,通常只做装配工作,不写具体业务逻辑。
二、按层拆还是按模块拆:先看项目边界
分层目录看起来直观,但业务复杂后会出现另一个问题:service目录里混杂了订单、用户、支付、营销等所有领域逻辑,文件数量可能膨胀到几十个。此时可以引入第二种策略——按功能模块拆分。每个模块内部再保留自己的handler、service、repository结构。
按模块拆分的目录可能长这样:
shop/ cmd/api/main.go internal/order/handler.go internal/order/service.go internal/order/repository.go internal/user/handler.go internal/user/service.go internal/user/repository.go internal/pkg/db/ internal/pkg/response/ go.mod
按模块拆的好处是修改某个业务域时,所有相关文件都在一个目录下,新人更容易定位。挑战在于模块之间如果直接互相调用,容易出现order依赖user、user又依赖order的情况。解决办法是让模块之间通过接口或事件解耦。例如订单服务需要查询用户信息,可以让order/service.go定义一个小接口UserReader,由user模块或其他适配层提供实现,而不是直接导入user包的具体类型。
我的建议是:接口数量在20个以内的小型项目直接按层拆即可;当团队按业务域分工,或者单个业务域代码已经超过一个层包的可控范围,就逐步迁移到按模块拆分。也可以采用混合模式,先按模块,再在模块内部按层,这样既有清晰领域边界,又保留分层约束。
三、用接口和构造函数注入把依赖接到一起
拆包后最常遇到的问题是循环依赖。假设service包需要调用repository包,而repository包为了写日志又要导入service包,Go编译器会直接报import cycle not allowed。解决思路是高层模块定义接口,低层模块只负责实现,装配阶段再注入具体对象。Go的接口是隐式实现的,这比Java式的显式implements更适合解耦。
下面以订单创建为例。order/service.go中不直接导入repository包,而是声明一个OrderRepository接口:
package service
import (
"context"
"errors"
)
type Order struct {
ID int64
UserID int64
Amount float64
}
type OrderRepository interface {
Create(ctx context.Context, order Order) error
FindByID(ctx context.Context, id int64) (Order, error)
}
type OrderService struct {
repo OrderRepository
}
func NewOrderService(repo OrderRepository) *OrderService {
return &OrderService{repo: repo}
}
func (s *OrderService) CreateOrder(ctx context.Context, o Order) error {
if o.Amount <= 0 {
return errors.New("amount must be positive")
}
return s.repo.Create(ctx, o)
}
在order/repository.go或者基础设施包中提供MySQL实现:
package repository
import (
"context"
"database/sql"
"ipipp.com/shop/internal/order/service"
)
type MySQLOrderRepository struct {
db *sql.DB
}
func NewMySQLOrderRepository(db *sql.DB) *MySQLOrderRepository {
return &MySQLOrderRepository{db: db}
}
func (r *MySQLOrderRepository) Create(ctx context.Context, o service.Order) error {
_, err := r.db.ExecContext(ctx,
"INSERT INTO orders(user_id, amount) VALUES(?, ?)",
o.UserID, o.Amount,
)
return err
}
func (r *MySQLOrderRepository) FindByID(ctx context.Context, id int64) (service.Order, error) {
var o service.Order
err := r.db.QueryRowContext(ctx,
"SELECT id, user_id, amount FROM orders WHERE id = ?",
id,
).Scan(&o.ID, &o.UserID, &o.Amount)
return o, err
}
构造函数NewOrderService接收接口类型,main.go只负责创建MySQL仓库并注入:
package main
import (
"database/sql"
"ipipp.com/shop/internal/order/repository"
"ipipp.com/shop/internal/order/service"
)
func main() {
db, _ := sql.Open("mysql", "user:pass@tcp(127.0.0.1:3306)/shop")
repo := repository.NewMySQLOrderRepository(db)
svc := service.NewOrderService(repo)
_ = svc
}
通过这种方式,service包完全不知道MySQL实现的存在,后续想换成PostgreSQL或内存实现,只需要新增一个实现类并在main中替换即可。更重要的是包依赖图保持单向:main依赖repository和service,repository依赖service中的接口定义,没有反向引用。
四、拆分时最容易犯的四个错误
第一个误区是过度拆分。每个结构体单独一个包、每个接口一个目录,会让导入路径变得极长,阅读代码时不断跳转。包的数量应该与团队认知负担匹配,通常一个模块内不超过十几个文件就没必要继续拆。
第二个误区是公共包变成垃圾场。utils、common、helpers这类包往往什么都能放,最终包内函数互相依赖其他业务包,形成隐式循环依赖。更推荐的做法是按职责命名,例如internal/pkg/db专门封装数据库连接,internal/pkg/response专门处理HTTP响应格式。如果某个工具函数只被一个模块使用,就放在那个模块内部。
第三个误区是模型在不同层之间重复定义。例如handler层有一个UserRequest,service层有UserBO,repository层有UserDO,它们字段几乎完全一样,只是名字不同。虽然某些场景需要隔离请求和存储结构,但多数情况下可以在service或model包定义统一的领域结构体,handler只做参数绑定,repository直接持久化该结构体的子集。重复定义会让转换代码占据大量篇幅。
第四个误区是错误处理被吞掉。跨包调用时,service层如果直接返回repository的底层错误,上层就不得不依赖数据库驱动包来理解错误。更好的做法是在边界处包装错误:repository返回fmt.Errorf("create order: %w", err),service决定是否重试,handler决定返回500还是400。
下面是一段常见错误示范:
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
var req struct {
UserID int64 `json:"user_id"`
Amount float64 `json:"amount"`
}
json.NewDecoder(r.Body).Decode(&req)
orderID, err := h.Service.CreateOrder(req.UserID, req.Amount)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]int64{"order_id": orderID})
}
这段代码把参数校验、错误分类、响应序列化都堆在handler中。更好的做法是让handler只负责HTTP协议,业务规则交给service,错误分类也由service返回明确的错误类型,再由handler统一映射。拆分不是简单移动文件,而是把职责重新分配。目录结构只是表象,真正的分层发生在依赖方向和接口边界上。只要坚持上层依赖下层、具体依赖抽象、模块内部高内聚,Go项目的包结构就能随业务扩展保持清晰可控。

