首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >.NET 轻量级富领域实体全攻略

.NET 轻量级富领域实体全攻略

作者头像
云中小生
发布2026-06-12 17:19:05
发布2026-06-12 17:19:05
500
举报

你还在被这些问题折磨吗?

  • 审计字段(UpdateIdUpdateTime)总忘记赋值,数据追溯链断裂
  • 订单状态被 order.Status = "Canceled" 这样随手一改,所有业务规则形同虚设
  • 单元测试因为 DateTime.Now 不可控,变成“看天吃饭”的玄学代码

根本原因只有一个:你的实体在“裸奔”。 数据和行为分家,任何代码都能直接修改数据库映射的每一个字段。 今天,我们来一起讨论一下 零第三方依赖、纯 C# 语法约束、完美兼容 EF Core 的轻量级富领域实体方案,让你的实体从源头穿上铠甲。


一、贫血实体的五大隐患

典型“便利”写法如下,相信你无比熟悉:

代码语言:javascript
复制
public class Order
{
    public Guid Id { get; set; }
    public string Status { get; set; }          // 谁都能改
    public DateTime CreateTime { get; set; }    // 可以被回溯成昨天
    public DateTime? UpdateTime { get; set; }   // 经常是 null
    public Guid? UpdateId { get; set; }         // 手工赋值,容易忘
    public List<OrderItem> Items { get; set; }  // 外部可随意 Clear
}

这种贫血模型把安全职责全部甩给分散的业务代码,不可避免地造成:

  1. 审计漏填 —— 每次更新都要手动写 UpdateTime = DateTime.Now; UpdateId = userId;,一处遗忘,日志断裂。
  2. 非法状态变更 —— 库存、订单状态可直接赋值,跨过所有领域约束。
  3. 脏数据泛滥 —— 创建时不强制必填项,数据库里出现 UserName 为 null 的残缺用户。
  4. 关联被破坏 —— 外部直接 Clear() 或添加无效行,数据一致性崩坏。
  5. 时间不可控 —— DateTime.Now 硬编码,测试无法稳定重现,容器化环境下时区事故频发。

把权力收回来,让实体自己守护数据,这就是富领域模型的使命。


二、设计核心:三大支柱,构建安全边界

我们不加 DDD 重型框架,只靠 C# 访问修饰符 + 方法封装 + 时间抽象 三个原生能力。

支柱①:属性访问控制

  • 核心字段 / 状态字段private set,外部不可直接赋值。
  • 创建时间init,构造函数赋值后只读,永久不可变。
  • 所有可持久化字段(包括可空信息)→ 收紧为 private set,统一通过语义化方法修改,杜绝绕过审计。

支柱②:行为封装,审计自驱

  • 所有修改都调用内部 Update(operatorId),自动刷新 UpdateTimeUpdateId,从物理上消灭漏填可能。
  • 软删除、启禁用等操作封装为 Delete()UpdateIsEnabled() 等方法,含义清晰,不可误用。

支柱③:时间抽象,可测试 + 时区安全

  • 引入 IDateTime 接口,默认使用 UtcNow,根除时区陷阱。
  • 单元测试只需注入固定时间桩,让时间相关逻辑确定且可重复。

三、代码实战:从基类到业务实体,完整落地

下面代码可直接复制到项目中,按需调整命名空间即可。

3.1 基础实体标识与时间抽象

代码语言:javascript
复制
// 基础标识实体(如有自己的框架可替换)
public abstract class BaseEntity<TKey>
{
    [Key]
    public TKey Id { get; set; } = default!;
}

// 时间抽象接口
public interface IDateTime
{
    DateTime Now { get; }
    DateTime UtcNow { get; }
}

public class SystemDateTime : IDateTime
{
    public DateTime Now => DateTime.Now;
    public DateTime UtcNow => DateTime.UtcNow;
}

3.2 实体基类:审计 + 时间注入 + 乐观并发

代码语言:javascript
复制
public abstract class BasicEntity : BaseEntity<Guid>
{
    private static IDateTime _dateTime = new SystemDateTime();

    /// <summary>
    /// 仅在应用启动或单元测试时调用一次,用于替换时间提供者。
    /// 注意:不要在生产运行时动态切换,避免线程安全问题。
    /// </summary>
    public static void SetDateTimeProvider(IDateTime provider)
    {
        _dateTime = provider ?? thrownew ArgumentNullException(nameof(provider));
    }

    protected BasicEntity() { }

    protected BasicEntity(Guid createId)
    {
        CreateId = createId;
        CreateTime = _dateTime.UtcNow;   // 统一使用 UTC,消除时区歧义
    }

    [Column("IsDeleted"), Comment("是否删除")]
    public bool IsDeleted { get; private set; } = false;

    [Column("IsEnabled"), Comment("是否启用")]
    public bool IsEnabled { get; private set; } = true;

    [Column("CreateTime"), Comment("创建时间")]
    public DateTime CreateTime { get; init; }   // init 保证只在初始化时可赋值

    [Column("UpdateTime"), Comment("更新时间")]
    public DateTime? UpdateTime { get; private set; }

    [Column("CreateId"), Comment("创建人ID")]
    public Guid CreateId { get; private set; }

    [Column("UpdateId"), Comment("更新人ID")]
    public Guid? UpdateId { get; private set; }

    /// <summary>统一维护审计字段</summary>
    public void Update(Guid updateId)
    {
        UpdateId = updateId;
        UpdateTime = _dateTime.UtcNow;
    }

    /// <summary>软删除</summary>
    public void Delete(Guid deleteId)
    {
        IsDeleted = true;
        IsEnabled = false;       // 删除同时必须禁用,防止业务复活已删数据
        Update(deleteId);
    }

    /// <summary>切换启用状态,内部检查已删除实体不可再次启用</summary>
    public void UpdateIsEnabled(Guid updateId, bool isEnabled)
    {
        if (IsDeleted) return;   // 或者抛出领域异常
        IsEnabled = isEnabled;
        Update(updateId);
    }
}

3.3 业务实体:SysUser —— 全部 private set,零裸赋值

代码语言:javascript
复制
[Comment("系统用户表")]
public class SysUser : BasicEntity
{
    private SysUser() { }   // EF Core 要求

    // 构造函数:必填项强制传入,杜绝残缺实体。注意:密码应传入哈希值,而非明文。
    public SysUser(string userName, string passwordHash, string? realName,
                   string? phone, string? email, Guid createId) : base(createId)
    {
        UserName = userName;
        PasswordHash = passwordHash;
        RealName = realName;
        Phone = phone;
        Email = email;
    }

    [Column("UserName"), Required, MaxLength(50)]
    public string UserName { get; private set; } = string.Empty;

    // 存储密码哈希,禁止外部直接接触
    [Column("PasswordHash"), Required, MaxLength(200)]
    public string PasswordHash { get; private set; } = string.Empty;

    // 以下可空字段全部 private set
    [Column("RealName"), MaxLength(50)]
    public string? RealName { get; private set; }

    [Column("Phone"), MaxLength(20)]
    public string? Phone { get; private set; }

    [Column("Email"), MaxLength(100)]
    public string? Email { get; private set; }

    public bool IsSuperAdmin { get; private set; }

    // ---------- 角色关联:私有集合,只读暴露 ----------
    private readonly List<SysUserRole> _userRoles = [];
    public IReadOnlyList<SysUserRole> UserRoles => _userRoles.AsReadOnly();

    public void AddUserRole(Guid roleId)
    {
        if (!_userRoles.Any(r => r.RoleId == roleId))
            _userRoles.Add(new SysUserRole(Id, roleId));
    }

    public void RemoveUserRole(Guid roleId)
    {
        _userRoles.RemoveAll(r => r.RoleId == roleId);
    }

    public void UpdateUserRoles(IEnumerable<Guid> newRoleIds, Guid operatorId)
    {
        var newSet = newRoleIds.Distinct().ToHashSet();
        var oldSet = _userRoles.Select(r => r.RoleId).ToHashSet();
        if (newSet.SetEquals(oldSet)) return;

        _userRoles.RemoveAll(r => oldSet.Except(newSet).Contains(r.RoleId));
        foreach (var id in newSet.Except(oldSet))
            _userRoles.Add(new SysUserRole(Id, id));

        Update(operatorId);
    }

    // ---------- 业务更新方法(审计自动跟随)----------
    public void UpdateProfile(string userName, string? realName, string? phone, string? email, Guid userId)
    {
        UserName = userName;
        RealName = realName;
        Phone = phone;
        Email = email;
        Update(userId);
    }

    public void UpdatePhone(string? phone, Guid userId)
    {
        Phone = phone;
        Update(userId);
    }

    public void UpdateEmail(string? email, Guid userId)
    {
        Email = email;
        Update(userId);
    }

    public void UpdatePasswordHash(string passwordHash, Guid userId)
    {
        PasswordHash = passwordHash;
        Update(userId);
    }

    public void SetSuperAdmin(bool isSuperAdmin, Guid userId)
    {
        IsSuperAdmin = isSuperAdmin;
        Update(userId);
    }
}

3.4 关联实体 SysUserRole

代码语言:javascript
复制
[Comment("用户角色关联表")]
public class SysUserRole
{
    private SysUserRole() { }

    public SysUserRole(Guid userId, Guid roleId)
    {
        UserId = userId;
        RoleId = roleId;
    }

    [Column("UserId")]
    public Guid UserId { get; private set; }

    [Column("RoleId")]
    public Guid RoleId { get; private set; }
}

若关联表需要记录分配时间、操作人,直接继承 BasicEntity 即可。


四、六大核心优势,看得见的安全感

贫血模型 vs 富领域实体对比图
贫血模型 vs 富领域实体对比图
  1. 审计永无遗漏 任何修改最终汇聚到 Update(operatorId),审计赋值一次编写,终身受用。
  2. 状态防篡改 IsDeletedIsEnabled 只能通过 DeleteUpdateIsEnabled 变更,业务语义显式且不可绕过。
  3. 时间可测试、时区安全 通过 IDateTime 注入,默认 UtcNow,测试中冻结时间轻轻松松,全球部署零事故。
  4. 脏数据源头拦截 构造函数强制必填,数据库中永无 UserName 为 null 的残缺用户。
  5. 关联集合只读保护 外部只能通过 AddUserRole 等方法修改,杜绝 Clear() 误操作或注入无效数据。
  6. 逻辑高度内聚 修改一个业务规则只需看这个实体文件,不用在数百个 Service 里寻找散落的 Status = "..."

五、EF Core 完全兼容 + 配置示范

private setinit、私有集合,EF Core 都能完美处理:

  • **private set**:EF Core 用反射读写,与 public set 无异。
  • **init**:EF Core 5.0+ 原生支持,插入后即只读,匹配 CreateTime 语义。
  • **私有集合 _userRoles**:只需在 DbContext 中配置导航关系,EF Core 会自动填充。

完整 Fluent API 配置示例(OnModelCreating)

代码语言:javascript
复制
// 用户实体
modelBuilder.Entity<SysUser>(e =>
{
    e.HasKey(u => u.Id);

    e.HasMany(u => u.UserRoles)
     .WithOne()
     .HasForeignKey(ur => ur.UserId)
     .OnDelete(DeleteBehavior.Cascade);

    // 确保 EF Core 能正确访问私有字段
    e.Navigation(u => u.UserRoles)
     .UsePropertyAccessMode(PropertyAccessMode.Field);
});

// 关联实体
modelBuilder.Entity<SysUserRole>(e =>
{
    e.HasKey(ur => new { ur.UserId, ur.RoleId });
});

版本要求:.NET 6+ / EF Core 6+,[Comment] 特性需 EF Core 5.0 以上。


六、测试友好:时间冻结仅需一行

既然强调时间可测试,我们展示一个极简的测试桩:

代码语言:javascript
复制
public class FakeDateTime : IDateTime
{
    public DateTime Now { get; set; } = new(2025, 1, 1);
    public DateTime UtcNow { get; set; } = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
}

测试示例:

代码语言:javascript
复制
[Fact]
public void Create_User_Should_Set_CreateTime_To_UtcNow()
{
    // Arrange
    var fakeTime = new FakeDateTime { UtcNow = new DateTime(2025, 6, 9, 10, 0, 0, DateTimeKind.Utc) };
    BasicEntity.SetDateTimeProvider(fakeTime);
    var userId = Guid.NewGuid();

    // Act
    var user = new SysUser("john", "hashed_pwd", "John", "123", "john@example.com", userId);

    // Assert
    Assert.Equal(fakeTime.UtcNow, user.CreateTime);
}

⚠️ 注意:并行测试中避免直接修改静态状态,推荐在每个测试类中 SetDateTimeProvider 并配合清理。


七、边界划分,让实体只做自己的事

  • 实体内:处理自身字段、简单状态变化、内部关联(如角色增删)。
  • 实体外:跨实体逻辑、密码哈希计算、发送邮件、调用外部 API 等,一律交给领域服务/应用服务

例如修改密码的完整流程:

代码语言:javascript
复制
public class UserService
{
    public void ChangePassword(Guid userId, string oldPassword, string newPassword)
    {
        var user = _repo.Get(userId);
        // 应用服务负责验证旧密码、计算新哈希
        if (!_passwordHasher.Verify(oldPassword, user.PasswordHash))
            throw new UnauthorizedAccessException();
        var newHash = _passwordHasher.Hash(newPassword);
        user.UpdatePasswordHash(newHash, userId);   // 实体只负责赋值+审计
    }
}

八、总结:让实体穿上铠甲,系统坚不可摧

轻量富领域实体完整架构
轻量富领域实体完整架构

我们未引入任何第三方框架,仅靠 C# 原生语法EF Core 天然支持,就实现了:

  • ✅ 审计全自动,彻底消灭漏填
  • ✅ 状态强制封装,篡改无处下手
  • ✅ 时间可替换,单测稳如磐石,时区永远正确
  • ✅ 脏数据从源头截断
  • ✅ 关联导航只读保护,行为闭环
  • ✅ 逻辑内聚,维护成本断崖式下降

这套模式兼容现有项目改造:先继承 BasicEntity,再逐步收紧 set 访问器,最后封装集合操作——每一步改进,都能立即感受到代码安全性的质变。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-06-10,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 .NET修仙日记 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、贫血实体的五大隐患
  • 二、设计核心:三大支柱,构建安全边界
    • 支柱①:属性访问控制
    • 支柱②:行为封装,审计自驱
    • 支柱③:时间抽象,可测试 + 时区安全
  • 三、代码实战:从基类到业务实体,完整落地
    • 3.1 基础实体标识与时间抽象
    • 3.2 实体基类:审计 + 时间注入 + 乐观并发
    • 3.3 业务实体:SysUser —— 全部 private set,零裸赋值
    • 3.4 关联实体 SysUserRole
  • 四、六大核心优势,看得见的安全感
  • 五、EF Core 完全兼容 + 配置示范
    • 完整 Fluent API 配置示例(OnModelCreating)
  • 六、测试友好:时间冻结仅需一行
  • 七、边界划分,让实体只做自己的事
  • 八、总结:让实体穿上铠甲,系统坚不可摧
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档