蒼時弦也
蒼時弦也
資深軟體工程師
發表於

對話紀錄 UseCase - Clean Architecture in TypeScript

這篇文章是 Clean Architecture in TypeScript 系列的一部分,你可以透過 Leanpub 提前閱讀內容。

經過將對話紀錄從前端移動到後端,我們需要再更近一步將這個行為抽離成商業邏輯,基本上跟傳統開發模式沒有太大差異,主要的變化是我們將這些任務交接給 Agent(代理)來處理,人類則負責設計跟提示。

定義介面

雖然商業邏輯可以交給 AI 來處理,然而我在身為工程師,我在 2025 年如何用 AI 寫程式一文提到,我認為商業邏輯盡可能交給人類寫還是會比較好的。

一方面程式語言是一種比自然語言更精確的語言,因此我們可以精確的描述要做的事情,另一方面則是透過這樣的過程,可以確保我們對商業邏輯有完整的消化(還是可以讓 AI 協助思考)

因此,我們先描述 GetChatMessage 這個使用案例,描述取得對話訊息該有的情境。

 1// src/usecase/getChatMessage.ts
 2
 3import { ChatMessage } from '@/entity/ChatMessage';
 4import { ChatMessageRepository, ChatMessagePresenter } from './interface';
 5
 6export class GetChatMessage {
 7	constructor(
 8		private readonly chatMessageRepository: ChatMessageRepository,
 9		private readonly chatMessagePresenter: ChatMessagePresenter,
10	) {}
11
12	async execute(id: string): Promise<void> {
13		const messages = await this.chatMessageRepository.byId(id);
14		messages.forEach((message) => {
15			this.chatMessagePresenter.addMessage(message);
16		});
17	}
18}
 1// src/entity/ChatMessage.ts
 2export class ChatMessage {
 3	constructor(
 4		public id: string,
 5		public chatId: string,
 6		public role: 'user' | 'assistant',
 7		public content: string,
 8	) {}
 9
10	get isUser(): boolean {
11		return this.role === 'user';
12	}
13}

因為我們需要透過 chatId 來篩選訊息,因此 execute(id: string) 要求要提供一個 ID 用於查詢,而讀取資料、呈現資料則透過 ChatMessageRepositoryChatMessagePresenter 兩個介面描述。

 1// src/usecase/interface.ts
 2
 3import { ChatMessage } from '@/entity/ChatMessage'
 4
 5export interface ChatMessageRepository {
 6	byId(id: string): Promise<ChatMessage[]>;
 7}
 8
 9export interface ChatMessagePresenter {
10	addMessage(message: ChatMessage): void;
11}

這邊定義的介面是用來告訴 Agent 應該要怎樣去滿足實作的需求,有了這些資訊後,就可以讓 AI 來幫我們處理實作。

低階元件

Clean Architecture 對跟資料庫、使用者介面相關處理的元件用「低階元件」來描述,也就是跟 I/O 越接近的越低階,通常也更瑣碎還需要記憶許多知識(如:SDK 用法、API 呼叫)這些都是 AI 所擅長的。

以下的 Prompt(提示)以 Aider 示範,將 src/entitysrc/usecase 兩個目錄加入 Read-only 的模式,以及重構的目標 src/controller/chat.ts 設定為可讀寫。

 1Refactor `src/controller/chat.ts` to use `GetChatMessage` usecase.
 2
 3## KvChatMessageRepository
 4
 5Create a new file `src/repository/KvChatMessageRepository.ts` to implements the `ChatMessageRepository` interface.
 6
 7* The key have prefix `messages:` and the value is an array
 8* Define a `MessageSchema` to store the message in the array
 9* Convert the `ChatMessage` to `MessageSchema` when saving and vice versa when retrieving
10* The message id is the index in the array
11
12Use mock data for this repository for now.
13
14## JsonChatMessagePresenter
15
16Create a new file `src/presenter/JsonChatMessagePresenter.ts` to implements the `ChatMessagePresenter` interface.
17
18* Define a `MessageSchema` to convert the `ChatMessage` to JSON
19* Define a `toJson` method to return the JSON representation of the `ChatMessage`

撰寫任務的 Prompt 時,需要盡可能明確的描述需要實作的介面是怎樣的,而且應該實作哪些內容。

這跟 Vibe Coding 的做法不太一樣,我們並非依照感覺去處理實作,而是依照「軟體工程」這個領域中,過去開發軟體的經驗歸納來定義程式怎麼實作,是有系統的方式進行。

清理與修正

使用大型語言模型難免會遇到隨機性的問題,即使我們的提示再精確完美,只要不是照抄改寫的方式,都還是會需要處理一些小問題。

在這一次的案例中 KvChatMessageRepository 的 Mock Data 使用了 chat1 當作預設的 Chat ID 然而在抽離到後端時,是自動選擇了 default 當作 Chat ID 使用,這就造成我們更新後看不到 Mock Data 提供的紀錄。

 1import { ChatMessage } from '@/entity/ChatMessage';
 2import { ChatMessageRepository } from '@/usecase/interface';
 3
 4interface MessageSchema {
 5  id: string;
 6  chatId: string;
 7  role: 'user' | 'assistant';
 8  content: string;
 9}
10
11export class KvChatMessageRepository implements ChatMessageRepository {
12  // Mock data for now
13  private mockMessages: Record<string, MessageSchema[]> = {
14    'default': [
15      {
16        id: '0',
17        chatId: 'chat1',
18        role: 'assistant',
19        content: 'Hello! How can I help you today?'
20      },
21      {
22        id: '1',
23        chatId: 'chat1',
24        role: 'user',
25        content: 'I have a question about the application.'
26      },
27      {
28        id: '2',
29        chatId: 'chat1',
30        role: 'assistant',
31        content: 'Sure, I\'d be happy to help with any questions you have about the application.'
32      }
33    ]
34  };
35
36  async byId(chatId: string): Promise<ChatMessage[]> {
37    // In a real implementation, we would fetch from KV with prefix "messages:"
38    const messages = this.mockMessages[chatId] || [];
39
40    // Convert MessageSchema to ChatMessage
41    return messages.map(msg =>
42      new ChatMessage(msg.id, msg.chatId, msg.role, msg.content)
43    );
44  }
45}

修正的方法也不困難,將 mockMessageschat1 像上述的範例一樣,改為 default 即可。

接下來,我們的 JsonChatMessagePresenter 有一個問題,他寫了一段如下的實作:

1{
2  // ...
3  timestamp: new Date().toLocaleTimeString() // Using current time as we don't have timestamp in ChatMessage
4}

這是因為我們在 ChatMessage 的設計中並沒有,但原本從 src/view 抽離到後端的 API Mock Data 還保留了自動產生的 Timestamp 欄位。

我們可以在 Aider 設定 src/view/Chat.tsx src/view/ChatMessage.tsxsrc/presenter/JsonChatMessagePresenter.ts 這三個檔案為可編輯,用以下的 Prompt 處理。

1We didn't need `timestamp` that delete it from presenter and view.

如果一切正常,我們應該會得到類似下面的 JsonChatMessagePresenter 以及正常的畫面呈現。

 1import { ChatMessage } from '@/entity/ChatMessage';
 2import { ChatMessagePresenter } from '@/usecase/interface';
 3
 4export interface MessageSchema {
 5  id: string;
 6  text: string;
 7  isUser: boolean;
 8}
 9
10export interface ChatResponse {
11  messages: MessageSchema[];
12}
13
14export class JsonChatMessagePresenter implements ChatMessagePresenter {
15  private messages: MessageSchema[] = [];
16
17  addMessage(message: ChatMessage): void {
18    this.messages.push({
19      id: message.id,
20      text: message.content,
21      isUser: message.isUser
22    });
23  }
24
25  toJson(): ChatResponse {
26    return {
27      messages: this.messages
28    };
29  }
30}

基本上,在我們目前這種叫小規模的開發流程中,我們獲得的成果應該會很接近人類的實作。一部分是因為訓練的資料源自過去累積的成果,另一部分則是我們透過足夠精確的指示,很好的替換掉打字,用更快速的方式產生程式碼。