我们将重构一个简单的问题跟踪应用程序,通过典型的层隔离,根据领域驱动的战术设计模式进行建模。
这个问题跟踪应用程序非常简单。您可以使用它执行多项业务操作 - 全部通过REST API,并且所有操作都完全由集成测试覆盖(请参阅 此处的 测试)。您可以:
某些操作具有验证规则:
第一个实施 - 贫血模型
我们的第一个实现 是非常常见的。我们有4个包负责我们的应用程序的给定层。所以我们有一个带有IssueController 的控制器包,我们处理所有的http请求。我们的问题还有一个模型包,它是JPA实体,以及IssueComment。最后,有服务类IssueService,以及与存储库包中的实体相关的两个存储库。
典型的请求调用往返非常简单:
服务可以更改问题状态:
<b>public</b> <b>void</b> update(Long issueId, IssueStatus newStatus) { Issue issue = issueRepository.findOne(issueId); <b>if</b> (issue.getStatus() == DONE && newStatus == NEW || issue.getStatus() == NEW && newStatus == DONE) { <b>throw</b> <b>new</b> RuntimeException(String.format(<font>"Cannot change issue status from %s to %s"</font><font>, issue.getStatus(), newStatus)); } issue.setStatus(newStatus); } </font>
你可以 在这里 找到所有的服务操作,控制器调用它 看起来像这样。
让我们尝试将此应用程序重构为领域驱动设计。
重构实体 - 丰富其行为
根据DDD概念,我们需要考虑我们的域模型及其不变量,识别 实体,值对象 以及 聚合根。 我们的实体候选人是Issue和IssueComment,因为这些对象是我们系统中需要识别的对象。事实上,IssueComment不必是一个实体 - 我们不使用它的id,也不需要区分这些对象。我们将其建模为具有id的JPA实体,以简化ORM映射。所以在DDD世界中,“问题Issue”成为唯一的实体,也成为聚合根 - 它包含对注释的引用,但在修改时我们将它们视为一个单元。
如果我们知道我们的聚合根,那么很容易开始重构。所有改变聚合状态的操作都需要在其中。因此,我们需要改变状态并将注释方法从服务添加到“问题Issue”模型中。
@Entity <b>public</b> <b>class</b> Issue { <font><i>// some mapping</i></font><font> <b>public</b> <b>void</b> changeStatusTo(IssueStatus newStatus) { <b>if</b> (<b>this</b>.status == IssueStatus.DONE && newStatus == IssueStatus.NEW || <b>this</b>.status == IssueStatus.NEW && newStatus == IssueStatus.DONE) { <b>throw</b> <b>new</b> RuntimeException(String.format(</font><font>"Cannot change issue status from %s to %s"</font><font>, <b>this</b>.status, newStatus)); } <b>this</b>.status = newStatus; } <b>public</b> <b>void</b> addComment(String comment) { <b>if</b> (status == IssueStatus.DONE) { <b>throw</b> <b>new</b> RuntimeException(</font><font>"Cannot add comment to done issue"</font><font>); } comments.add(<b>new</b> IssueComment(comment)); } } </font>
当然要实现这一点,我们需要稍微调整一下hibernate映射。我们改变了评论字段:
@Transient <b>private</b> List comments = <b>new</b> ArrayList<>();
改为:
@OneToMany(cascade = CascadeType.MERGE) <b>private</b> List comments;
我们使用延迟加载和级联,替代另外通过存储库加载,由于这个原因,我们的聚合可以修改其不变量(字段),而无需加载任何其他资源。
此外,所有可用的操作现在都在问题Issue类中,它至少有3个优点:
另一个不那么明显的好处是聚合内部的操作只能修改其不变量。让我们假设一个需求,需要对问题Issue发表评论,如果采取在服务中修改实体的办法,我们只要将UserRepository注入到IssueService中,添加评论后我们更改User模型并保存它。在DDD模型中没有办法做到这一点 - 我们没有任何机制在Issue实体内来加载和修改用户User模型。
重构服务 - 简化
由于业务逻辑从服务转移到实体,现在简化了服务。它只做3件事:
服务的更新状态方法示例是:
<b>public</b> <b>void</b> 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。
因此,我们将存储库重构为一个满足我们的希望只拥有一些方法的接口。
<b>public</b> <b>interface</b> IssueRepository { List findAll(); Issue save(Issue issue); Issue findBy(IssueId issueId); }
此外,您可以看到现在使用IssueId值对象而不是Long来查找问题。这样我们就避免了从不同的实体提供一些不同的Long的错误。
此接口的实现使用下面的spring data存储库,但当然您可以根据使用情况轻松地将其替换为您想要的任何内容。
重构包
最后值得一提的是在从贫血模型迁移到ddd时我们的应用程序的重新打包。我们从4个分组开始分组。在DDD模型中,我们有3个包:应用程序,域和基础结构。
多亏了这样的重新打包,我们在定位新内容的位置方面没有任何问题,这会在新的业务需求到来时出现。问题可能出现在哪里放置新类,例如,如果我们想要引入用户user的模块'。我们是否应该在应用程序,域和基础架构中添加新软件包,并在每个软件包下放置当前问题模型“模块”内部的问题包中,并创建新用户模块?
当然不是。根据DDD概念,用户'模块'是一个不同的 有界上下文, 所以我们应该创建单独的模块(maven one)或者至少创建2个不同的根包:
DDD值得做吗?
我们刚刚经历了从 贫血模型 到 DDD的 迁移过程,正如您所看到的,它并不那么简单。在更大的应用程序中,它可能非常困难,甚至也许是不可能实现的。值得做吗?当然答案是:这取决于:
DDD不是银弹。对于简单的CRUD应用程序或具有很少业务逻辑的应用程序,它可能是一种过度杀伤力。一旦您的应用程序变得非常大,DDD值得考虑。再次指出使用DDD可以获得的主要好处: