在 Go 中用 DDD 风格组织代码:实践、目录与命名规范(可落地)

2025/9/22 探索实践总结最佳

# 摘要

本文给出一个落地可执行的 Go + DDD 代码组织方案。包含:分层含义、推荐目录树、包命名与文件命名规范、典型代码片段(领域实体、仓储接口、应用服务、适配器/实现)、防坑注意项与代码风格建议。侧重 最小侵入、依赖倒置、包循环避免Go 风格gofmt、小写包名、单一职责、内建 internalpkg 约束)。


# 1. 基本原则(与 Go 的契合点)

  • 领域优先、基础设施靠后:领域模型(domain)不依赖任何数据库、Web、框架或第三方库。
  • 依赖倒置:高层(application / adapters)依赖领域接口(domain interfaces),具体实现注入到运行时。
  • 包的最小化与单一职责:一个包只聚焦一组职责(例如 order 聚合、user 聚合),避免出现“god package”。
  • 使用 internal 隔离实现细节:对外暴露只放在 pkg 或根导出包,其他实现放 internal
  • 避免循环依赖:通过接口与构造函数解耦,或把共享类型放到 pkg(谨慎)或 shared(尽量少用)。

# 2. 推荐目录结构(示例)

myapp/
├── cmd/
│   └── myapp/             # 程序入口(main.go)——可多个 cmd
│       └── main.go
├── configs/               # 配置文件、模板
├── internal/
│   ├── domain/            # 领域层(只含接口、实体、领域服务、事件)
│   │   ├── user/
│   │   │   ├── entity.go
│   │   │   ├── value_objects.go
│   │   │   └── repository.go  # 仓储接口
│   │   └── order/
│   │       └── ...
│   ├── application/       # 应用层(用例/事务协调、DTO、服务)
│   │   └── usercase/
│   │       ├── service.go
│   │       └── dto.go
│   ├── adapter/           # 适配器(输入/输出)实现
│   │   ├── http/
│   │   │   └── user_handler.go
│   │   └── grpc/
│   └── infra/             # 基础设施(DB、缓存、邮件、外部系统)
│       ├── db/
│       │   └── postgres_user_repo.go
│       └── logger/
├── api/                   # OpenAPI/Protos/接口定义
├── pkg/                   # 可以被外部项目复用的包(小心使用)
├── scripts/
├── deployments/
└── go.mod
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

说明:

  • 把业务代码放 internal,确保不会被外部 import(增强封装)。
  • domain 只包含领域概念与仓储接口,不包含任何 ORM 或 HTTP 相关内容。
  • adapterinfra 放置实现(仓储实现、HTTP handler、消息总线消费者等)。

# 3. 包命名与文件命名规范

  • 包名:短小、单数、小写(例如 user, order, payment),不要下划线或驼峰。
  • 避免使用 domain 下的通用包名(如 modelscommon)—这些通常招致职责不清。
  • 文件名:按职责细分(entity.go, repository.go, service.go, handler_http.go),避免单文件过大。
  • 类型命名:导出类型使用大写(User, Order);非导出使用小写。
  • 接口命名:接口名建议用行为后缀(UserRepository, PaymentGateway),而不是 IUser 风格。
  • 变量/函数命名:驼峰小写,导出函数首字母大写。

# 4. 领域层(internal/domain/...

特点:纯净、无依赖实现细节。

示例:internal/domain/user/entity.go

package user

import "time"

// User 是聚合根
type User struct {
    ID        string
    Email     string
    Password  string // 哈希
    CreatedAt time.Time
}

func NewUser(id, email, passwordHash string) *User {
    return &User{ID: id, Email: email, Password: passwordHash, CreatedAt: time.Now()}
}

func (u *User) ChangeEmail(new string) {
    // 领域验证/不变式在此处实现
    u.Email = new
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

示例:internal/domain/user/repository.go

package user

// UserRepository 定义了持久化契约(接口)
type UserRepository interface {
    Save(u *User) error
    FindByID(id string) (*User, error)
    FindByEmail(email string) (*User, error)
}
1
2
3
4
5
6
7
8

说明:领域层定义契约(接口),但不包含实现。实现应在 internal/infrainternal/adapter/persistence


# 5. 应用层(internal/application/...

职责:编排应用用例、组织事务、调用领域接口、转换 DTO。

示例:internal/application/usercase/service.go

package usercase

import (
    "errors"
    "myapp/internal/domain/user"
)

type UserService struct {
    repo user.UserRepository
}

func NewUserService(r user.UserRepository) *UserService { return &UserService{repo: r} }

func (s *UserService) Register(email, passwordHash string) (*user.User, error) {
    // 1. 领域规则校验(可调用领域服务)
    if existing, _ := s.repo.FindByEmail(email); existing != nil {
        return nil, errors.New("email exists")
    }
    u := user.NewUser(generateID(), email, passwordHash)
    if err := s.repo.Save(u); err != nil { return nil, err }
    return u, nil
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

注意:应用层可以处理事务边界(例如开始/提交 DB 事务),但应保持尽可能薄。


# 6. 适配器/基础设施层(internal/adapter, internal/infra

职责:把领域接口连接到具体技术实现(ORM、HTTP、消息队列)。

示例:internal/infra/db/postgres_user_repo.go

package db

import (
    "database/sql"
    "myapp/internal/domain/user"
)

type PostgresUserRepo struct { db *sql.DB }

func NewPostgresUserRepo(db *sql.DB) *PostgresUserRepo { return &PostgresUserRepo{db: db} }

func (r *PostgresUserRepo) Save(u *user.User) error {
    // SQL 操作
    return nil
}

func (r *PostgresUserRepo) FindByID(id string) (*user.User, error) { return nil, nil }
func (r *PostgresUserRepo) FindByEmail(email string) (*user.User, error) { return nil, nil }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

cmd/myapp/main.go 中构建依赖关系并注入:

func main() {
    db := mustOpenDB()
    userRepo := db.NewPostgresUserRepo(dbConn)
    userSvc := usercase.NewUserService(userRepo)
    // wire up http handlers with userSvc
}
1
2
3
4
5
6

# 7. 关于包名 user.User 的讨论

你可能注意到:在 Go 里 user.User(包名 + 类型名)经常出现。这是正常且符合惯例的。它能清晰表达:类型 User 属于 user 包。但要注意:

  • 若觉得冗余,可以在导入时起别名:u "myapp/internal/domain/user",使用 u.User
  • 不推荐把包也命名为 modelsentities 之类通用名,这会降低可读性。
  • 建议按 聚合(aggregate)建包:domain/userdomain/order,更契合 DDD。

# 8. 事务与一致性边界

  • 短事务:在应用层开启并提交(例如在 service 方法内),把事务对象通过仓储实现传递。
  • 跨聚合事务:优先用最终一致性(事件/消息)而不是分布式事务(两阶段),除非必须。

样例:在 application 层开始事务:

// pseudo
func (s *Service) DoSomething(...) error {
    tx := db.Begin()
    defer func() { if err != nil { tx.Rollback() } else { tx.Commit() } }()
    // repo 使用 tx 实现
}
1
2
3
4
5
6

# 9. 测试建议

  • 领域单测:只针对 internal/domain/* 的行为和不变式编写单元测试(无需 DB)。
  • 集成测试:把 infra 的实现替换为 test doubles 或使用测试 DB(docker-compose/testcontainers)。
  • 端到端测试:在 CI 中用真实依赖(容器化 DB / MQ)跑 e2e 测试。

# 10. 常见反模式与注意事项

  • 把 ORM 代码放进 domain:违反依赖倒置。领域应该只关心接口。
  • 大而全的 models:职责模糊,容易形成循环依赖。
  • 全局状态/单例数据库变量:不利于测试与并发。
  • 大量使用 pkg/shared:导致隐性耦合,优先用明确的接口注入。
  • 忽略错误包装:在边界处用 errors.Wrap%w 链接错误上下文,便于排查。

# 11. 代码风格 & 工具链建议

  • gofmt / goimports / golangci-lint(开启 govet,staticcheck)。
  • 采用 go test ./... 在 CI 中持续运行。使用 -race 在集成测试中检查竞态。
  • 依赖管理用 go mod,避免把 vendor 弹性化管理滥用。

# 12. 实战示例(完整包关系小结)

  • internal/domain/user:实体、值对象、仓储接口、领域服务。
  • internal/application/usercase:应用服务、DTO、用例编排。
  • internal/infra/db:Postgres/MySQL 的仓储实现。
  • internal/adapter/http:HTTP 层(处理 request -> DTO -> 调用 application service),返回标准化错误/响应。

这样可以保证:领域不依赖 infra;infra 依赖 domain;application 把 domain 与 infra 组装起来。


# 13. 最后一张清单(落地前检查)

  • [ ] 领域层:无任何第三方依赖。
  • [ ] 仓储接口放领域,具体实现放 infra/adapter。
  • [ ] 使用 internal 隔离实现,暴露可复用组件放 pkg
  • [ ] 包名小写、单数、职责单一。
  • [ ] 主函数(cmd/)负责依赖注入与启动。
  • [ ] CI 覆盖 gofmt, golangci-lint, go test

# 结语

把 DDD 的思路带入 Go 项目不是为了机械搬运概念,而是把“边界明确、职责单一、可测试、易演进”的工程实践落地。用 internal、短小清晰的包名、接口注入与慎用共享包,就能既保留 Go 的简洁,又实现领域驱动的长期可维护性。