---
title: "對話紀錄 UseCase - Clean Architecture in TypeScript"
date: 2025-09-05T00:00:00+08:00
publishDate: 2025-09-05T00:00:00+08:00
lastmod: 2025-11-21T10:38:12+08:00
tags: ["TypeScript","Clean Architecture","架構","經驗","AI"]
series: "clean-architecture-in-ts"
toc: true
permalink: "https://blog.aotoki.me/posts/2025/09/05/clean-architecture-in-ts-conversation-record-usecase/"
language: "zh-tw"
---



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

<!--more-->

## 定義介面{#define-interface}

雖然商業邏輯可以交給 AI 來處理，然而我在[身為工程師，我在 2025 年如何用 AI 寫程式](https://blog.aotoki.me/posts/2025/04/02/as-programmer-how-i-use-ai-codegen-in-2025/)一文提到，我認為商業邏輯盡可能交給人類寫還是會比較好的。

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

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

```ts
// src/usecase/getChatMessage.ts

import { ChatMessage } from '@/entity/ChatMessage';
import { ChatMessageRepository, ChatMessagePresenter } from './interface';

export class GetChatMessage {
	constructor(
		private readonly chatMessageRepository: ChatMessageRepository,
		private readonly chatMessagePresenter: ChatMessagePresenter,
	) {}

	async execute(id: string): Promise<void> {
		const messages = await this.chatMessageRepository.byId(id);
		messages.forEach((message) => {
			this.chatMessagePresenter.addMessage(message);
		});
	}
}
```

```ts
// src/entity/ChatMessage.ts
export class ChatMessage {
	constructor(
		public id: string,
		public chatId: string,
		public role: 'user' | 'assistant',
		public content: string,
	) {}

	get isUser(): boolean {
		return this.role === 'user';
	}
}
```

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

```ts
// src/usecase/interface.ts

import { ChatMessage } from '@/entity/ChatMessage'

export interface ChatMessageRepository {
	byId(id: string): Promise<ChatMessage[]>;
}

export interface ChatMessagePresenter {
	addMessage(message: ChatMessage): void;
}
```

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

## 低階元件{#low-level-component}

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

以下的 Prompt（提示）以 Aider 示範，將 `src/entity` 和 `src/usecase` 兩個目錄加入 Read-only 的模式，以及重構的目標 `src/controller/chat.ts` 設定為可讀寫。

```markdown
Refactor `src/controller/chat.ts` to use `GetChatMessage` usecase.

## KvChatMessageRepository

Create a new file `src/repository/KvChatMessageRepository.ts` to implements the `ChatMessageRepository` interface.

* The key have prefix `messages:` and the value is an array
* Define a `MessageSchema` to store the message in the array
* Convert the `ChatMessage` to `MessageSchema` when saving and vice versa when retrieving
* The message id is the index in the array

Use mock data for this repository for now.

## JsonChatMessagePresenter

Create a new file `src/presenter/JsonChatMessagePresenter.ts` to implements the `ChatMessagePresenter` interface.

* Define a `MessageSchema` to convert the `ChatMessage` to JSON
* Define a `toJson` method to return the JSON representation of the `ChatMessage`
```

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

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

## 清理與修正{#clean-and-fix}

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

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

```ts
import { ChatMessage } from '@/entity/ChatMessage';
import { ChatMessageRepository } from '@/usecase/interface';

interface MessageSchema {
  id: string;
  chatId: string;
  role: 'user' | 'assistant';
  content: string;
}

export class KvChatMessageRepository implements ChatMessageRepository {
  // Mock data for now
  private mockMessages: Record<string, MessageSchema[]> = {
    'default': [
      {
        id: '0',
        chatId: 'chat1',
        role: 'assistant',
        content: 'Hello! How can I help you today?'
      },
      {
        id: '1',
        chatId: 'chat1',
        role: 'user',
        content: 'I have a question about the application.'
      },
      {
        id: '2',
        chatId: 'chat1',
        role: 'assistant',
        content: 'Sure, I\'d be happy to help with any questions you have about the application.'
      }
    ]
  };

  async byId(chatId: string): Promise<ChatMessage[]> {
    // In a real implementation, we would fetch from KV with prefix "messages:"
    const messages = this.mockMessages[chatId] || [];

    // Convert MessageSchema to ChatMessage
    return messages.map(msg =>
      new ChatMessage(msg.id, msg.chatId, msg.role, msg.content)
    );
  }
}
```

修正的方法也不困難，將 `mockMessages` 的 `chat1` 像上述的範例一樣，改為 `default` 即可。

接下來，我們的 `JsonChatMessagePresenter` 有一個問題，他寫了一段如下的實作：

```ts
{
  // ...
  timestamp: new Date().toLocaleTimeString() // Using current time as we don't have timestamp in ChatMessage
}
```

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

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

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

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

```ts
import { ChatMessage } from '@/entity/ChatMessage';
import { ChatMessagePresenter } from '@/usecase/interface';

export interface MessageSchema {
  id: string;
  text: string;
  isUser: boolean;
}

export interface ChatResponse {
  messages: MessageSchema[];
}

export class JsonChatMessagePresenter implements ChatMessagePresenter {
  private messages: MessageSchema[] = [];

  addMessage(message: ChatMessage): void {
    this.messages.push({
      id: message.id,
      text: message.content,
      isUser: message.isUser
    });
  }

  toJson(): ChatResponse {
    return {
      messages: this.messages
    };
  }
}
```

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