使用战术 DDD 和 MonoRepos 实现可持续的 Angular 架构?

2025-05-27

使用战术 DDD 和 MonoRepos 实现可持续的 Angular 架构?

商业和工业应用通常寿命较长。这些应用包括面向客户的应用程序,其中包含复杂的后端服务和系统。如今,许多此类应用都使用 JavaScript 通过 Web 前端实现。

那么我们如何构建和维护这样的软件系统呢?

领域驱动设计 (DDD)提供了答案!最重要的是,它特别适合复杂的 Angular 解决方案。

在本文中我们将了解:

  • 战术设计 (DDD)如何帮助将我们的源代码组织为更小、更易于管理、更连贯的部分。
  • Monorepos如何帮助实现它们
  • Facades如何帮助隔离域逻辑
  • 客户端 DDD 如何为微前端铺平道路

所有这些主题也都是我们高级Angular 架构研讨会的一部分。如果您正在使用 Angular 实现商业或工业软件,不妨看看这个!

与往常一样,所使用的示例可以在我的GitHub 帐户中找到。

非常感谢Thomas Burleson对本文的深入审阅和贡献。

域分解

假设我们正在构建一个消费者旅游 Web 应用程序。我们有几个可以识别的域名:

域

为了管理这些领域的复杂性,我们应该利用领域驱动设计和 Angular 社区的最佳实践。

领域驱动设计有两种设计类型

前者涉及识别领域及其之间的通信,而战术设计则涉及构建这些领域。

我已经写过关于在 Angular 应用程序中使用战略设计的文章。您可以阅读这篇文章来了解基础知识。因此,在这里,我将重点介绍战术领域设计。

应用战术设计

现在让我们讨论如何用战术设计来构建我们的应用程序。

使用层来组织域

对于域,我们使用列细分(“泳道”)。此外,这些域可以通过库的层级来组织,从而实现行细分:

注意每个层可以包含一个或多个

虽然使用层是组织域的一种相当传统的方式,但也有其他选择,例如六边形架构或清洁架构。

那么共享功能怎么样?

对于需要跨域共享和使用的功能,shared可以使用额外的泳道。共享库可能非常有用。例如,用于身份验证或日志记录的共享库。

注:shared泳道对应DDD提出的Shared Kernel,也包含需要共享的技术库。

如何防止高耦合?

访问约束定义了哪些库可以使用/依赖其他库。通常,每个层只允许与底层通信。跨域访问也只允许与特定shared区域进行。使用这些限制的好处是可以实现松耦合,从而提高可维护性。

为了避免在该领域投入过多的逻辑shared,本文提出的方法还使用了为其他领域发布构建块的 API。这呼应了 DDD 中“开放服务”的理念。

对于共享部分,可以看到以下两个特点:

  • 如灰色块所示,大多数util库都位于该shared区域,特别是跨系统使用身份验证或日志记录等方面。
  • 这同样适用于确保系统范围外观和感觉的通用 UI 库。

特定功能怎么样?

但需要注意的是,特定领域的feature库不在此shared范围内。功能相关的代码应该放在其自己的领域内。

虽然开发人员可以选择在域之间共享功能代码,但这种做法可能会导致责任共担、协调工作量增加以及重大变更。因此,应谨慎共享。

代码组织

根据 Nrwl.io 的企业 MonoRepository 模式,我区分了五类层或库:

类别 描述 示例内容
特征 包含用例的组件。 搜索飞行部件
用户界面 包含所谓的“哑组件”,这些组件与用例无关,因此可重复使用。 日期时间组件、地址组件、地址管道
API 从当前子域导出构建块供其他人使用。 航班 API
领域 包含域(泳道)使用的域模型(类、接口、类型)
实用程序 包括通用实用功能 日期格式

这种完整的架构矩阵乍一看让人眼花缭乱。但简单回顾一下之后,几乎所有与我合作过的开发人员都认为,这种代码组织方式有利于代码复用和未来功能的实现。

隔离域

为了隔离域逻辑,我们将其隐藏在以特定于用例的方式表示它的外观后面:

隔离领域层

这个 Facade 还可以处理状态管理。有两篇很棒的文章可以深入探讨这些领域:

虽然 Facades 目前在 Angular 环境中非常流行,但这个想法也与 DDD(在其中它们被称为应用服务)有着很好的关联。

从架构上将基础设施需求与实际领域逻辑分开非常重要。

在 SPA 中,基础设施关注点(至少在大多数情况下)是与服务器的异步通信和数据交换。保持这种分离会产生三个额外的层:

  • 应用服务/外观,
  • 实际领域层,以及
  • 基础设施层。

当然,这些层现在也可以打包到各自的库中。为了简单起见,也可以将它们存储在一个库中,并进行相应的细分。如果这些层通常一起使用,并且只需要在单元测试中交换,那么这种做法就很有意义。

Monorepos 中的实现

一旦确定了架构的组件,接下来的问题是如何在 Angular 中实现它们。一种非常常见的方法,Google 自己也使用过,那就是使用 monorepo。它是一个包含软件系统所有库的代码仓库。

虽然使用 Angular CLI 创建的项目如今可以用作单一仓库 (monorepo),但流行的工具Nx提供了一些额外的可能性,这些可能性对于大型企业解决方案尤其有价值。其中包括之前讨论过的在库之间引入访问限制的方法。这可以防止每个库相互访问,从而导致整个系统高度耦合。

要在 monorepo 中创建库,一条指令就足够了:



ng generate library domain --directory boarding


Enter fullscreen mode Exit fullscreen mode

由于您只是使用ng generate library而不是ng generate module,因此您无需付出更多努力。但是,您将获得更清晰的结构、更好的可维护性和更少的耦合。

Nx 提供的开关directory指定了放置库的可选子目录。这样,它们就可以按域分组

带域名的 Monorepo

库的名称也反映了层级。
如果一个层级包含多个库,则使用这些库的名称作为前缀是合理的。这样就会出现类似feature-search或 这样的名称feature-edit

为了隔离实际的领域模型,此处显示的示例将领域库划分为下面提到的三个层:

在 Monorepo 中构建

通过查看 git 提交日志,Nx 还可以识别哪些库受到最新代码更改的影响

这些变更信息用于仅重新编译受影响的库或仅运行受影响的测试。显然,这对于存储在一个完整存储库中的大型系统来说,可以节省大量时间。

实体和你的战术设计

战术设计为构建领域层提供了许多思路。领域层的核心是反映现实世界领域和结构的实体。

以下清单显示了一个枚举和两个实体,符合 Java 或 C# 等面向对象语言的通常做法。



public enum BoardingStatus {
  WAIT_FOR_BOARDING,
  BOARDED,
  NO_SHOW
}

public class BoardingList {

  private int id;
  private int flightId;
  private List<BoardingListEntry> entries;
  private boolean completed;

  // getters and setters

  public void setStatus (int passengerId, BoardingStatus status) {
    // Complex logic to update status
  }

}

public class BoardingListEntry {

  private int id;
  private boarding status status;

  // getters and setters
}


Enter fullscreen mode Exit fullscreen mode

与面向对象领域中常见的情况一样,这些实体使用信息隐藏来确保其状态保持一致。您可以通过私有字段和操作它们的公共方法来实现这一点。

这些实体不仅封装了数据,还封装了业务规则。至少该方法setStatus表明了这种情况。只有在业务规则无法有效地容纳在实体中的情况下,DDD 才会定义所谓的领域服务。

在 DDD 中,仅表示数据结构的实体是不受欢迎的。社区称它们为“贫血的”

战术 DDD 与函数式编程

从面向对象的角度来看,前面的方法确实有道理。然而,对于 JavaScript 和 TypeScript 这样的语言来说,面向对象就没那么重要了。

Typescript 是一种多范式语言,其中函数式编程扮演着重要的角色。您可以在这里找到关于函数式 DDD 的书籍:

因此,使用函数式编程,先前考虑的实体模型将被拆分为数据部分和逻辑部分。领域驱动设计精粹 (Domain-Driven Design Distilled) 是 DDD 的标准著作之一,主要依赖于面向对象编程 (OOP),它也承认在函数式编程 (FP) 的世界中,这一规则的改变是必要的:



export type BoardingStatus = 'WAIT_FOR_BOARDING' | 'BOARDED' | 'NO_SHOW' ;

export interface BoardingList {
    readonly id: number;
    readonly flightId: number;
    readonly entries: BoardingListEntry [];
    readonly completed: boolean;
}

export interface BoardingListEntry {
    readonly passengerId: number;
    readonly status: BoardingStatus;
}


Enter fullscreen mode Exit fullscreen mode


export function updateBoardingStatus (
                   boardingList: BoardingList,
                   passengerId: number,
                   status: BoardingStatus): Promise <BoardingList> {

        // Complex logic to update status

}


Enter fullscreen mode Exit fullscreen mode

这里的实体也使用了公共属性。这种做法在函数式编程中相当常见;过度使用 getter 和 setter(它们只会委托给私有属性)常常遭到嘲笑!

然而,更有趣的问题是函数式编程如何避免不一致的状态。答案出奇地简单:数据结构最好是immutable 的readonly。示例中的关键字强调了这一点。

程序中想要改变此类对象的部分必须克隆它,并且如果程序的其他部分已经首先为自己的目的验证了对象,那么它们可以假定它仍然有效。

使用不可变数据结构的一个好处是,变更检测的性能得到了优化。不再需要深度比较。相反,更改的对象实际上是一个新的实例,因此对象引用将不再相同。

带有聚合的战术 DDD

为了跟踪领域模型的组件,战术 DDD 将实体组合成聚合。在上一个示例中,BoardingListBoardingListEntry构成了这样的聚合。

聚合中所有组件的状态必须整体一致。例如,在上面的例子中,可以指定只有当 no的状态为时, completedin才可以设置为BoardingListtrueBoardingListEntryWAIT_FOR_BOARDING

此外,不同的聚合不能通过对象引用相互引用,而是可以使用 ID。这可以避免聚合之间不必要的耦合。大型域因此可以分解为较小的聚合组。

领域驱动设计精粹 (Domain-Driven Design Distilled)建议将聚合体设计得尽可能小。首先,将每个实体视为一个聚合体,然后将需要更改的聚合体立即合并在一起。

立面

Facades(又称应用服务)用于以特定于用例的方式表示领域逻辑。它具有以下几个优点:

  • 封装复杂性
  • 关注状态管理
  • 简化的 API

独立于 DDD,这个想法在 Angular 世界中已经流行了一段时间。

对于我们的示例,我们可以创建以下 Facade:



@Injectable ({providedIn: 'root'})
export class FlightFacade {

    private notifier = new BehaviorSubject<Flight[]>([]);    
    public  flights$ = this.notifier.asObservable();

    constructor(private flightService: FlightService) { }

    search(from: string, to: string, urgent: boolean): void {
        this.flightService
            .find(from, to, urgent)
            .subscribe (
              (flights) => this.notifier.next(flights),
              (err) => console.error ('err', err);
            );
    }
}


Enter fullscreen mode Exit fullscreen mode

请注意外观模块中 RxJS 和可观察对象的使用。这意味着外观模块可以在情况发生变化时自动提供更新的航班信息。外观模块的另一个优势是能够透明地引入 Redux,并@ngrx/store在需要时进行后续操作。这不会影响任何外部应用程序组件。

对于 Facade 的消费者来说,它自己管理状态还是委托给状态管理库来管理状态并不重要。

鼓励开发人员阅读/观看 Thomas Burleson 关于这个主题的作品:

无状态外观

虽然使服务器端服务无状态是一种很好的做法,但对于 Web/客户端层的服务来说,这一目标往往并不高效。

Web SPA 具有状态,这使其变得用户友好!

为了避免用户体验问题,Angular 应用程序不希望一遍又一遍地从服务器重新加载所有信息。因此,概述的 Facade 保存了已加载的航班信息(包含在之前讨论过的可观察对象中)。

领域事件

除了性能提升之外,使用 Observable 还提供了另一个优势。Observable 允许进一步解耦,因为发送者和接收者不必直接了解彼此。

这也与 DDD 完美契合,领域事件的使用如今已成为架构的一部分。如果应用程序的某个部分发生了一些有趣的事情,它会发送一个领域事件,其他应用程序部分就可以对此做出反应。

在所示的示例中,领域事件可能指示乘客现已登机。如果系统的其他部分对此感兴趣,则可以执行特定的逻辑。

对于熟悉 Redux 或 Ngrx 的 Angular 开发人员:领域事件可以表示为分派的动作

领域驱动设计和微前端?

众所周知,领域驱动设计的思想为微服务架构铺平了道路。因此,客户端 DDD 可以作为微前端的基础。

无论是部署单体应用、微前端,还是两者之间的任何组合,都取决于 Monorepo 的使用情况。如果 Monorepo 为每个域都创建了专属的应用,那么就向微前端迈出了一大步:

客户端 DDD 作为微前端的基础

上面讨论的访问限制确保了松散耦合,甚至允许稍后拆分为多个存储库。这样,您就可以按照经典的微服务架构来讨论微前端了。然而,在这种情况下,团队必须负责版本控制和共享库的分发,这在微服务中很常见。

在我的博客文章中可以找到有关此主题的更多想法,这是关于切割您的域,而不是(首先)关于微前端!

结论

现代单页应用程序 (SPA) 通常不仅仅是数据传输对象 (DTO) 的接收者。它们通常包含重要的领域逻辑,这增加了复杂性。DDD 的理念可以帮助开发人员管理和扩展由此产生的复杂性。

由于 TypeScript 的对象函数式特性以及普遍的惯例,一些规则的改变是必要的。例如,我们通常使用不可变变量,并将数据结构与操作它们的逻辑分开。

这里概述的实施方案基于以下想法:

  • 使用按域分组的多个库的 monorepos 有助于构建基本结构。
  • 库之间的访问限制可防止域之间的耦合。
  • 外观为各个用例准备领域模型并负责维护状态。
  • 如果需要,可以在外观后面使用 Redux,而不会引起应用程序其余部分注意。

此外,团队还利用客户端DDD为微前端创造了条件。

如果您想了解所有这些主题的实际应用,请查看我们的Angular Architecture 研讨会

文章来源:https://dev.to/angular/sustainable-angular-architectures-with-tropical-ddd-and-monorepos-c61
PREV
我为什么选择 Angular
NEXT
Google Maps 现已成为 Angular 组件的赢家