【ASP.NET CORE】 13. DDD初步实现

本系列专栏基于杨中科老师的《ASP.NET Core技术内幕与项目实战》,本人记录梳理的学习笔记,有部分的增补和省略。更全面系统的讲解,请看杨老师的视频课:【.NET教程,.Net Core视频教程,杨中科主讲】

一、实现充血模型

充血模型 = 属性 + 业务方法 + 数据校验 + 私有状态

拒绝只有 getter/setter 的贫血类,把数据和行为绑定,这是 DDD 最基础的落地。

1.充血模型 User 实体

cs 复制代码
// 辅助类:密码哈希(实际项目用 BCrypt/PBKDF2)
public static class HashHelper
{
    public static string Hash(string password) 
        => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(password));
}

// 充血模型实体
public record User
{
    // 特征:标识符不可变(init)
    public int Id { get; init; }
    public DateTime CreatedDateTime { get; init; }

    // 特征:公开属性私有 set,外部只能通过方法修改
    public string UserName { get; private set; }
    public int Credit { get; private set; }

    // 特征:私有字段(不对外暴露,仅内部操作)
    private string? passwordHash;
    private string? remark;

    // 特征:只读属性(只提供get,不允许外部修改)
    public string? Remark
    {
        get { return remark; }
    }

    // 特征:临时属性(不存数据库,仅内存使用)
    public string? Tag { get; set; }

    // 特征:私有无参构造(仅EF Core使用,禁止外部直接new)
    private User() { }

    // 特征:公共构造方法(强制业务规则,创建必须传用户名)
    public User(string yhm)
    {
        this.UserName = yhm;
        this.CreatedDateTime = DateTime.Now;
        this.Credit = 10; // 初始积分
    }

    // 业务方法:修改用户名
    public void ChangeUserName(string newValue)
    {
        this.UserName = newValue;
    }

    // 业务方法:修改密码(自带校验!充血模型核心)
    public void ChangePassword(string newValue)
    {
        // 业务规则:密码长度不能小于6
        if (newValue.Length < 6)
        {
            throw new ArgumentException("密码长度不能小于6位");
        }
        this.passwordHash = HashHelper.Hash(newValue);
    }
}

2.EF Core 配置

映射私有字段、忽略临时属性

cs 复制代码
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

public class UserConfig : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<User> builder)
    {
        // 映射私有字段 passwordHash
        builder.Property("passwordHash");

        // 映射私有字段 remark 到 Remark 属性
        builder.Property(u => u.Remark).HasField("remark");

        // 忽略临时属性 Tag(不存入数据库)
        builder.Ignore(u => u.Tag);

        // 常规配置
        builder.Property(u => u.UserName).IsRequired().HasMaxLength(50);
    }
}

3.使用方式

强制走业务方法,保证数据安全

cs 复制代码
// 1. 创建用户
User u1 = new User("Zack");
u1.Tag = "MyTag"; // 临时属性,不入库
u1.ChangePassword("123456"); // 必须调用方法修改
ctx.Users.Add(u1);
ctx.SaveChanges();

// 2. 查询用户
User u1 = ctx.Users.First(u => u.UserName == "Zack");
Console.WriteLine(u1);

二、实现值对象

值对象 = 无 ID + 不可变 + 数据校验 + 依附实体存在

在 EF Core 中使用 Owned Entity Types(从属实体) 实现。

1.地理坐标值对象

cs 复制代码
// 不可变值对象(record 天然不可变)
public record Geo
{
    public double Longitude { get; init; }
    public double Latitude { get; init; }

    // 构造时强制校验业务规则
    public Geo(double longitude, double latitude)
    {
        if (longitude < -180 || longitude > 180)
            throw new ArgumentException("经度格式错误");
        if (latitude < -90 || latitude > 90)
            throw new ArgumentException("纬度格式错误");

        this.Longitude = longitude;
        this.Latitude = latitude;
    }
}

2.多语言字符串值对象

cs 复制代码
public record MultilingualString(string Chinese, string? English);

3.EF Core 配置

OwnsOne

cs 复制代码
// 假设 Shop 实体使用 Name 值对象
public class Shop
{
    public int Id { get; set; }
    public MultilingualString Name { get; set; } = null!;
    public Geo Location { get; set; } = null!;
}

// 配置
public class ShopConfig : IEntityTypeConfiguration<Shop>
{
    public void Configure(EntityTypeBuilder<Shop> builder)
    {
        // 配置值对象
        builder.OwnsOne(c => c.Name, nb =>
        {
            nb.Property(e => e.English).HasMaxLength(20).IsUnicode(false);
            nb.Property(e => e.Chinese).HasMaxLength(20).IsUnicode(true);
        });

        builder.OwnsOne(s => s.Location);
    }
}

4.枚举转字符串存储

提升可读性

cs 复制代码
public enum Currency
{
    CNY, USD, NZD
}

// 配置:枚举存字符串,而不是数字
builder.Property(e => e.Currency)
       .HasConversion<string>() // 核心
       .HasMaxLength(10);

三、聚合与聚合根实现

聚合 = 一组紧密关联的实体 + 值对象 聚合根 = 聚合唯一入口,外部只能访问根

1.定义聚合根标识接口

cs 复制代码
/// <summary>
/// 聚合根标记接口
/// </summary>
public interface IAggregateRoot
{
}

2.实现聚合根

cs 复制代码
// Order 是聚合根,实现 IAggregateRoot
public class Order : IAggregateRoot
{
    public int Id { get; set; }
    public DateTime CreateTime { get; set; }

    // 包含值对象
    public Address ShippingAddress { get; set; } = null!;

    // 包含子实体(非聚合根,外部禁止直接访问)
    private List<OrderItem> _items = new();
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();

    // 只能通过根添加明细(高内聚)
    public void AddItem(int productId, int count)
    {
        _items.Add(new OrderItem(productId, count));
    }
}

// 子实体:非聚合根,外部不能直接操作
public class OrderItem
{
    public int Id { get; set; }
    public int ProductId { get; set; }
    public int Count { get; set; }

    // 私有构造,只能由 Order 调用
    internal OrderItem(int productId, int count)
    {
        ProductId = productId;
        Count = count;
    }
}

3.EF Core 配置规范

  • 只给聚合根声明 DbSet
  • 子实体 / 值对象通过根访问
  • 跨聚合只引用 ID,不引用对象
cs 复制代码
public class AppDbContext : DbContext
{
    // 只暴露聚合根
    public DbSet<User> Users => Set<User>();
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(GetType().Assembly);
    }
}

拓展:多个聚合根放一个上下文还是多个?

方案 优点 缺点
一个上下文 简单、事务方便、配置少 庞大、职责不清
多个上下文(边界上下文) 职责清晰、微服务友好 跨上下文事务复杂

建议一个微服务 = 一个界限上下文 = 一个 DbContext ,里面可以包含多个聚合根

四、领域事件实现

领域事件:同一服务内的业务触发通知 最佳实践:延迟发布(SaveChanges 时发布),避免 "未入库就发事件" 的误报。

1.核心思路

  • 实体只注册事件,不立即发布
  • SaveChangesAsync 中统一发布
  • 发布后清空事件列表

2.实现步骤

第一步:定义领域事件基础接口

cs 复制代码
public interface IDomainEvents
{
    IEnumerable<INotification> GetDomainEvents();
    void AddDomainEvent(INotification eventItem);
    void ClearDomainEvents();
}

第二步:实体基类

cs 复制代码
public abstract class BaseEntity : IDomainEvents
{
    private List<INotification> _domainEvents = new();

    public IEnumerable<INotification> GetDomainEvents() => _domainEvents.AsReadOnly();

    public void AddDomainEvent(INotification eventItem)
    {
        _domainEvents.Add(eventItem);
    }

    public void ClearDomainEvents()
    {
        _domainEvents.Clear();
    }
}

第三步:重写 DbContext 实现自动发布

cs 复制代码
public class AppDbContext : DbContext
{
    private readonly IMediator _mediator;

    // 注入 MediatR
    public AppDbContext(DbContextOptions<AppDbContext> options, IMediator mediator)
        : base(options)
    {
        _mediator = mediator;
    }

    // 重写 SaveChanges 发布事件
    public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
    {
        // 1. 找到所有注册了领域事件的实体
        var domainEntities = ChangeTracker.Entries<IDomainEvents>()
            .Where(e => e.Entity.GetDomainEvents().Any()).ToList();

        // 2. 取出所有事件
        var domainEvents = domainEntities.SelectMany(e => e.Entity.GetDomainEvents()).ToList();

        // 3. 清空实体事件
        domainEntities.ForEach(e => e.Entity.ClearDomainEvents());

        // 4. 保存数据
        int result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);

        // 5. 发布事件(确保数据入库后再发,避免误报)
        foreach (var evt in domainEvents)
        {
            await _mediator.Publish(evt);
        }

        return result;
    }
}

第四步:定义领域事件

cs 复制代码
// 用户创建事件
public record UserCreatedDomainEvent(User User) : INotification;

第五步:在实体中注册事件

cs 复制代码
public class User : BaseEntity // 继承基类
{
    public User(string userName)
    {
        UserName = userName;
        // 注册事件,不立即发布
        AddDomainEvent(new UserCreatedDomainEvent(this));
    }
}

第六步:编写事件处理器

cs 复制代码
public class UserCreatedEventHandler : INotificationHandler<UserCreatedDomainEvent>
{
    public async Task Handle(UserCreatedDomainEvent notification, CancellationToken cancellationToken)
    {
        // 执行业务:发送欢迎短信、增加积分、日志记录
        Console.WriteLine($"新用户创建:{notification.User.UserName}");
        await Task.CompletedTask;
    }
}

注册 MediatR(Program.cs)

cs 复制代码
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
});

总结

  1. **充血模型:**私有 set、私有字段、业务方法、数据校验;EF 映射私有字段、忽略临时属性。
  2. 值对象: 使用 record 保证不可变;构造函数校验;EF OwnsOne 配置。
  3. 聚合与聚合根: 标记接口 IAggregateRoot;只暴露聚合根 DbSet;跨聚合只引用 ID。
  4. 领域事件: 实体注册事件;SaveChanges 统一发布;MediatR 处理;杜绝 "未入库就发事件" BUG。
相关推荐
huabiangaozhi2 小时前
Spring Cloud Gateway 整合Spring Security
java·后端·spring
中屹指纹浏览器2 小时前
2026年浏览器指纹技术原理、平台检测机制与安全防护体系构建
经验分享·笔记
Keeling17202 小时前
SpringAI学习笔记(三)会话记忆功能
笔记·学习·spring·ai
悠哉悠哉愿意2 小时前
【物联网学习笔记】RTC
笔记·单片机·嵌入式硬件·物联网·学习·实时音视频
早起CaiCai2 小时前
【综述 + 2018】内重力波
笔记
野犬寒鸦2 小时前
从零起步学习计算机操作系统:进程篇(知识扩展提升)
java·服务器·开发语言·后端·面试
轩情吖2 小时前
MySQL内置函数
android·数据库·c++·后端·mysql·开发·函数
2501_930707782 小时前
使用C#代码将 PDF 转换为 PostScript(PS)格式
开发语言·pdf·c#
金山几座2 小时前
C#学习记录-泛型
开发语言·学习·c#