
對話紀錄 UseCase - Clean Architecture in TypeScript
經過將對話紀錄從前端移動到後端,我們需要再更近一步將這個行為抽離成商業邏輯,基本上跟傳統開發模式沒有太大差異,主要的變化是我們將這些任務交接給 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 用於查詢,而讀取資料、呈現資料則透過 ChatMessageRepository
和 ChatMessagePresenter
兩個介面描述。
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/entity
和 src/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}
修正的方法也不困難,將 mockMessages
的 chat1
像上述的範例一樣,改為 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.tsx
和 src/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}
基本上,在我們目前這種叫小規模的開發流程中,我們獲得的成果應該會很接近人類的實作。一部分是因為訓練的資料源自過去累積的成果,另一部分則是我們透過足夠精確的指示,很好的替換掉打字,用更快速的方式產生程式碼。
如果對這篇文章有興趣,可以透過以下連結繼續閱讀這系列的其他文章。
- 連載介紹 - Clean Architecture in TypeScript
- 目標設定 - Clean Architecture in TypeScript
- Hono 框架 - Clean Architecture in TypeScript
- tsyringe 套件 - Clean Architecture in TypeScript
- 專案設定 - Clean Architecture in TypeScript
- 介面規劃 - Clean Architecture in TypeScript
- 架構規劃 - Clean Architecture in TypeScript
- 助手對話介面 - Clean Architecture in TypeScript
- 對話紀錄 API - Clean Architecture in TypeScript
- 對話紀錄 UseCase - Clean Architecture in TypeScript