一个全面的领域驱动设计示例,包含问题空间战略分析和各种战术模式

19-04-04 banq
                   

这是一个由实际业务需求驱动的库的项目。我们使用与领域驱动设计,行为驱动开发,事件风暴,用户故事映射密切相关的技术。

领域描述

这是一个图书馆借书案例:

一个公共图书馆能让读者在各个图书馆分支机构借阅书籍。在任何给定的时间点,只有一位顾客借阅可用的一本书籍。

书籍是流通自由借阅的或有限制性的借阅,可以有检索或使用费。限制的书籍只能由研究员赞助人借阅。常规顾客在任何给定时刻限制为五次借阅,而研究人员的借阅人可以无限次借阅。

open-ended状态书籍表示有效状态,借阅者可以借阅;书籍的closed-ended 状态在借阅者发出借阅请求后的固定天数内如果未完成借阅,那么书籍这种状态将过期,这种检查是在一天开始时通过查看即将到期保留的每日表格来完成的。只有研究人员可以请求修改open-ended的借阅时间。

如果在同一个图书馆分支机构试图暂停,任何在图书馆分支机构进行两次以上逾期结账的顾客都会被拒绝。一本书可以借出长达60天。通过查看具有逾期结账的每日表格来检查逾期结账。

借阅者通过查看借阅人资料与他当前的自己借阅,可进行结账等进行交互。赞助人资料看起来像每日工作表,但信息仅限于一位顾客,不只是每天信息。目前,顾客可以看到当前借阅(未取消或过期)和当前结账(包括逾期)。此外,他能够借阅一本书并取消暂停。

通过查看具有逾期结账的每日表格来检查逾期结账。一个借阅人人究竟知道哪些书可以借出?图书馆有书籍目录列表,其中书籍与其特定实例一起被添加。只有当书籍目录中已存在具有匹配ISBN的书籍时,才能添加书籍的特定书籍实例。图书必须具有非空标题和价格。在添加实例时,我们决定它是可流通的还是需要限制借阅的。(例如,有一本书由作者签名,我们希望设置为受限制借阅的)

流程发现

我们开始的第一件事是在Big Picture EventStorming的帮助下进行领域浏览。描述登陆了我们的虚拟墙:EventStorming会话带来了许多发现,使用便签建模: 在会话期间,我们发现了以下定义:

项目结构和架构

在最开始,不要使项目过于复杂,我们决定将每个有界上下文分配给一个单独的包,这意味着系统是一个模块化的整体。但是,将上下文放入maven模块或最终放入微服务中没有障碍。

有界的上下文应该引入架构意义上的自治。因此,封装上下文的每个模块都有自己的本地架构,与领域问题复杂性相对应。在上下文中,我们确定了真正的业务逻辑(借阅),我们引入了一个领域模型,它是一个简化的(为了项目的目的)现实的抽象,事件风暴期间如果发现没有任何复杂领域逻辑,我们应用类似CRUD的本地架构实现。

六边形架构可以让我们将领域和应用程序逻辑以及与框架(和基础结构)分开。我们用这种方法获得了什么好处?

首先,我们可以对应用程序的最重要部分进行单元测试 - 业务逻辑 - 通常不需要任何依赖。

其次,我们为自己创造了调整基础架构层的机会,而无需担心破坏核心功能。

在基础架构层,我们使用Spring Framework作为最成熟和最强大的应用程序框架,具有令人难以置信的测试支持。

正如我们已经提到的,该架构是由Event Storming会议推动的。除了识别上下文及其复杂性之外,我们还可以决定将读写模型(CQRS)分开。举个例子,你可以看看Patron Profiles和Daily Sheets。

聚合

在Event Storming对话建模期间发现聚合之间是通过事件相互通信。但是,它们是实现立即或最终保持一致?这就存在争议?由于聚合通常决定业务边界,因此最终的一致性听起来是更好的选择,但软件中的选择绝不是无成本的。提供最终一致性需要一些基础结构工具,如消息代理或事件存储。这里我们选择强一致。

良好的架构是推迟所有重要决策的架构

这就是为什么以后我们可以轻松更改一致性模型,为每个选项提供测试,包括基于DomainEvents接口的基本实现,可以根据我们的需求和工具集进行调整。我们来看看以下示例:

行为驱动测试立即一致性代码:

def 'should synchronize PatronBooks, Book and DailySheet with events'() {
    given:
        bookRepository.save(book)
    and:
        patronBooksRepo.publish(patronCreated())
    when:
        patronBooksRepo.publish(placedOnHold(book))
    then:
        patronShouldBeFoundInDatabaseWithOneBookOnHold(patronId)
    and:
        bookReactedToPlacedOnHoldEvent()
    and:
        dailySheetIsUpdated()
}

boolean bookReactedToPlacedOnHoldEvent() {
    return bookRepository.findBy(book.bookId).get() instanceof BookOnHold
}

boolean dailySheetIsUpdated() {
    return new JdbcTemplate(datasource).query("select count(*) from holds_sheet s where s.hold_by_patron_id = ?",
            [patronId.patronId] as Object[],
            new ColumnMapRowMapper()).get(0)
            .get("COUNT(*)") == 1
}

请注意,这里我们只是在事件发布后立即从数据库中读取

事件总线的简单实现基于Spring应用程序事件:

@AllArgsConstructor
public class JustForwardDomainEventPublisher implements DomainEvents {

    private final ApplicationEventPublisher applicationEventPublisher;

    @Override
    public void publish(DomainEvent event) {
        applicationEventPublisher.publishEvent(event);
    }
}

最终一致性

def 'should synchronize PatronBooks, Book and DailySheet with events'() {
    given:
        bookRepository.save(book)
    and:
        patronBooksRepo.publish(patronCreated())
    when:
        patronBooksRepo.publish(placedOnHold(book))
    then:
        patronShouldBeFoundInDatabaseWithOneBookOnHold(patronId)
    and:
        bookReactedToPlacedOnHoldEvent()
    and:
        dailySheetIsUpdated()
}

void bookReactedToPlacedOnHoldEvent() {
    pollingConditions.eventually {
        assert bookRepository.findBy(book.bookId).get() instanceof BookOnHold
    }
}

void dailySheetIsUpdated() {
    pollingConditions.eventually {
        assert countOfHoldsInDailySheet() == 1
    }
}

请注意,测试看起来与前一个测试完全相同,但现在我们使用Groovy的 PollingConditions来执行异步功能测试

事件总线的示例实现如下:

@AllArgsConstructor
public class StoreAndForwardDomainEventPublisher implements DomainEvents {

    private final JustForwardDomainEventPublisher justForwardDomainEventPublisher;
    private final EventsStorage eventsStorage;

    @Override
    public void publish(DomainEvent event) {
        eventsStorage.save(event);
    }

    @Scheduled(fixedRate = 3000L)
    @Transactional
    public void publishAllPeriodically() {
        List<DomainEvent> domainEvents = eventsStorage.toPublish();
        domainEvents.forEach(justForwardDomainEventPublisher::publish);
        eventsStorage.published(domainEvents);
    }
}

通过事件进行通信并不是使两个聚合同时更改的唯一方法。如果存在需要满足的逻辑不适合单个聚合的边界的情况,则领域服务将介入操作,并显式调用(发送命令)聚合方法。

事件

谈到聚合间通信,我们必须记住事件会减少耦合,但不要完全删除它。因此,仅共享(发布)那些其他聚合存在和运行所必需的领域事件是非常重要的。否则,其他聚合可能会开始一些事件来执行它们不应执行的操作。这个问题的解决方案可能是领域事件和集成事件的区别。

ArchUnit

成功项目的主要组成部分之一是技术领导力,让团队朝着正确的方向前进。尽管如此,有一些工具可以支持团队保持代码清洁并保护架构,这样项目就不会成为泥浆的大球,因此开发和维护起来会很愉快。我们提出的第一个选项是ArchUnit - 一个Java架构测试工具。ArchUnit允许您编写架构的单元测试,以便始终与初始愿景保持一致。Maven模块也可以替代,但让我们关注前者。

在六边形体系结构方面,必须确保我们不混合不同级别的抽象(六边形级别):

@ArchTest
public static final ArchRule model_should_not_depend_on_infrastructure =
    noClasses()
        .that()
        .resideInAPackage("..model..")
        .should()
        .dependOnClassesThat()
        .resideInAPackage("..infrastructure..");

并且该框架不会影响域模型

@ArchTest
public static final ArchRule model_should_not_depend_on_spring =
    noClasses()
        .that()
        .resideInAPackage("..io.pillopl.library.lending..model..")
        .should()
        .dependOnClassesThat()
        .resideInAPackage("org.springframework..");

函数性思维

当您查看代码时,您可能会发现函数式编程的气味。虽然我们没有遵循干净的 FP,但我们尝试将业务流程视为管道或工作流,并通过以下概念利用函数风格。

不可变的对象

代表业务概念的每个类都是不可变的,这要归功于我们:

  • 提供完全封装和对象的状态保护,
  • 用于多线程访问的安全对象,
  • 控制所有副作用更清晰。

纯粹的函数

在设计级别事件风暴中发现的领域操将建模为纯函数,并以Java的函数接口的形式在域和应用程序层中声明它们。它们的实现作为具有副作用的普通方法放置在基础结构层中。这样,我们可以明确地遵循无处不在的语言的抽象,并保持这种抽象实现不可知。作为示例,您可以查看FindAvailableBook接口及其实现:

@FunctionalInterface 
public  interface  FindAvailableBook { Option < AvailableBook > findAvailableBookBy(BookId bookId); 
}

@AllArgsConstructor
class BookDatabaseRepository implements FindAvailableBook {

    private final JdbcTemplate jdbcTemplate;

    @Override
    public Option<AvailableBook> findAvailableBookBy(BookId bookId) {
        return Match(findBy(bookId)).of(
                Case($Some($(instanceOf(AvailableBook.class))), Option::of),
                Case($(), Option::none)
        );
    }  

    Option<Book> findBy(BookId bookId) {
        return findBookById(bookId)
                .map(BookDatabaseEntity::toDomainModel);
    }

    private Option<BookDatabaseEntity> findBookById(BookId bookId) {
        return Try
                .ofSupplier(() -> of(jdbcTemplate.queryForObject("SELECT b.* FROM book_database_entity b WHERE b.book_id = ?",
                                      new BeanPropertyRowMapper<>(BookDatabaseEntity.class), bookId.getBookId())))
                .getOrElse(none());
    }  
} 

类型系统

类型系统像我们在EventStorming建模过程中发现作为单独的类每个域对象的状态:AvailableBook,BookOnHold,CollectedBook。通过这种方法,我们提供了比具有Book基于枚举的状态管理的单个类更清晰的抽象。将逻辑移动到这些特定类将单一责任原则带到不同的级别。而且,我们不是在每个业务方法中检查不变量,而是将角色留给编译器。例如,请考虑以下情况:您只能暂停一本当前可用的图书。我们可以通过以下方式完成它:

public Either<BookHoldFailed, BookPlacedOnHoldEvents> placeOnHold(Book book) {
  if (book.status == AVAILABLE) {  
      ...
  }
}

但我们使用类型系统并声明以下签名的方法:

public  < BookHoldFailed,BookPlacedOnHoldEvents > placeOnHold(AvailableBook book){
       ... 
}

Monads

业务方法可能会有不同的结果。有人可能会返回一个值或者null,当出现意外情况时抛出异常,或者只是在不同情况下返回不同的对象。所有这些情况都是Java等面向对象语言的典型情况,但不适合函数风格。我们正在处理monads(Vavr提供的monadic容器)的这个问题:

当方法返回可选值时,我们使用Option monad:

Option<Book> findBy(BookId bookId) {
    ...
}

当方法可能返回两个可能值之一时,我们使用Eithermonad:

Either<BookHoldFailed, BookPlacedOnHoldEvents> placeOnHold(AvailableBook book) {
    ...
}

当可能发生异常时,我们使用Trymonad:

Try<Result> placeOnHold(@NonNull PlaceOnHoldCommand command) {
    ...
}

多亏了这一点,我们可以遵循函数式编程风格,但我们也丰富了我们的域语言,使我们的代码对客户更具可读性。

模式匹配

根据给定书籍对象的类型,我们经常需要执行不同的操作。可以选择if / else或switch / case语句系列,但模式匹配提供了最简洁和灵活性。使用下面的代码,我们可以检查对象的多种模式并访问它们的成分,因此我们的代码具有最小剂量的语言构造噪声:

private Book handleBookPlacedOnHold(Book book, BookPlacedOnHold bookPlacedOnHold) {
    return API.Match(book).of(
        Case($(instanceOf(AvailableBook.class)), availableBook -> availableBook.handle(bookPlacedOnHold)),
        Case($(instanceOf(BookOnHold.class)), bookOnHold -> raiseDuplicateHoldFoundEvent(bookOnHold, bookPlacedOnHold)),
        Case($(), () -> book)
    );
}

(No) ORM

不使用JPA,使用Spring Data JDBC。

我们希望更多地控制SQL查询,并尽量减少对象 - 关系阻抗不匹配。此外,由于相对较小的聚合,包含保护不变量所需的数据,我们不需要JPA延迟加载机制。

使用六边形体系结构,我们可以分离域和持久性模型并独立测试它们。

此外,我们还可以为不同的聚合引入不同的持久性策略。在这个项目中,我们使用普通的SQL查询和JdbcTemplate并使用一个名为Spring Data JDBC的新的非常有前途的项目,它没有前面提到的与JPA相关的开销。请在下面找到存储库的示例:

interface PatronBooksEntityRepository extends CrudRepository<PatronBooksDatabaseEntity, Long> {

    @Query("SELECT p.* FROM patron_books_database_entity p where p.patron_id = :patronId")
    PatronBooksDatabaseEntity findByPatronId(@Param("patronId") UUID patronId);

}

同时,我们提出了使用纯SQL查询和JdbcTemplate以下方法来持久化聚合的其他方法:

@AllArgsConstructor
class BookDatabaseRepository implements BookRepository, FindAvailableBook, FindBookOnHold {

    private final JdbcTemplate jdbcTemplate;

    @Override
    public Option<Book> findBy(BookId bookId) {
        return findBookById(bookId)
                .map(BookDatabaseEntity::toDomainModel);
    }

    private Option<BookDatabaseEntity> findBookById(BookId bookId) {
        return Try
                .ofSupplier(() -> of(jdbcTemplate.queryForObject("SELECT b.* FROM book_database_entity b WHERE b.book_id = ?",
                                     new BeanPropertyRowMapper<>(BookDatabaseEntity.class), bookId.getBookId())))
                .getOrElse(none());
    }
    
    ...
}

架构代码差距

我们非常注意保持整体架构(包括图表)和代码结构之间的一致性。识别出有界上下文后,我们可以将它们组织在模块中(包,更具体)。多亏了这一点,我们获得了著名的微服务自治,同时拥有一个单一的应用程序。每个包都有明确定义的公共API,通过使用受保护包或私有范围封装所有实现细节。

代码结构:

└── library
    ├── catalogue
    ├── commons
    │   ├── aggregates
    │   ├── commands
    │   └── events
    │       └── publisher
    └── lending
        ├── book
        │   ├── application
        │   ├── infrastructure
        │   └── model
        ├── dailysheet
        │   ├── infrastructure
        │   └── model
        ├── librarybranch
        │   └── model
        ├── patron
        │   ├── application
        │   ├── infrastructure
        │   └── model
        └── patronprofile
            ├── infrastructure
            ├── model
            └── web

你可以看到这个架构尖叫着它有两个有界的上下文:catalogue 和lending借阅。此外,借阅上下文围绕五个业务对象构建:book,dailysheet,librarybranch,patron和patronprofile,而catalog没有子包,这表明它可能是一个内部没有复杂逻辑的CRUD。请查看下面的架构图。

与逐层打包相比,这种方法的另一个优点是,为了提供功能,您通常只需要在一个包中进行,这就是前面提到的自治。一旦我们将上下文包分成单独的微服务,这种自治就可以转移到应用程序级别 。考虑到这些因素,可以将自主权交给可以端到端地处理整个业务领域的产品团队。

模型代码差距

在我们的项目中,我们尽最大努力将模型代码差距缩小到最低限度。这意味着我们试图同时关注模型和代码并使它们保持一致。您将在下面找到一些示例。

从最简单的部分开始,您将在下面找到与描述的命令和事件对应的模型类:

@Value
class PlaceOnHoldCommand {
    ...
}
@Value
class BookPlacedOnHold implements PatronBooksEvent {
    ...
}
@Value
class MaximumNumberOhHoldsReached implements PatronBooksEvent {
    ...    
}
@Value
class BookHoldFailed implements PatronBooksEvent {
    ...
}

我们知道它现在可能看起来不太令人印象深刻,但是如果你看一下聚合的实现,你会发现代码不仅反映了聚合名称,还反映了PlaceOnHold 命令处理的整个场景。让我们揭开细节:

public class PatronBooks {

    public Either<BookHoldFailed, BookPlacedOnHoldEvents> placeOnHold(AvailableBook book) {
        return placeOnHold(book, HoldDuration.openEnded());
    }
    
    ...
}    

如果您尝试暂停可用的书籍,它可能会失败(BookHoldFailed)或产生一些事件 - 什么事件?

@Value
class BookPlacedOnHoldEvents implements PatronBooksEvent {
    @NonNull UUID eventId = UUID.randomUUID();
    @NonNull UUID patronId;
    @NonNull BookPlacedOnHold bookPlacedOnHold;
    @NonNull Option<MaximumNumberOhHoldsReached> maximumNumberOhHoldsReached;

    @Override
    public Instant getWhen() {
        return bookPlacedOnHold.when;
    }

    public static BookPlacedOnHoldEvents events(BookPlacedOnHold bookPlacedOnHold) {
        return new BookPlacedOnHoldEvents(bookPlacedOnHold.getPatronId(), bookPlacedOnHold, Option.none());
    }

    public static BookPlacedOnHoldEvents events(BookPlacedOnHold bookPlacedOnHold, MaximumNumberOhHoldsReached maximumNumberOhHoldsReached) {
        return new BookPlacedOnHoldEvents(bookPlacedOnHold.patronId, bookPlacedOnHold, Option.of(maximumNumberOhHoldsReached));
    }

    public List<DomainEvent> normalize() {
        return List.<DomainEvent>of(bookPlacedOnHold).appendAll(maximumNumberOhHoldsReached.toList());
    }
}

更新详细情况点击标题进入Github

                   

1