iT邦幫忙

2024 iThome 鐵人賽

DAY 22
1
Software Development

一個好的系統之好維護基本篇 ( 馬克版 )系列 第 22

Day-22: 好的軟體架構的特點 ( Base Clean Architecture + DDD )

  • 分享至 

  • xImage
  •  

同步至 medium

這篇文章中我們將會以 Clean Architecture 為基準,來定義好的架構的特點。

然後這本書我自已覺得他比較算是在定義好的軟體架構,比較不算是很明確的說像 Hexagonal Architecturev、Onion Architecture、DCI、BCE 之類這種明確的架構,也就是說好有那些 Layer 之類的。但題外話,這本書我自已覺得廢話有點多……

還有順到說一下,這裡面有一些也是我自已在找好的軟體架構特點,所以如果看到一些不是 clean architecture 裡面寫的,別太意外,因為我還是有參考其它東西 ~ 只是主要是 base Clean Architecture。

接下來我們先來看乾淨的架構最常看到的這張圖:

https://ithelp.ithome.com.tw/upload/images/20241005/20089358dEOOpMkYez.jpg
圖片來源:Clean Architecture

接下來我幾個好的特點,會以這張圖為基礎來開始一個一個說,然後總共有 5 個,但不一定都是乾淨架構的,他也有被人嘴,但整體上我覺得他可以當個 base。


特點 1. Layer 有關注點分離( SoC:Separation of Concerns )

首先這個地方應該也是傳統上分層架構想要的特點也是相同的,它們的目的都是為了:

讓每一個層級有職責,不要什麼都是大泥球

然後在 Clean Architecture 中它就是根據那張圖分層了大約以下的 layer:

  • Entities: 就我們之前一直說的 domain model,也是 ddd 的 aggregate 與 entity。還不太熟細的朋朋請參考這篇文章:Day-14: 提升維護性與降低複雜度的好方法之 Domain Model
  • UseCase: 就我們常說的 service,以 ddd 來說我覺得可以算是 application service + domain service。
  • Controller + Gateways + Presenters: 第一個 controller 應該沒個人都知道,另外兩個就是對外溝通的東西。

這個第一個特點我覺得大概就降,然後順到說一下,作者也有提到不是只有這 4 個 :

Only Four Circles?
No, the circles are schematic. You may find that you need more than just these four. There’s no rule that says you must always have just these four.However, The Dependency Rule always applies.

根據最後一句話,事實上依賴規則才是重點。

這讓偶想到我們的 Util (淚


特點 2. 分層有高階與低階之分

在書中提到,高階與低階的分法為 :

越接近 I/O 的就越低階,反之則越高階,以那張圖來看 Entity 就是最高階

那為什麼要這樣呢 ? 因為只要分的清楚,那就會知道我們具體實現大部份都集中在那,那這樣就代表我們可以解依賴,讓低階的修改不會影響到高階。

然後順到說一下,以這樣來說那以下兩個我們常見的 layer 都算是低階 :

  1. controller
  2. repository

所以也就是說 controller、repository 的修改是不能影響到 usecase 與 domain model 的。


特點 3. 有依賴規則 - 內層不依賴外層 + 依賴關係方向 + 不要跳層

總共有 3 點:

  1. 內層不依賴外層 ( 高層不依賴內層 )
  2. 限制依賴關係的方向,只能由外到內
  3. 不要跳層

1. 內層不依賴外層

就簡單的說就是 domain model (最內層),不能 new 外部的東西,例如 usecase 之類的,如下範例,它是一個 user domain model,然後它需要訂單金額來計算等級,通常是這種情況下,會是由外部帶入 order domain model,或是外部計算好金額帶入到這個 domain model 中。

// Bad

class User {
  id: string; 
  level: Level;
  
  constructor(private orderService: OrderService) {}

  calcuateUserLevel () {
    const orders = await orderService.getOrdersByUserId(this.id)
    // 然後用 orders 金額來計算等級之類的。
  }
}

但這裡有個地方有矛盾,你的 usecase ( service ),一定需要 repository 啊……

那這樣不就是 usecase (內層) 依賴了 repository(外層),對吧 ?

// Domain Layer - CourseService.ts 
import { CourseRepository } from '../infrastructure/CourseRepository';

class CourseService {
    private courseRepository: CourseRepository;

    constructor(couresRepository: CourseRepository) {
        // 直接依賴具體的 CourseRepository 而非抽象接口
        this.courseRepository = couresRepository;
    }

    async getCourse(courseId: string): Promise<Course | null> {
        return await this.courseRepository.findCourseById(courseId);
    }

    async createCourse(course: Course): Promise<void> {
        await this.courseRepository.saveCourse(course);
    }
}

還記得我們之前提的依賴反轉原則 ( DIP: Dependency Inversion Principle )嗎 ?

  1. 高層次的模組不應該依賴於低層次的模組,兩者都應該依賴於抽象介面。
  2. 抽象不要依賴細節,細節要依賴於抽象

Day-03 : 設計原則之 SOLID - ISP、DIP

它的反轉就是可以幫我們解決這個問題。

將 repository 的 interface 放在 domain layer

然後順到說一下不只 repository 的 interface,還有以下的 inteface 可能都會開在內層, ,例如 usecase 的 input、output,我看過超多直接 api body 進來的東西直接丟到內層…

// Domain Layer -  ICourseRepository.ts
interface ICourseRepository {
    findCourseById(courseId: string): Promise<Course | null>;
    saveCourse(course: Course): Promise<void>;
}

------------------------------------------------------------------

// Domain Layer -  CourseService.ts
import { ICourseRepository } from './ICourseRepository';
import { Course } from './Course';

class CourseService {
    private courseRepository: ICourseRepository;

    constructor(courseRepository: ICourseRepository) {
        this.courseRepository = courseRepository;
    }

    async getCourse(courseId: string): Promise<Course | null> {
        return await this.courseRepository.findCourseById(courseId);
    }

    async createCourse(course: Course): Promise<void> {
        await this.courseRepository.saveCourse(course);
    }
}

// Infra Layer - CourseRepository.ts

export class CourseRepository implements ICourseRepository {
    async findCourseById(courseId: string): Promise<Course | null> {
        // 模擬從資料庫查詢數據
        return await mockDatabase.find(courseId); 
    }

    async saveCourse(course: Course): Promise<void> {
        // 模擬將課程保存到資料庫
        await mockDatabase.save(course);
    }
}

https://ithelp.ithome.com.tw/upload/images/20240917/20089358uOLlRmJTC3.png

2. 限制依賴關係的方向,只能由外到內

簡單的說就是只有業務可以影響實作,而不能實作影響業務。然後工程術語就是只能外層 new 內層,不能內層的 new 外層。

https://ithelp.ithome.com.tw/upload/images/20241005/20089358dEOOpMkYez.jpg

3. 然後不要跳層

這個在實作篇有提到,很多時後我們通常會分層三個層級 controller、service、repository,那在實務上是不是有很多時後會有人直接 controller -> repository 呢 ?

有吧 ? 我之前也有做這個事情,然後書中提到可能會有以下的後果:

  • 有可能會讓最內層的與最外層的綁定,例如 controller 直接用 domain model,然後回傳出去。
  • 測試的情況下,明明 controller 只要 mock service,但這樣的話還有 mock repository。

特點 4. 有 Bounded Context ( Feature ) 結構

主要的原因在於:

沒有分 Feature 或是 Bounded Context 的情況下,就像是一個 service 資料夾中,一塊 service,但是沒有人知道他們之間的關係。

沒有的話最後大概就長的如下,我相信應該不少人有看過:

src/
├── services/
│   ├── UserService.ts            # 處理用戶邏輯,但與其他服務關聯不明確
│   ├── CourseService.ts          # 負責課程管理,但與 User、Lecture 等的關係模糊
│   ├── LectureService.ts         # 負責課程講座,但與 Assignment 是否有聯繫不明
│   ├── AssignmentService.ts      # 處理作業管理,與 Course 是否有關?不清楚
│   ├── AnnouncementService.ts    # 負責公告邏輯,是否與其他課程相關?不明確
│   ├── NotificationService.ts    # 處理通知,但不清楚與 Announcement 和 Email 是否有關聯
│   ├── EmailService.ts           # 處理郵件,但是否與 Notification 相關未明確
│   ├── PaymentService.ts         # 處理付款邏輯,但不知與 Course 或 User 是否有依賴關係
│   ├── AuthService.ts            # 處理用戶認證,但與 UserService 或其他安全邏輯的關係不明
│   ├── LoggingService.ts         # 記錄系統日誌,不知道具體哪些服務會依賴這個
│   ├── ReportService.ts          # 負責生成報告,但與 Assignment 或 Course 的聯繫不明
│   ├── StatisticsService.ts      # 負責數據統計,不清楚是否與 Report 或其他服務共享資料
│   ├── FeedbackService.ts        # 處理用戶反饋,是否與其他服務有關聯?不明確
│   ├── ArchiveService.ts         # 負責歸檔,但與 Assignment 或 Course 是否有互動不清楚
│   ├── GradingService.ts         # 負責打分,但是否與 Assignment 有直接關聯不清楚
│   ├── BadgeService.ts           # 負責徽章系統,與 Grading 或 User 是否有聯繫未明

~備註~
這點預設的情況是正常產品服務的情況下,但是如果是一些比較特別的,例如 BFF,或是已經是微服務很單獨立,例如 Video Service 這種情況,我自已覺得這個就可能不需要了。

特點 5. 有定好跨層溝通的規則

這個在 Clean Architecture 實作篇中有提到 4 種模式,分別為:

  • (X) No Mapping: 就是一個然後世界通用。
  • Two-Way Mapping
  • Full Mapping
  • One-Way Mapping

其中第一種除非你是那種專案做了後,就可以不用維護的情況,不然沒必要就不要這樣搞,像偶們家……第 1 個版本的軟體架構就是 No Mapping本 ( 偶們家最大服務有 4 個軟體架構…… ),就是資料庫的 mongoose model 世界通用,結果導致現在要追一個方法的回傳結果,都要看一下這個 model 中間有沒有修改還啥……

https://ithelp.ithome.com.tw/upload/images/20241006/20089358PH14HCPbRe.png
圖片來源: Clean Architecture 實作篇 - No Mapping

然後接下來第二種那個 Account 就是 Domain Model,然後 controller 那要出去時會轉成 WebModel,然後要儲到資料庫時就會轉成 Persistence Model。這樣的好處在於:

Domain Model 不會被 API 與資料庫的需要,而進行更改,例如 API 畫面要某個欄位,但 Domain Model 沒有。

但這個有幾個東西可能要想一下:

  • controller -> usecase 的 input 是什麼 ? 根據以下的範例是 domain model,但我自已在實務時很多情況 usecase 會定義好 input 的 interface,而不是 domain model 進來,因為 input 為 domain model 發現很難用,很多情況下也還需要一些處理,才能轉成 domain model,但還有沒辦法的。
  • 但我 usecase 回傳的的確是 Domain model,但最後出去時一定要轉成 WebModel。

https://ithelp.ithome.com.tw/upload/images/20241006/20089358wf7KtaB0Dl.png
圖片來源: Clean Architecture 實作篇 - Two-Way Mapping

接下來第三種的 Full Mapping,它的特色如下,我自已覺得我實作上比較偏像這種,但還是有一些變化:

  • usecase 回傳的是 domain model,不過書中作者也是這樣做,所以應該是沒差。
  • 然後這個是以 operation 來作為單位,對輸入做再切隔。

整體來說我自已比較喜歡這個。

https://ithelp.ithome.com.tw/upload/images/20241006/20089358ga5NigScVz.png
圖片來源: Clean Architecture 實作篇 - Full Mapping

最後一種 One-Way Mapping 比較特別一點,那就是 DomainModel、WebModel、PersistenceModel 都會有個統一的 state inteface,然後這個好處就是可以減少 mapping 的工作。

https://ithelp.ithome.com.tw/upload/images/20241006/20089358qdL8g5VN6w.png
圖片來源: Clean Architecture 實作篇 - One-Way Mapping

這個寫個範例會比較清楚一點 :

interface StateInterface {
  id: string;
  status: 'PENDING' | 'PAID' | 'SHIPPED' | 'CANCELLED';
  createdAt: Date;
  updatedAt: Date;
}

// -----------------------------------------------------
// WebModel 
class OrderWebModel implements StateInterface {
  id: string;
  status: 'PENDING' | 'PAID' | 'SHIPPED' | 'CANCELLED';
  createdAt: Date;
  updatedAt: Date;
  displayTotal: string; // Web 層特有的屬性,用於顯示格式化後的金額

  constructor(order: OrderDomainModel) {
    this.id = order.id;
    this.status = order.status;
    this.createdAt = order.createdAt;
    this.updatedAt = order.updatedAt;
    this.displayTotal = `$${order.totalAmount.toFixed(2)}`;
  }
}

// -----------------------------------------------------
// Domain Model 
class OrderWebModel implements StateInterface {
  id: string;
  status: 'PENDING' | 'PAID' | 'SHIPPED' | 'CANCELLED';
  createdAt: Date;
  updatedAt: Date;
  displayTotal: string; // Web 層特有的屬性,用於顯示格式化後的金額

  constructor(order: OrderDomainModel) {
    this.id = order.id;
    this.status = order.status;
    this.createdAt = order.createdAt;
    this.updatedAt = order.updatedAt;
    this.displayTotal = `$${order.totalAmount.toFixed(2)}`;
  }

  // 可以包含一些前端專用的格式化方法
  getFormattedCreatedAt(): string {
    return this.createdAt.toDateString();
  }
}

// -----------------------------------------------------
// PersistenceModel
class OrderPersistenceModel implements StateInterface {
  id: string;
  status: 'PENDING' | 'PAID' | 'SHIPPED' | 'CANCELLED';
  createdAt: Date;
  updatedAt: Date;
  dbId: string; // Persistence 層特有的屬性,表示資料庫中的唯一識別符

  constructor(data: any) {
    this.id = data.id;
    this.status = data.status;
    this.createdAt = new Date(data.createdAt);
    this.updatedAt = new Date(data.updatedAt);
    this.dbId = data.dbId;
  }
}

範例資料夾

整體來說如下:

  1. 特點 1. 有Layer 關注點分離: 每個 layer 都有職責。
  2. 特點 2. 有分層有高階與低階之分: 整體的高到低為 domain -> usecase -> infrastructure or persentation。
  3. 特點 3. 有依賴規則 - 內層不依賴外層 + 依賴關係方向 + 不要跳層: 像 usecase 就沒有依賴 infrastructure。
  4. 特點 4. 有 Bounded Context ( Feature ) 結構: 有分 course 與 article 以 bounded context 為概念的結構。
  5. 特點 5. 有定好跨層溝通的規則: 範例是用 Full Mapping,那個 commands 就是封裝 usecase input。
src/
├── course/
│   ├── use-cases/
│   │   ├── CreateCourseUseCase.ts
│   │   ├── GetCourseUseCase.ts
│   │   ├── intefaces/
│   │       └── ICourseRepository.ts
│   │   └── commands/
│   │       └── CreateCourseCommand.ts
│   ├── domain/
│   │   ├── entities/
│   │   │   └── Course.ts
│   │   └── value-objects/
│   │       └── CourseId.ts
│   ├── infrastructure/
│   │   ├── repositories/
│   │   │   └── CourseRepository.ts
│   │   └── database/
│   │       └── MongoDBConnection.ts
│   ├── presentation/
│   │   ├── controllers/
│   │   │   └── CourseController.ts
│   │   ├── models/
│   │   │   └── WebCourseModel.ts
│   └── shared/
│       ├── mapper/
│       │   └── CourseMapper.ts
│       └── utils/
│           └── DateFormatter.ts
├── article/
│   ├── use-cases/
│   ├── domain/
│   ├── infrastructure/
│   ├── presentation/
│   └── shared/
└── shared/

小結

這篇文章中,咱們根據 Clean Architecture 來找出好的架構的特點 :

  • 特點 1. 有Layer 關注點分離
  • 特點 2. 有分層有高階與低階之分
  • 特點 3. 有依賴規則 - 內層不依賴外層 + 依賴關係方向 + 不要跳層
  • 特點 4. 有 Bounded Context ( Feature ) 結構
  • 特點 5. 有定好跨層溝通的規則

然後事實上我們很常聽到的 Hexagonal Architecture 事實也都有這些特點,所以他也是一種 Clean Architecture。

然後以整個維護性來看,我自已覺得軟體架構是最重要的,因為我發現就算一個地方寫的在好,但是只要軟體架構沒訂好,那最後那個好的地方也會被慢慢的污染。

然後接下來的章節就是在這個基準上,peak 每一個地方的細節了。


上一篇
Day-21: Event Storming To Code
下一篇
Day-23: Domain Event 之 Transactional OutBox 與 EventBus
系列文
一個好的系統之好維護基本篇 ( 馬克版 )30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言