DDD中需访问基础设施层的业务逻辑分层最佳实践
核心结论
你当前在应用层直接调用仓储做校验的写法不合理,最优方案是通过领域服务封装需要访问持久层的校验逻辑,既不破坏DDD分层依赖规则,也能避免业务逻辑泄漏、从根源上防止非法领域对象生成。
先澄清几个常见认知误区
- 误区1:领域层不能调用仓储。DDD的依赖规则是领域层不依赖基础设施层的具体实现,只要在领域层定义仓储的抽象接口(比如
ICountryCodeRepository),由基础设施层实现这个接口,依赖方向永远是基础设施指向领域,完全不违反分层规则。 - 误区2:值对象里可以调用仓储。非常不推荐。值对象的设计原则是无副作用、纯内存运算,创建时的校验应该是同步、无IO的格式/规则校验(比如alpha-3编码必须是3位大写字母这类固定规则),一旦引入IO依赖,值对象就失去了可测试性、轻量性,本质是把领域服务的职责强行塞给了值对象。
- 误区3:少量逻辑泄漏到应用层无所谓。这只是无法优雅收敛逻辑时的妥协方案,长期运行必踩坑:只要有一个用例漏写校验,就会生成非法领域对象,脏数据入库后的修复成本极高。只要能通过合理分层做到逻辑内聚,完全没必要承担这类风险。
具体落地方案
把需要访问持久层的校验逻辑收敛到领域服务中,应用层只负责流程编排,不编写任何业务校验规则:
第一步:拆分值对象的校验职责
值对象CountryAlpha3Code的create方法只做纯内存的格式校验,不引入任何外部依赖:
// 领域层的值对象,无外部IO依赖 public static create(value: string): Result<CountryAlpha3Code> { // 仅做固定规则校验:非空、长度3、全大写字母、无不可见字符 if (!value || value.length !== 3 || !/^[A-Z]{3}$/.test(value)) { return Result.fail('国家编码必须是3位大写alpha-3格式'); } return Result.ok(new CountryAlpha3Code({ value })); }
第二步:领域层定义仓储接口+校验领域服务
在领域层目录下定义仓储的抽象接口,再编写专门的领域服务承载持久化相关的校验逻辑:
// 领域层定义的仓储抽象接口,不依赖任何基础设施实现 export interface ICountryCodeRepository { existsByAlpha3Code(code: CountryAlpha3Code): Promise<boolean>; } // 领域层的校验领域服务,仅依赖抽象仓储接口 export class CountryCodeValidationService { constructor(private readonly countryCodeRepo: ICountryCodeRepository) {} async validateCodeExists(code: CountryAlpha3Code): Promise<Result<void>> { const exists = await this.countryCodeRepo.existsByAlpha3Code(code); if (!exists) { return Result.fail(`国家编码${code.value}不存在`); } return Result.ok(); } }
第三步:应用层仅做流程编排
应用层注入领域服务、仓储,只负责按顺序调用组件、处理DTO转换、返回结果,不编写业务校验规则:
export default class UseCaseClass implements IUseCaseInterface { constructor( private readonly _repo: IRepo, // 注入领域层定义的接口,由基础设施层做实现注入 private readonly countryValidationService: CountryCodeValidationService ) {} async execute(request: dto): Promise<dtoResponse> { // 1. 先执行纯内存的VO/实体格式校验 const someOtherKeyorError = KeyEntity.create(request.someOtherDtoKey); const countryOrError = CountryAlpha3Code.create(request.country); const dtoResult = Result.combine([someOtherKeyorError, countryOrError]); if (dtoResult.isFailure) { return left(Result.fail<void>(dtoResult.error)) as dtoResponse; } const countryCode = countryOrError.getValue(); try { // 2. 调用领域服务执行需要访问持久层的校验 const validationResult = await this.countryValidationService.validateCodeExists(countryCode); if (validationResult.isFailure) { return left(new ValidCountryCodeError.CountryCodeNotValid(countryCode.value)) as dtoResponse; } // 3. 所有校验通过后再创建实体、持久化 const dataOrError = MyEntity.create({ ...request, key: someOtherKeyorError.city.getValue(), country: countryCode, }); const commandResult = await this._repo.save(dataOrError.getValue()); return right(Result.ok<any>(commandResult)); } catch (err: any) { return left(new AppError.UnexpectedError(err)) as dtoResponse; } } }
其余疑问的针对性解答
- 关于用领域事件实现同步校验:完全没必要。领域事件天生适合异步、最终一致的场景(比如发通知、生成统计数据),同步校验用领域服务是最直接、可测试性最强、调试成本最低的方案,硬用事件实现同步只会增加不必要的复杂度。
- 关于如何避免漏校验:可以把实体的构造函数设为私有,仅暴露经过完整校验的静态工厂方法,甚至把校验逻辑内置到工厂方法中,强制所有创建实体的路径都必须走校验流程,从根源上避免非法对象生成。
- 高并发场景兜底:如果是邮箱全局唯一性、库存扣减这类强一致要求的场景,不要只靠应用层/领域层的校验,必须在数据库层加唯一索引、乐观锁/悲观锁做兜底,避免并发场景下的脏数据。
内容的提问来源于stack exchange,提问作者Mr X
相关产品推荐
相关产品推荐

