从贫血领域模型重构为充血领域模型


贫血领域模型是一个没有任何行为、只有数据属性的领域模型。

缺血(贫血、失血)领域模型在简单的应用程序中工作得很好,但如果您有丰富的业务逻辑,它们就很难维护和发展。
业务逻辑和规则的重要部分最终分散在整个应用程序中。它降低了内聚性和可重用性,并使添加新功能变得更加困难。

充血模型试图通过封装尽可能多的业务逻辑来解决这个问题。
但如何设计充血领域模型呢?
这是将业务逻辑移入领域并完善领域模型的永无止境的过程。

让我们看看如何从贫血领域模型重构为充血领域模型。

失血模型代码:

public async Task<Result> Handle(SendInvitationCommand command)
{
    var member = await _memberRepository.GetByIdAsync(command.MemberId);

    var gathering = await _gatheringRepository.GetByIdAsync(command.GatheringId);

    if (member is null || gathering is null)
    {
        return Result.Failure(Error.NullValue);
    }

    if (gathering.Creator.Id == member.Id)
    {
        throw new Exception("Can't send invitation to the creator.");
    }

    if (gathering.ScheduledAtUtc < DateTime.UtcNow)
    {
        throw new Exception(
"Can't send invitation for the past.");
    }

    var invitation = new Invitation
    {
        Id = Guid.NewGuid(),
        Member = member,
        Gathering = gathering,
        Status = InvitationStatus.Pending,
        CreatedOnUtc = DateTime.UtcNow
    };

    gathering.Invitations.Add(invitation);

    _invitationRepository.Add(invitation);

    await _unitOfWork.SaveChangesAsync();

    await _emailService.SendInvitationSentEmailAsync(member, gathering);

    return Result.Success();
}


将业务逻辑移至域中
让我们从 "Invitation  "实体开始,为它定义一个构造函数。我可以通过在构造函数中设置 Status 和 CreatedOnUtc 属性来简化设计。我还将使其具有内部性,以便只能在域内创建邀请函实例。

public sealed class Invitation
{
    internal Invitation(Guid id, Gathering gathering, Member member)
    {
        Id = id;
        Member = member;
        Gathering = gathering;
        Status = InvitationStatus.Pending;
        CreatedOnUtc = DateTime.Now;
    }

    // Data properties omitted for brevity.
}

我之所以将 "Invitation  "构造函数设置为内部构造函数,是为了在 "Gathering  "实体上引入一个新方法。让我们称它为 SendInvitation,它将负责实例化一个新的邀请函实例并将其添加到内部集合中。

目前,Gathering.Invitations 集合是公开的,这意味着任何人都可以获取引用并修改集合。

我们不希望出现这种情况,因此我们可以将该集合封装在一个私有字段后面。这样,管理 _invitations 集合的责任就转移到了 Gathering 类。

下面是 Gathering 类现在的样子:

public sealed class Gathering
{
    private readonly List<Invitation> _invitations;

    // Other members omitted for brevity.

    public void SendInvitation(Member member)
    {
        var invitation = new Invitation(Guid.NewGuid(), gathering, member);

        _invitations.Add(invitation);
    }
}

将验证规则移入领域
接下来我们可以把验证规则移到 SendInvitation 方法中,进一步丰富领域模型。

不幸的是,这仍然是一种糟糕的做法,因为当验证失败时,会抛出 "预期 "异常。如果要使用异常来执行验证规则,至少应该正确使用异常,并使用特定异常而不是通用异常。

不过,使用结果对象来表达验证错误会更好。

public sealed class Gathering
{
    // Other members omitted for brevity.

    public void SendInvitation(Member member)
    {
        if (gathering.Creator.Id == member.Id)
        {
            throw new Exception(
"Can't send invitation to the creator.");
        }

        if (gathering.ScheduledAtUtc < DateTime.UtcNow)
        {
            throw new Exception(
"Can't send invitation for the past.");
        }

        var invitation = new Invitation(Guid.NewGuid(), gathering, member);

        _invitations.Add(invitation);
    }
}

 
引入result 对象:

public sealed class Gathering
{
    // Other members omitted for brevity.

    public Result SendInvitation(Member member)
    {
        if (gathering.Creator.Id == member.Id)
        {
            return Result.Failure(DomainErrors.Gathering.InvitingCreator);
        }

        if (gathering.ScheduledAtUtc < DateTime.UtcNow)
        {
            return Result.Failure(DomainErrors.Gathering.AlreadyPassed);
        }

        var invitation = new Invitation(Guid.NewGuid(), gathering, member);

        _invitations.Add(invitation);

        return Result.Success();
    }
}


这种方法的好处是,我们可以为可能出现的域错误引入常量。域错误目录将作为域的文档,使域更具表现力。

最后,下面是经过上述修改后的一开始的处理方法:

public async Task<Result> Handle(SendInvitationCommand command)
{
    var member = await _memberRepository.GetByIdAsync(command.MemberId);

    var gathering = await _gatheringRepository.GetByIdAsync(command.GatheringId);

    if (member is null || gathering is null)
    {
        return Result.Failure(Error.NullValue);
    }

    var result = gathering.SendInvitation(member);

    if (result.IsFailure)
    {
        return Result.Failure(result.Errors);
    }

    await _unitOfWork.SaveChangesAsync();

    await _emailService.SendInvitationSentEmailAsync(member, gathering);

    return Result.Success();
}

如果你仔细观察一下 "Handle  "方法,就会发现它在做两件事:

  1. 将更改保存到数据库
  2. 发送电子邮件

这意味着它不是原子性的。

数据库事务有可能完成,而电子邮件发送有可能失败。此外,发送电子邮件会减慢方法的运行速度,从而影响性能。

如何使该方法具有原子性?

在后台发送电子邮件。这对我们的业务逻辑并不重要,所以这样做是安全的。

用领域事件表达副作用
您可以使用域事件来表达在您的域中发生了一些系统中其他组件可能感兴趣的事情。

我经常使用域事件来触发后台操作,如发送通知或电子邮件。

让我们来介绍一下 InvitationSentDomainEvent:

public record InvitationSentDomainEvent(Invitation Invitation) : IDomainEvent;

我们将在 SendInvitation 方法中引发该域事件:

public sealed class Gathering
{
    private readonly List<Invitation> _invitations;

    // Other members omitted for brevity.

    public Result SendInvitation(Member member)
    {
        if (gathering.Creator.Id == member.Id)
        {
            return Result.Failure(DomainErrors.Gathering.InvitingCreator);
        }

        if (gathering.ScheduledAtUtc < DateTime.UtcNow)
        {
            return Result.Failure(DomainErrors.Gathering.AlreadyPassed);
        }

        var invitation = new Invitation(Guid.NewGuid(), gathering, member);

        _invitations.Add(invitation);

        Raise(new InvitationSentDomainEvent(invitation));

        return Result.Success();
    }
}

目的是删除Handle 方法中负责发送电子邮件的代码:

public async Task<Result> Handle(SendInvitationCommand command)
{
    var member = await _memberRepository.GetByIdAsync(command.MemberId);

    var gathering = await _gatheringRepository.GetByIdAsync(command.GatheringId);

    if (member is null || gathering is null)
    {
        return Result.Failure(Error.NullValue);
    }

    var result = gathering.SendInvitation(member);

    if (result.IsFailure)
    {
        return Result.Failure(result.Errors);
    }

    await _unitOfWork.SaveChangesAsync();

    return Result.Success();
}

我们只需要关注业务逻辑的执行以及将任何更改持久化到数据库中。这些更改的一部分也将是系统在后台发布的域事件。

当然,我们需要为域事件提供相应的处理程序:

public sealed class InvitationSentDomainEventHandler
    : IDomainEventHandler<InvitationSentDomainEvent>
{
    private readonly IEmailService _emailService;

    public InvitationSentDomainEventHandler(IEmailService emailService)
    {
        _emailService = emailService;
    }

    public async Task Handle(InvitationSentDomainEvent domainEvent)
    {
        await _emailService.SendInvitationSentEmailAsync(
            domainEvent.Invitation.Member,
            domainEvent.Invitation.Gathering);
    }
}

我们做到了两件事:

  • 处理 SendInvitationCommand 现在是原子式的了
  • 电子邮件在后台发送,如果出现错误,可以安全地重试

总结
设计丰富的充血领域模型是一个循序渐进的过程,你可以随着时间的推移慢慢发展领域模型。

第一步可以让你的领域模型更具防御性:

  • 使用内部关键字隐藏构造函数
  • 封装集合访问

这样做的好处是,你的领域模型将有一个细粒度的公共应用程序接口(方法),作为执行业务逻辑的入口点。

当行为被封装在一个类中时,就很容易进行测试,而无需模拟外部依赖关系。

您可以引发领域事件,通知系统发生了重要事件,任何感兴趣的组件都可以订阅该领域事件。通过领域事件,您可以开发一个解耦系统,专注于核心领域逻辑,而不必担心副作用。

不过,这并不意味着每个系统都需要充血领域模型。

您应该实事求是,决定什么时候值得这么做。