Clean Architecture is widely used in many projects alongside DDD(Domain-driven Design)
and MSA(Microservice Architecture)
. This project is an idea-driven initiative that leverages TypeScript, a monorepo setup, and Clean Architecture to effectively scale and maintain various web client services that share the same domain.
清洁架构(Clean Architecture)常与 DDD(Domain-driven Design)
和 MSA(Microservice Architecture)
结合应用于众多项目中。该项目是一个理念驱动的实践,利用 TypeScript、monorepo 结构及清洁架构,有效扩展和维护共享同一领域的多种 Web 客户端服务。
However, if the project is a small-scale application that primarily focuses on simple UI, or if the API server is tightly coupled with the client, adopting Clean Architecture might negatively impact maintainability due to increased code complexity and boilerplate code.
然而,若项目为小型应用且主要关注简单 UI,或 API 服务器与客户端紧密耦合,采用清洁架构可能因代码复杂度增加和模板代码过多而对可维护性产生负面影响。
In this sample project, a monorepo is structured using Workspaces
provided by Yarn. The monorepo consists of the Domains and Adapters layers as separate packages, while each service is also packaged individually. These services directly utilize, extend, or inherit elements from the Domains and Adapters layers to build their respective implementations.
本示例项目中,通过 Yarn 提供的 Workspaces
构建了 monorepo 结构。该 monorepo 将领域层(Domains)与适配器层(Adapters)作为独立包,同时各服务也单独打包。这些服务直接使用、扩展或继承领域层和适配器层的元素,以构建各自的实现。
+ My English is not perfect, so please bear with me.
+ 我的英语并不完美,所以请多包涵。
As with many architectures, the primary goal of Clean Architecture is to separate concerns. It divides layers according to each concern, designs around the domain rather than detailed implementations, and ensures the inner layers do not depend on external elements like frameworks, databases, or UIs.
与许多架构一样,清洁架构的主要目标是分离关注点。它根据每个关注点划分层次,围绕领域而非具体实现进行设计,并确保内部层次不依赖于框架、数据库或用户界面等外部元素。
- Separate the detailed implementation area and the domain area.
将具体实现区域与领域区域分离。 - The architecture does not depend on the framework.
该架构不依赖于框架。 - The outer layers can depend on the inner layers, but the inner layers cannot depend on the outer layers.
外层可以依赖内层,但内层不能依赖外层。 - Both high-level and low-level modules depend on abstractions.
高层模块和低层模块都依赖于抽象。
The flow of Clean Architecture can be briefly illustrated in the diagram above.
清洁架构的流程可简要通过上方示意图展现。
In the monorepo structure, the Domains layer, Adapters layer, and Service layer are clearly separated into individual packages with well-defined dependencies. At the root level, basic configurations for TypeScript, ESLint, and Jest are provided, which can be extended and used in the lower-level packages.
在 monorepo 结构中,领域层、适配器层和服务层被清晰地分离为独立的包,并具有明确定义的依赖关系。根目录提供了 TypeScript、ESLint 和 Jest 的基础配置,这些配置可在下层包中扩展使用。
If the project is for a single service rather than multiple services sharing the same domain, a monorepo is not necessary. Instead, the Domains and Adapters layers can be structured as directories rather than separate packages, while the service package can be placed in the Frameworks directory. This allows the entire project to be organized into three main sections: Domains, Adapters, and Frameworks, forming the core of the architecture.
若项目仅针对单一服务而非多个共享同一领域的服务,则无需采用 monorepo。此时,领域层和适配器层可作为目录而非独立包进行组织,而服务包可置于 Frameworks 目录下。如此,整个项目可划分为三大核心部分:Domains(领域)、Adapters(适配器)和 Frameworks(框架),构成架构主体。
/packages
├─ domains
│ └─ src
│ ├─ aggregates
│ ├─ entities
│ ├─ useCases
│ ├─ vos
│ ├─ repositories
│ │ └─ interface
│ └─ dtos
│ └─ interface
├─ adapters
│ └─ src
│ ├─ presenters
│ ├─ repositories
│ ├─ dtos
│ └─ infrastructures
│ └─ interface
├─ client-a(built with React)
│ └─ src
│ ├─ di
│ └─ ...
└─ client-b(built with Next.js)
└─ src
├─ di
└─ ...
In this sample project, service packages use shared packages (Domains
, Adapters
, and other potential packages
) through a Source-to-Source
approach, rather than referencing pre-built outputs. This approach ensures that the service's module bundler can effectively eliminate unused code during the final build. Therefore, all shared packages must be written using ES Modules
.
本示例项目中,服务包通过 Source-to-Source
方式引用共享包( Domains
、 Adapters
、 and other potential packages
),而非预构建输出。此方法确保服务的模块打包器能在最终构建时有效剔除未使用代码。因此,所有共享包必须采用 ES Modules
编写。
Most module bundlers natively support tree shaking for code written in ES Modules.
大多数模块打包工具原生支持对 ES 模块编写的代码进行树摇优化。
The domain layer defines business rules and business logic.
领域层定义了业务规则和业务逻辑。
In the sample project, it represents a simple forum service where users can view a list of posts or create posts and comments. It is structured as a single package within a monorepo, containing definitions for Entities, Use Cases, and Value Objects. Various service packages utilize these definitions to build their respective functionalities.
在示例项目中,它代表了一个简单的论坛服务,用户可以查看帖子列表或创建帖子及评论。该层以单一包的形式组织在 monorepo 中,包含实体(Entities)、用例(Use Cases)和值对象(Value Objects)的定义。各服务包利用这些定义构建各自的功能模块。
Entities are one of the core concepts in domain modeling, representing objects that maintain a unique identity and contain both state and behavior. An Entity is not just a data holder but is responsible for controlling and managing its data. It encapsulates important business rules and logic within the domain.
实体是领域建模中的核心概念之一,代表具有唯一标识且包含状态与行为的对象。实体不仅是数据持有者,还需负责管控自身数据,封装领域内重要的业务规则与逻辑。
In the sample project, there are three entities: Post, Comment, and User.
在示例项目中,存在三个实体:帖子(Post)、评论(Comment)和用户(User)。
Clean Architecture shares a common goal with DDD in pursuing domain-centric design. While Clean Architecture focuses on structural flexibility, maintainability, technological independence, and testability of software, DDD emphasizes solving complex business problems.
清洁架构(Clean Architecture)与 DDD 在追求以领域为中心的设计上有着共同目标。清洁架构侧重于软件的结构灵活性、可维护性、技术独立性和可测试性,而 DDD 则强调解决复杂的业务问题。
However, Clean Architecture adopts some of DDD's philosophy and principles, making it compatible with DDD and providing a framework to effectively implement DDD concepts. For example, Clean Architecture can leverage DDD concepts such as Ubiquitous Language
and Aggregate Root
.
然而,清洁架构采纳了领域驱动设计(DDD)的部分理念与原则,使其与 DDD 兼容,并提供了一个有效实现 DDD 概念的框架。例如,清洁架构可以运用 DDD 中的 Ubiquitous Language
和 Aggregate Root
等概念。
Ubiquitous Language refers to a shared language used by all team members to maintain consistent communication throughout a project. This language should be shared by everyone involved, including project leaders, domain experts, developers, UI/UX designers, business analysts, and QA engineers. It should not only be used in documentation and discussions during collaboration but also be reflected in the software model and code.
通用语言是指项目所有成员使用的共享语言,以确保项目全程沟通的一致性。这一语言应被项目负责人、领域专家、开发人员、UI/UX 设计师、业务分析师及质量保证工程师等所有相关人员共同使用。它不仅应体现在协作过程中的文档和讨论里,还应反映在软件模型及代码中。
An Aggregate is a consistency boundary that can include multiple entities and value objects. It encapsulates internal state and controls external access. All modifications must go through the Aggregate Root, which helps manage the complexity of relationships within the model and maintain consistency when services expand or transactions become more complex.
聚合是一个一致性边界,可以包含多个实体和值对象。它封装了内部状态并控制外部访问。所有修改都必须通过聚合根进行,这有助于管理模型中关系的复杂性,并在服务扩展或事务变得更加复杂时保持一致性。
In the sample project, Post serves as an Aggregate, with the Comment entity having a dependent relationship on it. Therefore, adding or modifying a comment must be done through the Post entity. Additionally, while the Post entity requires information about the author (the User who wrote the post), the User is an independent entity. To maintain a loose relationship, only the User's id and name are included as a Value Object within Post.
在示例项目中,Post 作为一个聚合,Comment 实体对其具有依赖关系。因此,添加或修改评论必须通过 Post 实体进行。此外,虽然 Post 实体需要关于作者(撰写帖子的用户)的信息,但 User 是一个独立实体。为了保持松散的关系,仅将用户的 id 和名称作为值对象包含在 Post 中。
Use Cases define the interactions between users and the service, leveraging domain objects such as Entities, Aggregates, and Value Objects to deliver business functionality to users. From a system architecture perspective, Use Cases help separate application logic from business rules. Rather than directly controlling business logic, Use Cases facilitate interaction with the domain objects, allowing them to enforce business rules and logic.
用例定义了用户与服务之间的交互,通过利用领域对象(如实体、聚合和值对象)向用户交付业务功能。从系统架构角度看,用例有助于将应用逻辑与业务规则分离。用例并不直接控制业务逻辑,而是促进与领域对象的交互,由后者执行业务规则和逻辑。
In the sample project, Use Cases include simple interactions such as retrieving a summarized list of posts, and adding, deleting, or modifying posts and comments.
在示例项目中,用例包含简单的交互操作,例如获取帖子摘要列表,以及添加、删除或修改帖子与评论。
Since the Repository belongs to the Adapter layer, the higher-level Use Case should not directly depend on it. Therefore, in the Use Case, an abstract interface for the Repository is implemented, which is later handled through Dependency Injection(DI)
.
由于仓库属于适配器层,更高层级的用例不应直接依赖它。因此,在用例中实现了仓库的抽象接口,后续通过 Dependency Injection(DI)
进行处理。
Similar to the domain layer, the adapters are also organized as a single package within the monorepo. The adapter layer typically includes Presenters, Repositories, and Infrastructure components. These are used in service packages through dependency injection (DI) and can be extended by inheriting and customizing them as needed.
与领域层类似,适配器在 monorepo 中也作为一个独立包组织。适配器层通常包含展示器(Presenters)、仓储(Repositories)和基础设施组件(Infrastructure)。这些组件通过依赖注入(DI)方式被服务包使用,并可根据需要通过继承和定制进行扩展。
The Infrastructure layer manages external connections such as communication with external servers via HTTP or interactions with browser APIs like LocalStorage, which are commonly used in web services.
基础设施层负责管理外部连接,例如通过 HTTP 与外部服务器通信,或与浏览器 API(如 LocalStorage)交互,这些在 Web 服务中十分常见。
In a typical backend, the Repository layer handles CRUD operations related to databases, such as storing, retrieving, modifying, and deleting data. It abstracts database interactions so that the business logic does not need to be aware of the underlying data store.
在典型的后端架构中,仓库层负责处理与数据库相关的 CRUD 操作,如存储、检索、修改和删除数据。它抽象了数据库交互,使得业务逻辑无需了解底层数据存储的具体实现。
Similarly, in the sample project, the Repository layer performs POST, GET, PUT, and DELETE operations for HTTP communication with the API server. It abstracts these interactions so the business logic is not concerned with where the data originates. Data retrieved from external servers is encapsulated as DTOs (Data Transfer Objects) to ensure stability when used internally within the client.
类似地,在示例项目中,仓库层执行 POST、GET、PUT 和 DELETE 操作,以通过 HTTP 与 API 服务器通信。它抽象了这些交互,使业务逻辑不必关心数据来源。从外部服务器获取的数据被封装为 DTO(数据传输对象),以确保在客户端内部使用时保持稳定性。
The Presenter layer handles requests from the UI, forwarding them to the server. It also converts entity data into View Models used in the UI, responding appropriately based on user requests.
展示器层处理来自用户界面的请求,并将其转发至服务器。同时,它将实体数据转换为用户界面中使用的视图模型,并根据用户请求做出适当响应。
Each layer ultimately operates through Dependency Injection. For example, interfaces are defined for each layer, and various implementations are created based on these interfaces, which are then injected as needed.
每一层最终都通过依赖注入来运作。例如,为每一层定义接口,并根据这些接口创建不同的实现,然后根据需要注入这些实现。
In the sample project, a Repository interface is defined, and two implementations, NetworkRepository (for HTTP communication) and StorageRepository (for web storage), are created. These are then injected into services based on the requirements.
在示例项目中,定义了一个 Repository 接口,并创建了两个实现:NetworkRepository(用于 HTTP 通信)和 StorageRepository(用于 Web 存储)。然后根据需求将这些实现注入到服务中。
This approach to service configuration through Dependency Injection helps clearly define roles and responsibilities, minimizing the scope of changes. By relying on abstraction in the design, new implementations can be easily added, allowing for highly scalable and flexible service development.
这种通过依赖注入配置服务的方法有助于明确定义角色和职责,最小化变更范围。通过在设计上依赖抽象,可以轻松添加新的实现,从而实现高度可扩展和灵活的服务开发。
Typically, HTTP communication and web storage serve different purposes, so it is uncommon to define and selectively use two implementations in the same way as in the sample project. This example was simply used to demonstrate the differences between various implementations.
通常,HTTP 通信与网页存储服务于不同目的,因此在同一项目中定义并选择性使用两种实现方式并不常见,如示例项目所示。此例仅用于展示不同实现间的差异。
The sample project's client services consist of two simple services: client-a and client-b. Both services are built based on the same domain-driven architecture, and their UI components are designed following the principles of Atomic Design.
该示例项目的客户端服务包含两个简单服务:client-a 和 client-b。这两个服务均基于相同的领域驱动架构构建,其 UI 组件设计遵循原子设计原则。
Vite, React, Jotai, Tailwind CSS, Jest, RTL, Cypress
Client-A directly utilizes elements from the Domains
and Adapters
layers and implements methods for each domain using React hooks and the global state management library Jotai. These methods act as the Presenters layer in the final service.
客户端 A 直接调用 Domains
和 Adapters
层的元素,并通过 React hooks 及全局状态管理库 Jotai 为每个领域实现方法。这些方法在最终服务中充当表示层(Presenters layer)。
Previously, the Adapters package explicitly included a Presenters directory to represent a framework-agnostic Presenters layer. However, in services like this sample project that use React, we extend the Presenters layer by injecting dependencies into the final Presenters objects and utilizing React hooks to achieve a composition that aligns with the framework.
此前,Adapters 包明确包含了一个 Presenters 目录,用以表示与框架无关的表示层。然而,在像本示例项目这样使用 React 的服务中,我们通过向最终的 Presenters 对象注入依赖项并利用 React 钩子来实现与框架一致的组合方式,从而扩展了表示层。
import { API_URL } from "../constants"
import infrastructuresFn from "./infrastructures"
import repositoriesFn from "./repositories"
import useCasesFn from "./useCases"
import presentersFn from "./presenters"
export default function di(apiUrl = API_URL) {
const infrastructures = infrastructuresFn(apiUrl)
const repositories = repositoriesFn(infrastructures)
const useCases = useCasesFn(repositories)
const presenters = presentersFn(useCases)
return presenters
}
import { useCallback, useMemo, useOptimistic, useState, useTransition } from "react"
import { atom, useAtom } from "jotai"
import presenters from "../di"
import PostVM from "../vms/PostVM"
import IPostVM from "../vms/interfaces/IPostVM"
const PostsAtoms = atom<IPostVM[]>([])
export default function usePosts() {
const di = useMemo(() => presenters(), [])
const [post, setPost] = useState<IPostVM>(null)
const [posts, setPosts] = useAtom<IPostVM[]>(PostsAtoms)
const [optimisticPost, setOptimisticPost] = useOptimistic(post)
const [optimisticPosts, setOptimisticPosts] = useOptimistic(posts)
const [isPending, startTransition] = useTransition()
const getPosts = useCallback(async () => {
startTransition(async () => {
const resPosts = await di.post.getPosts()
const postVMs = resPosts.map((post) => new PostVM(post))
setPosts(postVMs)
})
}, [di.post, setPosts])
...
}
In Client-A, we structured the View Model in the project layer to effectively manage UI state in React.
在 Client-A 中,我们在项目层构建了 ViewModel,以有效管理 React 中的 UI 状态。
import CryptoJS from "crypto-js"
import IUserInfoVO from "domains/vos/interfaces/IUserInfoVO"
import ICommentVM, { ICommentVMParams } from "./interfaces/ICommentVM"
export default class CommentVM implements ICommentVM {
readonly id: string
readonly postId: string
readonly author: IUserInfoVO
readonly createdAt: Date
key: string
content: string
updatedAt: Date
constructor(parmas: ICommentVMParams) {
this.id = parmas.id
this.postId = parmas.postId
this.author = parmas.author
this.content = parmas.content
this.createdAt = parmas.createdAt
this.updatedAt = parmas.updatedAt
this.key = this.generateKey(this.id, this.updatedAt)
}
updateContent(content: string): void {
this.content = content
this.updatedAt = new Date()
this.key = this.generateKey(this.id, this.updatedAt)
}
applyUpdatedAt(date: Date): void {
this.updatedAt = date
this.key = this.generateKey(this.id, this.updatedAt)
}
private generateKey(id: string, updatedAt: Date): string {
const base = `${id}-${updatedAt.getTime()}`
return CryptoJS.MD5(base).toString()
}
}
The View Model provides methods to handle value changes (e.g., updateContent). Whenever a value is updated, the updatedAt field is also modified. By using a combination of the updatedAt value and the ID, we generate a unique key
that allows React to detect changes in the view and trigger re-renders as needed.
ViewModel 提供了处理值变更的方法(例如 updateContent)。每当值被更新时,updatedAt 字段也会被修改。通过结合 updatedAt 值和 ID,我们生成了一个唯一的 key
,使得 React 能够检测视图中的变更,并在需要时触发重新渲染。
...
export default function usePosts() {
...
const deleteComment = useCallback(
async (commentId: string) => {
startTransition(async () => {
setOptimisticPost((prevPost) => {
prevPost.deleteComment(commentId)
return prevPost
})
try {
const isSucess = await di.post.deleteComment(commentId)
if (isSucess) {
const resPost = await di.post.getPost(optimisticPost.id)
const postVM = new PostVM(resPost)
setPost(postVM)
}
} catch (e) {
console.error(e)
}
})
},
[di.post, optimisticPost, setOptimisticPost, setPost]
)
...
}
In the Presenter layer's hooks, we implemented optimistic updates using the methods provided by the View Model. For instance, when sending a delete request for a comment, we immediately apply the changes locally. After the request succeeds, we fetch the updated data to synchronize the state.
在 Presenter 层的 hooks 中,我们利用 ViewModel 提供的方法实现了乐观更新。例如,当发送删除评论的请求时,我们会立即在本地应用变更。待请求成功后,再获取最新数据以同步状态。
Next.js, Jotai, Tailwind CSS, Jest, RTL, Cypress
Client-B is a service that represents an extension of the service, using the same domain as Client-A. While similar to Client-A, Client-B is built on Next.js, whereas Client-A operates through HTTP communication with an API server to manipulate data. In contrast, Client-B operates based on local storage (Local Storage) without HTTP communication.
Client-B 是一项服务扩展,与 Client-A 共享同一领域模型。尽管与 Client-A 类似,但 Client-B 基于 Next.js 构建,而 Client-A 通过 HTTP 与 API 服务器通信来操作数据。相反,Client-B 基于本地存储(Local Storage)运行,无需 HTTP 通信。
By utilizing the interfaces and implementations defined in the Domains and Adapters layers, similar to Client-A, Client-B can be structured with high code reusability. Additionally, during the dependency injection (DI) process, a simple switch from using a repository that communicates via HTTP to one that uses local storage makes it easy to implement a new service.
通过利用 Domains 和 Adapters 层定义的接口与实现(与 Client-A 类似),Client-B 能以高代码复用性构建。此外,在依赖注入(DI)过程中,只需简单地将基于 HTTP 通信的仓库切换为使用本地存储的仓库,即可轻松实现新服务。
Client-B serves as a simple example of structuring another client service using the same domain, rather than focusing on the specific functionality of the service.
Client-B 作为简单示例,重点在于展示如何利用相同领域模型构建另一个客户端服务,而非聚焦于服务的具体功能实现。
When services use the same framework, as in this sample project, the advantages of a monorepo setup can be leveraged by creating a separate package for shared UI components. This increases component reusability, making it easier to expand and maintain services more efficiently.
当服务采用相同框架时,如本示例项目所示,通过为共享 UI 组件创建独立包,可以充分发挥 monorepo 设置的优势。这提高了组件的可复用性,使得服务扩展与维护更加高效便捷。
You can build or run each package in the sample project using the commands registered at the root.
您可以使用根目录注册的命令来构建或运行示例项目中的每个包。
$ yarn install
# client-a
$ yarn start:a
# client-b
$ yarn start:b
I'm grateful for all the support and interest 🙇♂️
感谢所有的支持与关注 🙇♂️