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
}
报表聚合结构(Aggregate) 聚合边界(事务一致性) 聚合根:Report ID, Title, Status, UpdatedAt 子实体:ReportSection SectionID, Title, ChartType, Order 值对象:ChartConfig Type, ColorScheme, DataMapping 值对象:DateRange Start, End 值对象:ReportStatus Draft, Published, Archived 外部只能访问聚合根

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()
}
DDD 分层架构与仓储模式 领域层(Domain Layer) 实体 / 值对象 领域服务 仓储接口 定义业务逻辑、不变约束 协调多个聚合的复杂操作 定义存取聚合的契约 基础设施层(Infrastructure Layer) PostgreSQL 仓储实现 Redis 缓存仓储实现 依赖倒置:领域层定义接口,基础设施层实现;领域层不依赖基础设施层

3.6 报表模块中的 DDD 实践要点

Go 语言实现 DDD 的常见模式:
  • 包结构:按限界上下文分包,如 internal/report/domain/、internal/report/infrastructure/。
  • 接口在消费方定义:仓储接口放在领域层,实现放在基础设施层。
  • 避免贫血模型:实体和值对象应包含行为方法,而非仅作为数据容器。
  • 使用构造函数保证不变量:值对象通过 NewXXX 函数创建,确保合法状态。
  • 聚合内使用事务:一个事务只修改一个聚合,通过仓储的 Save 方法保证。