你还在被这些问题折磨吗?
UpdateId、UpdateTime)总忘记赋值,数据追溯链断裂order.Status = "Canceled" 这样随手一改,所有业务规则形同虚设DateTime.Now 不可控,变成“看天吃饭”的玄学代码根本原因只有一个:你的实体在“裸奔”。 数据和行为分家,任何代码都能直接修改数据库映射的每一个字段。 今天,我们来一起讨论一下 零第三方依赖、纯 C# 语法约束、完美兼容 EF Core 的轻量级富领域实体方案,让你的实体从源头穿上铠甲。
典型“便利”写法如下,相信你无比熟悉:
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
}
这种贫血模型把安全职责全部甩给分散的业务代码,不可避免地造成:
UpdateTime = DateTime.Now; UpdateId = userId;,一处遗忘,日志断裂。UserName 为 null 的残缺用户。Clear() 或添加无效行,数据一致性崩坏。DateTime.Now 硬编码,测试无法稳定重现,容器化环境下时区事故频发。把权力收回来,让实体自己守护数据,这就是富领域模型的使命。
我们不加 DDD 重型框架,只靠 C# 访问修饰符 + 方法封装 + 时间抽象 三个原生能力。
private set,外部不可直接赋值。init,构造函数赋值后只读,永久不可变。private set,统一通过语义化方法修改,杜绝绕过审计。Update(operatorId),自动刷新 UpdateTime 和 UpdateId,从物理上消灭漏填可能。Delete()、UpdateIsEnabled() 等方法,含义清晰,不可误用。IDateTime 接口,默认使用 UtcNow,根除时区陷阱。下面代码可直接复制到项目中,按需调整命名空间即可。
// 基础标识实体(如有自己的框架可替换)
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;
}
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);
}
}
SysUser —— 全部 private set,零裸赋值[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);
}
}
SysUserRole[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即可。

Update(operatorId),审计赋值一次编写,终身受用。IsDeleted、IsEnabled 只能通过 Delete、UpdateIsEnabled 变更,业务语义显式且不可绕过。IDateTime 注入,默认 UtcNow,测试中冻结时间轻轻松松,全球部署零事故。UserName 为 null 的残缺用户。AddUserRole 等方法修改,杜绝 Clear() 误操作或注入无效数据。Status = "..."。private set、init、私有集合,EF Core 都能完美处理:
private set**:EF Core 用反射读写,与 public set 无异。init**:EF Core 5.0+ 原生支持,插入后即只读,匹配 CreateTime 语义。_userRoles**:只需在 DbContext 中配置导航关系,EF Core 会自动填充。// 用户实体
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 以上。
既然强调时间可测试,我们展示一个极简的测试桩:
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);
}
测试示例:
[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并配合清理。
例如修改密码的完整流程:
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 访问器,最后封装集合操作——每一步改进,都能立即感受到代码安全性的质变。