使用HttpPatch不破坏领域模型封装的解决方案(ASP.NET Core)
这个问题我之前在做DDD项目的时候也碰到过——JsonPatchDocument默认靠反射直接修改属性,完全绕开了领域模型的业务方法,把我们精心设计的验证逻辑和领域事件都给跳过了,确实头疼。下面给你几个实用的解决方案,你可以根据项目情况选择:
方案一:自定义JsonPatch扩展,映射到领域方法
最直接的方式是给JsonPatchDocument<Customer>写个扩展方法,让Patch操作里的路径对应到领域模型的业务方法,而不是直接改属性。比如针对你的Customer模型:
先创建扩展类:
public static class CustomerPatchExtensions { public static void ApplyToCustomer(this JsonPatchDocument<Customer> patchDoc, Customer customer) { foreach (var op in patchDoc.Operations) { switch (op.path.Trim().ToLower()) { case "/firstname": case "/lastname": // 收集姓名相关的最新值(如果只改其中一个字段,保留另一个原字段值) var newFirstName = op.path == "/firstname" ? op.value.ToString() : customer.Firstname; var newLastName = op.path == "/lastname" ? op.value.ToString() : customer.Lastname; // 调用领域方法执行验证和修改 customer.UpdateName(newFirstName, newLastName); break; case "/birthdate": if (DateTime.TryParse(op.value.ToString(), out var newBirthDate)) { customer.UpdateBirthDate(newBirthDate); } else { throw new InvalidOperationException("Invalid birth date format"); } break; default: throw new NotSupportedOperationException($"Patch operation for path {op.path} is not allowed"); } } } }
然后在Controller里替换默认的ApplyTo:
[HttpPatch("{id}")] public async Task<IActionResult> Update(Guid id, [FromBody] JsonPatchDocument<Customer> patchDoc) { var customer = await _customerRepo.GetByIdAsync(id); if (customer == null) return NotFound(); try { patchDoc.ApplyToCustomer(customer); await _customerRepo.SaveChangesAsync(); return Ok(customer); } catch (Exception ex) { return BadRequest(ex.Message); } }
这种方式完全遵循领域模型的封装,所有修改都必须经过业务方法,保证验证和领域事件正常触发。缺点是需要为每个领域模型写对应扩展,适合模型不多的场景。
方案二:自定义Patch文档类型,配合ModelBinder
如果项目里领域模型很多,可以搞个通用的Patch文档类型,让客户端直接指定要调用的业务方法和参数,再通过ModelBinder解析:
先定义自定义Patch文档:
public class DomainMethodPatch { public string MethodName { get; set; } public object[] Args { get; set; } }
然后写ModelBinder解析请求体:
public class DomainPatchBinder : IModelBinder { public Task BindModelAsync(ModelBindingContext context) { if (context == null) throw new ArgumentNullException(nameof(context)); var req = context.HttpContext.Request; using var reader = new StreamReader(req.Body); var json = reader.ReadToEnd(); var patchOps = JsonSerializer.Deserialize<List<DomainMethodPatch>>(json); context.Result = ModelBindingResult.Success(patchOps); return Task.CompletedTask; } }
Controller里这样接收并调用方法:
[HttpPatch("{id}")] public async Task<IActionResult> Update( Guid id, [ModelBinder(typeof(DomainPatchBinder))] List<DomainMethodPatch> patches) { var customer = await _customerRepo.GetByIdAsync(id); if (customer == null) return NotFound(); foreach (var patch in patches) { var method = typeof(Customer).GetMethod( patch.MethodName, BindingFlags.Public | BindingFlags.Instance); if (method == null) { return BadRequest($"Method {patch.MethodName} doesn't exist on Customer"); } try { method.Invoke(customer, patch.Args); } catch (Exception ex) { return BadRequest($"Failed to run {patch.MethodName}: {ex.InnerException?.Message ?? ex.Message}"); } } await _customerRepo.SaveChangesAsync(); return Ok(customer); }
请求体可以写成这样:
[ { "MethodName": "UpdateName", "Args": ["Jane", "Smith"] }, { "MethodName": "UpdateBirthDate", "Args": ["1985-05-15"] } ]
这种方式更通用,不用为每个模型写扩展,但需要客户端配合,而且反射调用要注意参数匹配和异常处理。
方案三:用MediatR将Patch转化为领域命令
如果你的项目用了MediatR,把Patch操作转化为对应的领域命令是更符合CQRS思路的做法。比如先定义命令:
public record UpdateCustomerNameCommand(Guid CustomerId, string FirstName, string LastName) : IRequest; public record UpdateCustomerBirthDateCommand(Guid CustomerId, DateTime BirthDate) : IRequest;
然后写命令处理程序(这里只示例UpdateName的):
public class UpdateCustomerNameHandler : IRequestHandler<UpdateCustomerNameCommand> { private readonly ICustomerRepository _repo; public UpdateCustomerNameHandler(ICustomerRepository repo) { _repo = repo; } public async Task Handle(UpdateCustomerNameCommand request, CancellationToken ct) { var customer = await _repo.GetByIdAsync(request.CustomerId, ct); customer.UpdateName(request.FirstName, request.LastName); await _repo.SaveChangesAsync(ct); } }
最后Controller里根据Patch路径发送对应命令:
[HttpPatch("{id}")] public async Task<IActionResult> Update(Guid id, [FromBody] JsonPatchDocument<Customer> patchDoc) { var customer = await _customerRepo.GetByIdAsync(id); if (customer == null) return NotFound(); foreach (var op in patchDoc.Operations) { switch (op.path.ToLower()) { case "/firstname": case "/lastname": var firstName = op.path == "/firstname" ? op.value.ToString() : customer.Firstname; var lastName = op.path == "/lastname" ? op.value.ToString() : customer.Lastname; await _mediator.Send(new UpdateCustomerNameCommand(id, firstName, lastName)); break; case "/birthdate": if (DateTime.TryParse(op.value.ToString(), out var birthDate)) { await _mediator.Send(new UpdateCustomerBirthDateCommand(id, birthDate)); } else { return BadRequest("Invalid birth date format"); } break; default: return BadRequest($"Unsupported patch path: {op.path}"); } } return Ok(await _customerRepo.GetByIdAsync(id)); }
这种方式把业务逻辑完全放到命令处理程序里,Controller只做路由和请求解析,符合单一职责,也方便测试和扩展。
总结一下,最推荐方案一或者方案三,这两种都能很好地守住领域模型的封装边界,让所有修改都经过业务逻辑的校验。
内容的提问来源于stack exchange,提问作者Iris

