.NET 领域驱动设计:用户角色更新如何从应用服务落地到领域实体(代码拆解) 很多时候我们写业务逻辑时会把一堆代码塞进 Service导致它又肥又难测。下面这两段代码来自同一个功能更新用户的角色列表。一段是应用服务层的入口一段是实体内部的核心逻辑。把它们放在一起看就能明白什么叫“分层不分家”。整体长什么样功能很简单前端传一个用户 ID 和一组新的角色 ID系统用这组角色全量替换用户原有的角色。如果新旧一样就什么都不做。两个方法分工明确服务层UpdateUserRoleAsync负责接收请求、校验参数、加载聚合、提交事务。实体层UpdateUserRoles负责真正的业务规则计算差异、更新集合、记录操作人。第一段服务层方法像一个严谨的门卫/// summary /// 更新用户角色 /// /summary /// param namedto更新用户角色数据传输对象/param /// param namecancellationToken取消令牌/param /// returns更新后的用户实体/returns /// exception crefArgumentNullExceptiondto 为 null/exception /// exception crefArgumentException用户 ID 为空 Guid/exception /// exception crefKeyNotFoundException用户不存在/exception public async TaskSysUser UpdateUserRoleAsync(UpdateUserRoleDto dto, CancellationToken cancellationToken default) { ArgumentNullException.ThrowIfNull(dto); if (dto.Id Guid.Empty) throw new ArgumentException(用户 ID 不能是空 Guid, nameof(dto)); var entity await _repository.Queryable(s s.Id dto.Id) .Include(s s.UserRoles) .FirstOrDefaultAsync(cancellationToken) ?? throw new KeyNotFoundException($未找到 Id 为 {dto.Id} 的用户); entity.UpdateUserRoles(dto.RoleIds, _currentUser.Id); await _unitOfWork.SubmitAsync(cancellationToken); return entity; }它做了什么参数校验DTO 不能为空用户 ID 不能是Guid.Empty。不对的东西直接挡在外面。加载实体用仓储查用户顺带把已有的角色集合UserRoles一起加载出来。查不到就抛KeyNotFoundException。委托业务逻辑调实体自己的UpdateUserRoles方法把新角色 ID 和当前操作人传进去。实体内部怎么改服务层不关心。统一提交工作单元提交所有改动。因为是全量替换增删操作会在这一次提交里完成。返回实体调用方可以直接拿到修改后的用户不用再查一次。几个值得注意的点事务边界清晰_unitOfWork.SubmitAsync确保所有集合操作原子化。取消令牌传递到底层数据库查询和提交都能响应取消请求。异常的粒度参数错误、找不到数据分别对应不同异常上层控制器可以据此返回 400 或 404。第二段实体内部方法真正的业务主场/// summary /// 全量替换当前用户的角色列表 /// /summary /// param namenewRoleIds新的角色 Id 集合会自动去重/param /// param nameoperatorId操作人 Id用于记录最后修改人/param /// remarks /// 该方法会计算新旧集合的差异执行增删操作并更新当前实体的最后修改信息 /// 若新旧集合完全相同则不会做任何修改 /// /remarks public void UpdateUserRoles(IEnumerableGuid newRoleIds, Guid operatorId) { ArgumentNullException.ThrowIfNull(newRoleIds); if (operatorId Guid.Empty) throw new ArgumentException(操作人 Id 不能为空, nameof(operatorId)); var newIdSet newRoleIds.Distinct().ToHashSet(); var oldIdSet _userRoles.Select(r r.RoleId).ToHashSet(); if (newIdSet.SetEquals(oldIdSet)) return; var toAdd newIdSet.Except(oldIdSet).ToList(); var toRemove oldIdSet.Except(newIdSet).ToHashSet(); toAdd.ForEach(roleId _userRoles.Add(new SysUserRole(Id, roleId))); if (toRemove.Count 0) { _userRoles.RemoveAll(ur toRemove.Contains(ur.RoleId)); } Update(operatorId); }这段代码的核心逻辑基于集合运算的差异更新。不直接清空再添加而是用HashSet表示新旧角色 ID 集合自动去重高效比较。SetEquals判断完全一致 → 直接返回避免无意义的数据库操作。Except算出需要新增和需要删除的 ID。分别操作内部的_userRoles集合。一些有意思的细节_userRoles是什么通常是实体里的一个ListUserRole或ICollectionUserRoleEF Core 会追踪它的变化。直接Add或RemoveAll修改集合ChangeTracker 会自动生成对应的 INSERT/DELETE SQL。为什么先加后删顺序其实没有依赖关系。这里先执行toAdd.ForEach再删toRemove即使角色 ID 在新旧集合里同时出现不可能因为Except已经排除了交集也不会有问题。但更常见的是先删后加或先加后删都可以。作者用了先加后删逻辑上没问题。Update(operatorId)是什么一个内部方法负责设置LastModifiedBy operatorId和LastModifiedTime DateTime.UtcNow。审计字段在这里统一更新避免了服务层再去手动赋值。避免了一次坑如果新旧集合完全相同直接返回。这省了一次数据库提交因为外层的_unitOfWork.SubmitAsync虽然会被调用但 EF Core 的 ChangeTracker 没有检测到任何变更提交实际是空操作但显式 return 更清晰。集合差异计算示意图两段代码是怎么配合的层面关注点代码特征应用服务协调资源、事务、安全异步、仓储、工作单元、异常转换领域实体业务规则、数据一致性同步方法、集合操作、无基础设施依赖服务层不知道“角色更新的规则”——比如要不要去重、要不要比较新旧、要不要记录修改人。它只负责把实体从数据库拿出来调一个方法然后保存。实体层不知道“数据从哪里来、保存到哪里去”——它只知道自己的_userRoles集合和Update方法。这让你可以轻松地对实体做单元测试不需要 mock 任何数据库。这种写法的好处可测试性new SysUser().UpdateUserRoles(…)可以直接测不用跑集成测试。变更隔离以后想加一条规则“不能移除最后一个角色”只需要改实体方法服务层纹丝不动。事务安全所有改动都在同一个工作单元里增删要么一起成功要么一起失败。代码阅读流畅读服务层时不会被业务细节干扰读实体层时不用担心事务提交。一些可以讨论的改进点返回实体 vs 返回 DTO服务层直接返回SysUser实体如果上层不小心修改了它且没有再次提交可能引发隐蔽 bug。更稳妥的做法是返回一个UserRoleDto或者用AsNoTracking()断开跟踪。并发问题两个请求同时更新同一个用户的角色后提交的会覆盖前一个。可以在实体上加一个ConcurrencyStamp字段更新时检查乐观锁。大集合的性能如果用户角色数量巨大比如几千个RemoveAll用Contains判断会有 O(n*m) 复杂度。但角色通常不会那么多日常场景足够。最后代码写得干净不是因为用了什么花哨的设计模式而是因为它做对了一件事把不同职责放在不同的层次里。服务层就干服务层的活实体层就干实体层的活谁也不越界。下次你写更新用户角色的功能时不妨也试试这样拆服务层只做管道实体层做决策。你会发现代码变得更容易读懂也更容易改。