从贫血模型到DDD的重构


我们将重构一个简单的问题跟踪应用程序,通过典型的层隔离,根据领域驱动的战术设计模式进行建模。
这个问题跟踪应用程序非常简单。您可以使用它执行多项业务操作 - 全部通过REST API,并且所有操作都完全由集成测试覆盖(请参阅此处的测试)。您可以:

  • 创造一个新问题
  • 得到所有问题
  • 评论一个问题
  • 改变问题状态

某些操作具有验证规则:
  • 可用的状态转换是:new - > in_progress,in_progress - > done
  • 问题的注释只能添加到状态为new或in_progress的问题中

第一个实施 - 贫血模型
我们的第一个实现是非常常见的。我们有4个包负责我们的应用程序的给定层。所以我们有一个带有IssueController 的控制器包,我们处理所有的http请求。我们的问题还有一个模型包,它是JPA实体,以及IssueComment。最后,有服务类IssueService,以及与存储库包中的实体相关的两个存储库。
典型的请求调用往返非常简单:

  • 在控制器中我们处理http请求,从url或从请求内容中获取参数并将它们传递给服务
  • 服务是我们应用程序的核心 - 所有逻辑都在这里。我们使用存储库加载实体,执行一些业务操作,并在修改后返回对象(如果需要)
  • Controller在调用服务后检索域对象,并将其(如果有)转换为json

服务可以更改问题状态:

public void update(Long issueId, IssueStatus newStatus) {
    Issue issue = issueRepository.findOne(issueId);
    if (issue.getStatus() == DONE && newStatus == NEW || issue.getStatus() == NEW && newStatus == DONE) {
        throw new RuntimeException(String.format("Cannot change issue status from %s to %s", issue.getStatus(), newStatus));
    }
    issue.setStatus(newStatus);
}

你可以在这里找到所有的服务操作,控制器调用它看起来像这样。
让我们尝试将此应用程序重构为领域驱动设计。

重构实体 - 丰富其行为

根据DDD概念,我们需要考虑我们的域模型及其不变量,识别实体,值对象以及聚合根。我们的实体候选人是Issue和IssueComment,因为这些对象是我们系统中需要识别的对象。事实上,IssueComment不必是一个实体 - 我们不使用它的id,也不需要区分这些对象。我们将其建模为具有id的JPA实体,以简化ORM映射。所以在DDD世界中,“问题Issue”成为唯一的实体,也成为聚合根 - 它包含对注释的引用,但在修改时我们将它们视为一个单元。
如果我们知道我们的聚合根,那么很容易开始重构。所有改变聚合状态的操作都需要在其中。因此,我们需要改变状态并将注释方法从服务添加到“问题Issue”模型中。

@Entity
public class Issue {
    // some mapping
    public void changeStatusTo(IssueStatus newStatus) {
        if (this.status == IssueStatus.DONE && newStatus == IssueStatus.NEW || this.status == IssueStatus.NEW && newStatus == IssueStatus.DONE) {
            throw new RuntimeException(String.format(
"Cannot change issue status from %s to %s", this.status, newStatus));
        }
        this.status = newStatus;
    }

    public void addComment(String comment) {
        if (status == IssueStatus.DONE) {
            throw new RuntimeException(
"Cannot add comment to done issue");
        }
        comments.add(new IssueComment(comment));
    }
}

当然要实现这一点,我们需要稍微调整一下hibernate映射。我们改变了评论字段:

@Transient
private List comments = new ArrayList<>();

改为:

@OneToMany(cascade = CascadeType.MERGE)
private List comments;

我们使用延迟加载和级联,替代另外通过存储库加载,由于这个原因,我们的聚合可以修改其不变量(字段),而无需加载任何其他资源。

此外,所有可用的操作现在都在问题Issue类中,它至少有3个优点:

  • 所有验证逻辑现在都可以放在发生更改的对象中
  • 我们能一下子看到了“问题Issue”API - 这使我们能够非常快速地了解从业务角度可以解决的问题
  • 没有人可以引入我们对象的不一致状态,因为没有公共修饰符(如setStatus)可用

另一个不那么明显的好处是聚合内部的操作只能修改其不变量。让我们假设一个需求,需要对问题Issue发表评论,如果采取在服务中修改实体的办法,我们只要将UserRepository注入到IssueService中,添加评论后我们更改User模型并保存它。在DDD模型中没有办法做到这一点 - 我们没有任何机制在Issue实体内来加载和修改用户User模型。

重构服务 - 简化
由于业务逻辑从服务转移到实体,现在简化了服务。它只做3件事:

  • 从存储库加载聚合
  • 在加载的聚合上调用方法来修改它
  • 保存修改后的对象

服务的更新状态方法示例是:
public void update(String issueId, IssueStatus newStatus) {
    Issue issue = issueRepository.findBy(IssueId.from(issueId));
    issue.changeStatusTo(newStatus);
    issueRepository.save(issue);
}

所有的业务逻辑都归于问题Issue模型。如果服务没有执行其他操作,比如在其他聚合根上发送事件或操作,我们可以做更多。摆脱服务并在控制器中完成所有逻辑。

重构存储库 - 独立于具体实现
为了与DDD存储库概念保持一致,我们需要对其进行一些重构。在贫血模型中,我们使用了2个存储库 - 一个用于Issue,第二个用于IssueComment。都通过创建扩展CrudRepository的接口,使用spring-data存储库创建存储库。这种方案有一些缺点。
首先,它直接与具体实现耦合。如果我们想要更改它(例如测试时使用内存保存),我们需要做一些模拟或提供一些自定义bean,其中包含我们在CrudRepository中实现的所有方法。
其次,使用spring-data存储库,我们得到了许多我们不想要的方法的默认实现,比如count,exists或deleteAll。
因此,我们将存储库重构为一个满足我们的希望只拥有一些方法的接口。

public interface IssueRepository {
  List findAll();
  Issue save(Issue issue);
  Issue findBy(IssueId issueId);
}

此外,您可以看到现在使用IssueId值对象而不是Long来查找问题。这样我们就避免了从不同的实体提供一些不同的Long的错误。
此接口的实现使用下面的spring data存储库,但当然您可以根据使用情况轻松地将其替换为您想要的任何内容。

重构包
最后值得一提的是在从贫血模型迁移到ddd时我们的应用程序的重新打包。我们从4个分组开始分组。在DDD模型中,我们有3个包:应用程序,域和基础结构。

  • 域包含我们的实体和值对象以及存储库接口(我们在这里也有IssueIdSequenceGenerator,但它是另一个我们将在另一篇文章中描述的故事)。所有的业务逻辑都属于这里。
  • 应用程序具有控制器和与从json转换为模型和返回相关的所有内容。它还包含应用程序服务(我们的IssueService)。应用程序使用域对象来检测它们(加载聚合,调用业务操作)。
  • 最后一个包是基础结构,它包含域中使用的所有接口的实现,以及内部用于提供此实现的所有类(例如CrudIssueRepository)。

多亏了这样的重新打包,我们在定位新内容的位置方面没有任何问题,这会在新的业务需求到来时出现。问题可能出现在哪里放置新类,例如,如果我们想要引入用户user的模块'。我们是否应该在应用程序,域和基础架构中添加新软件包,并在每个软件包下放置当前问题模型“模块”内部的问题包中,并创建新用户模块?
当然不是。根据DDD概念,用户'模块'是一个不同的有界上下文,所以我们应该创建单独的模块(maven one)或者至少创建2个不同的根包:

DDD值得做吗?
我们刚刚经历了从贫血模型DDD的迁移过程,正如您所看到的,它并不那么简单。在更大的应用程序中,它可能非常困难,甚至也许是不可能实现的。值得做吗?当然答案是:这取决于:
DDD不是银弹。对于简单的CRUD应用程序或具有很少业务逻辑的应用程序,它可能是一种过度杀伤力。一旦您的应用程序变得非常大,DDD值得考虑。再次指出使用DDD可以获得的主要好处:

  • 通过有意义的方法更好地表达域对象中的业务逻辑;
  • 域对象通过仅操作其内部来封闭事务边界,这简化了业务逻辑实现,
  • 非常简单的包结构
  • 更好地区分领域和持久性机制