---
title: "側欄 Use Case - Clean Architecture in TypeScript"
date: 2025-10-03T00:00:00+08:00
publishDate: 2025-10-03T00: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/10/03/clean-architecture-in-ts-sidebar-use-case/"
language: "zh-tw"
---



購物車側欄第一階段的 API 處理完畢後，我們一樣需要重構為 Use Case 來保持架構的一致性，以及後續的可擴充性，我們會繼續使用 AI 來協力完成這個修改。

<!--more-->

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

開始修改之前，我們需要先在 Entity 和 Use Case 把我們預期的商業邏輯定義出來。

加入 `src/entity/Cart.ts` 這個檔案，實作以下內容。

```ts
export class CartItem {
	constructor(
		public readonly id: number, // index in the cart
		public readonly name: string,
		public readonly unitPrice: number,
		public readonly quantity: number,
	){}

	get totalPrice(): number {
		return this.unitPrice * this.quantity;
	}
}

export class Cart {
	private _items: CartItem[] = [];

	constructor(
		public readonly id: string
	) {}

	get items(): CartItem[] {
		return [...this._items];
	}

	updateItem(name: string, unitPrice: number, quantity: number): void {
		const existingItemIndex = this._items.findIndex(item => item.name === name);
		if (existingItemIndex >= 0) {
			this._items[existingItemIndex] = new CartItem(existingItemIndex, name, unitPrice, quantity);
		} else {
			const newItem = new CartItem(this._items.length, name, unitPrice, quantity);
			this._items.push(newItem);
		}
	}

	removeItem(name: string): void {
		const itemIndex = this._items.findIndex(item => item.name === name);
		if (itemIndex >= 0) {
			this._items.splice(itemIndex, 1);
			for (let i = itemIndex; i < this._items.length; i++) {
				this._items[i] = new CartItem(i, this._items[i].name, this._items[i].unitPrice, this._items[i].quantity);
			}
		}
	}
}
```

基本上只需要描述 Cart 的行為，細節的部分像是 GitHub Copilot 的補全機制在這邊可以很快的把剩下的行為實作出來，唯一需要注意的是在商品資料更新的行為有可能跟預期不同，補全後還是需要仔細確認。

接下來在 `src/usecase/interface.ts` 定義我們所需的 Repository 和 Presenter 到裡面。

```ts
// ...

export interface CartRepository {
	byId(id: string): Promise<Cart>;
}

export interface CartPresenter {
	addItem(item: CartItem): void;
}
```

最後加入 `src/usecase/getCart.ts` 描述我們預期的功能。

```ts
import { CartRepository, CartPresenter } from './interface';

export class GetCartUsecase {
  constructor(
  	private readonly cartRepository: CartRepository,
  	private readonly cartPresenter: CartPresenter,
	){}

	async execute(id: string): Promise<void> {
		const cart = await this.cartRepository.byId(id);
		cart.items.forEach((item) => {
			this.cartPresenter.addItem(item);
		});
	}
}
```

## 進行重構{#refactoring}

當我們必要的介面定義完畢後，就可以跟前面的處理一樣讓 AI 來把剩下的細節處理完，而且我們已經有 `GetChatMessage` 的範例當基準，使用的提示（Prompt）可以直接要求參考這些檔案。

以 Aider 為例子，這次會將 `src/controller/cart.ts`、`src/usecase/interface.ts`、`src/usecase/getCart.ts` 和 Entity 加入外，還會把 `src/controller/chat.ts`、`src/presenter/JsonChatMessagePresenter.ts` 等檔案也加入參考，因此提示會變成像下面這樣。

```markdown
Reference to `src/controller/chat.ts` to refactor `src/controller/cart.ts` to use `GetCartUsecase`

## CartRepository

- Reference `KvChatMessageRepository` to implement `CartRepository` as `KvCartRepository`
- Save in memory for now
- Define `CartSchema` and `CartItemSchema` for saving in kv
- Predefine mock data in repository for development purposes

## CartPresenter

- Reference `JsonChatMessagePresenter` to implement `CartPresenter` as `JsonCartPresenter`
- Define `CartSchema` and `CartItemSchema` for presenting in json
- Define `toJson` method to convert `Cart` to JSON
```

大多數時候我們的 Repository、Presenter 這類低階元件都會非常類似，以往會耗費大量的時間維護跟修改，以及從裡面抽離商業邏輯出來，但是透過 AI 就能把「類似但不規則」這樣的情況解決，而不需要人工去手動區分差異來維護。

## 工具使用{#tool-use}

取得購物車內容的 Use Case 實作後，我們還可以把它轉換成 AI 可以使用的工具，我們先在 `src/service/LlmAssistantService.ts` 中調整原本生成訊息的實作，修改為以下版本。

```ts
// ...

const { text } = await generateText({
  model: openai(this.env.OPENAI_MODEL),
  system: `
  You are a helpful assistant that can answer questions and provide information about the shopping cart. You can also retrieve the current contents of the cart when requested.
  `,
  messages: aiMessages,
  maxSteps: 5,
  tools: {
    getCart: tool({
      description: 'Get the current shopping cart contents',
      parameters: z.object({}),
      execute: async () => {
        // TODO: use getCartUseCase
        return { items: [] };
      }
    })
  }
});

// ...
```

我們先簡單的加入 System Prompt 避免被 Tool Use 的資訊影響，並且定義一個尚未實作的工具。

不過 `AssistantService` 的設計無法知道目前對話的 ID 是多少，我們需要更新一下 `src/usecase/interface.ts` 和 `src/usecase/chatWithAssistant.ts` 加入可以識別對話的資訊。

```ts
// ...
export interface AssistantService {
	ask(chatId: string, messages: ChatMessage[]): Promise<string>;
}
```

```ts
export class ChatWithAssistant {

	// ...
	async execute(id: string, message: string): Promise<void> {
		// ...

		await this.chatMessageRepository.save(id, newMessages);

		newMessages.forEach((message) => {
			this.chatMessagePresenter.addMessage(message);
		});
	}
}
```

最後，讓 Aider 參考像是 `src/controller/cart.ts` 和 Use Case、Entity 等檔案，並且允許編輯 `src/controller/chat.ts` 加入新的依賴，透過以下提示要求重構。

```markdown
Update `LlmAssistantService` to implement `getCart` tool.

- The `cartId` is same as `chatId`
- Use `getCartUsecase` to retrieve the cart.
- Add `ToolCartPresenter` to format the cart response, use `toTool()` method.
- Use dependency injection to provide repository and presenter
- Create a new usecase in tool handler
```

完成之後處理一些設計上的細節，現在我們就可以在 LLM 的視窗向 AI 提問購物車有的商品。在測試時要注意 LLM 模型對工具使用的支援狀況，如果使用 Ollama 測試會需要支援工具的模型才能順利呼叫。

除此之外，AI SDK 的 `maxSteps` 設定必須手動調整，大約在 5 ~ 15 的範圍即可，這是因為目前比較新的模型會自己使用工具到可以回答，如果只有預設的 `1` 會造成使用完工具就停止，而沒有任何訊息回傳。
