3. 领域驱动设计(DDD)基础:实体、值对象、聚合、领域服务、仓储模式
领域驱动设计(Domain-Driven Design,DDD)为报表模块的边界划分提供了战术层面的建模工具。在 Go 语言中实施 DDD,核心在于正确识别和实现五种基本构造块:实体(Entity)、值对象(Value Object)、聚合(Aggregate)、领域服务(Domain Service)和仓储(Repository)。本节将逐一剖析这些概念,并结合报表场景给出 Go 实现示例。
3.1 实体(Entity)
实体是具有唯一标识且会随时间改变状态的对象。在报表系统中,Report(报表)是一个典型的实体——每个报表拥有唯一的 ID,其状态(如“草稿”、“已发布”)和属性(如标题、数据源)会随生命周期变化。
- 拥有唯一标识(ID),通常由系统生成或业务规则确定。
- 可变性:属性可以修改,状态可以流转。
- 相等性由 ID 决定,而非属性值。
// Report 是报表系统中的核心实体
type Report struct {
ID ReportID // 值对象作为 ID
Title string
Status ReportStatus // 值对象
DataSource DataSourceID
CreatedAt time.Time
UpdatedAt time.Time
}
// ReportID 是报表的唯一标识,本身是一个值对象
type ReportID struct {
Value string
}
// 实体相等性比较:仅比较 ID
func (r Report) Equals(other Report) bool {
return r.ID.Value == other.ID.Value
}
3.2 值对象(Value Object)
值对象通过属性值来定义自身,没有唯一标识,且不可变。在报表模块中,ReportStatus、DateRange、ChartConfig 等都是值对象。值对象的核心价值在于封装概念完整性和不变约束。
| 对比维度 | 实体(Entity) | 值对象(Value Object) |
|---|---|---|
| 标识 | 有唯一 ID | 无 ID,由属性定义 |
| 可变性 | 可变 | 不可变(Immutable) |
| 相等性 | ID 相等 | 所有属性相等 |
| 生命周期 | 有独立生命周期 | 依附于实体 |
| 报表示例 | Report, Template, Schedule | ReportStatus, DateRange, FilterCriteria |
// DateRange 是不可变的值对象
type DateRange struct {
Start time.Time
End time.Time
}
// 通过构造函数保证不变性
func NewDateRange(start, end time.Time) (DateRange, error) {
if start.After(end) {
return DateRange{}, errors.New("start must be before end")
}
return DateRange{Start: start, End: end}, nil
}
// 值对象相等性:比较所有字段
func (d DateRange) Equals(other DateRange) bool {
return d.Start.Equal(other.Start) && d.End.Equal(other.End)
}
// 值对象方法返回新实例,不修改自身
func (d DateRange) Duration() time.Duration {
return d.End.Sub(d.Start)
}
3.3 聚合(Aggregate)与聚合根
聚合是一组相关对象的集合,外部只能通过聚合根(Aggregate Root)访问内部对象。聚合保证了数据一致性和不变约束。在报表模块中,Report 作为聚合根,内部包含 ReportSection(报表章节)和 ChartConfig(图表配置)等子实体和值对象。
- 聚合根是唯一允许被外部引用的对象。
- 聚合内部的对象只能通过聚合根的方法修改。
- 一个事务只修改一个聚合。
- 聚合应尽量设计得小一些,以提升性能和并发能力。
// Report 作为聚合根
type Report struct {
ID ReportID
Title string
Status ReportStatus
Sections []ReportSection // 聚合内部的子实体
Config ReportConfig // 聚合内部的值对象
}
// 通过聚合根方法修改内部状态,保证一致性
func (r *Report) AddSection(title string, chartType ChartType) error {
if r.Status != ReportStatusDraft {
return errors.New("only draft reports can add sections")
}
section := ReportSection{
ID: NewSectionID(),
Title: title,
ChartType: chartType,
Order: len(r.Sections) + 1,
}
r.Sections = append(r.Sections, section)
r.UpdatedAt = time.Now()
return nil
}
// ReportSection 是聚合内部的实体,外部不能直接引用
type ReportSection struct {
ID SectionID
Title string
ChartType ChartType
Order int
}
3.4 领域服务(Domain Service)
当某个领域操作不适合放在实体或值对象中时,应将其建模为领域服务。领域服务是无状态的,它协调多个聚合或执行复杂的业务逻辑。在报表模块中,ReportGenerationService 就是一个典型的领域服务——它需要协调 Report 聚合、DataSource 聚合和 Template 聚合来完成报表生成。
- 操作涉及多个聚合时,使用领域服务。
- 操作涉及外部系统(如第三方 API)时,使用领域服务。
- 操作没有明显的归属实体时,使用领域服务。
- 领域服务应保持无状态,所有依赖通过构造函数注入。
// ReportGenerationService 是领域服务
type ReportGenerationService struct {
reportRepo ReportRepository
dataSourceRepo DataSourceRepository
templateRepo TemplateRepository
}
func NewReportGenerationService(
reportRepo ReportRepository,
dataSourceRepo DataSourceRepository,
templateRepo TemplateRepository,
) *ReportGenerationService {
return &ReportGenerationService{
reportRepo: reportRepo,
dataSourceRepo: dataSourceRepo,
templateRepo: templateRepo,
}
}
// GenerateReport 协调多个聚合完成报表生成
func (s *ReportGenerationService) GenerateReport(ctx context.Context, cmd GenerateReportCommand) (*Report, error) {
// 1. 加载数据源聚合
dataSource, err := s.dataSourceRepo.FindByID(ctx, cmd.DataSourceID)
if err != nil {
return nil, fmt.Errorf("load data source: %w", err)
}
// 2. 加载模板聚合
template, err := s.templateRepo.FindByID(ctx, cmd.TemplateID)
if err != nil {
return nil, fmt.Errorf("load template: %w", err)
}
// 3. 创建报表聚合根
report, err := NewReport(cmd.Title, dataSource, template)
if err != nil {
return nil, fmt.Errorf("create report: %w", err)
}
// 4. 执行数据填充和图表生成(复杂业务逻辑)
if err := report.PopulateData(dataSource.FetchData(ctx)); err != nil {
return nil, fmt.Errorf("populate data: %w", err)
}
// 5. 持久化报表聚合
if err := s.reportRepo.Save(ctx, report); err != nil {
return nil, fmt.Errorf("save report: %w", err)
}
return report, nil
}
3.5 仓储模式(Repository)
仓储(Repository)为聚合提供集合式的存取接口,将领域层与基础设施层(如数据库)解耦。在 DDD 中,每个聚合根对应一个仓储。仓储接口定义在领域层,实现放在基础设施层。
| 仓储方法 | 说明 | 报表示例 |
|---|---|---|
Save |
保存聚合(新增或更新) | 保存报表及其章节 |
FindByID |
根据 ID 查找聚合 | 按 ReportID 查找报表 |
FindBySpecification |
根据规约查找聚合集合 | 查找所有“已发布”的报表 |
Delete |
删除聚合 | 删除指定报表 |
// ReportRepository 接口定义在领域层
type ReportRepository interface {
Save(ctx context.Context, report *Report) error
FindByID(ctx context.Context, id ReportID) (*Report, error)
FindBySpecification(ctx context.Context, spec Specification) ([]*Report, error)
Delete(ctx context.Context, id ReportID) error
}
// Specification 规约模式,用于封装查询条件
type Specification interface {
ToQuery() (string, []interface{})
}
// StatusSpecification 按状态查询的规约实现
type StatusSpecification struct {
Status ReportStatus
}
func (s StatusSpecification) ToQuery() (string, []interface{}) {
return "status = ?", []interface{}{s.Status}
}
// PostgreSQLReportRepository 是基础设施层的实现
type PostgreSQLReportRepository struct {
db *sql.DB
}
func (r *PostgreSQLReportRepository) Save(ctx context.Context, report *Report) error {
// 使用事务保证聚合内所有对象的一致性
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// 保存报表主表
_, err = tx.ExecContext(ctx,
`INSERT INTO reports (id, title, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET title=$2, status=$3, updated_at=$5`,
report.ID.Value, report.Title, report.Status, report.CreatedAt, report.UpdatedAt)
if err != nil {
return err
}
// 保存报表章节(子实体)
for _, section := range report.Sections {
_, err = tx.ExecContext(ctx,
`INSERT INTO report_sections (id, report_id, title, chart_type, "order")
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET title=$3, chart_type=$4, "order"=$5`,
section.ID.Value, report.ID.Value, section.Title, section.ChartType, section.Order)
if err != nil {
return err
}
}
return tx.Commit()
}
3.6 报表模块中的 DDD 实践要点
- 包结构:按限界上下文分包,如
internal/report/domain/、internal/report/infrastructure/。 - 接口在消费方定义:仓储接口放在领域层,实现放在基础设施层。
- 避免贫血模型:实体和值对象应包含行为方法,而非仅作为数据容器。
- 使用构造函数保证不变量:值对象通过
NewXXX函数创建,确保合法状态。 - 聚合内使用事务:一个事务只修改一个聚合,通过仓储的
Save方法保证。