請 AI 幫你加快取,它確實加了。程式碼跑得動,也有職責分離的意思——快取邏輯被抽進了另一個 class。但如果你仔細看 UserService 或 OrderRepository,你會發現裡面多了一個 cache 的依賴,多了幾行 if/else,多了一段「先查快取、沒有就查 DB、再寫回快取」的流程。
快取的邏輯確實被移走了,但它還是從外面爬進來,黏在了你的商業邏輯身上。
AI 典型的做法:快取包在服務裡
假設你有一個 UserRepository,AI 收到「加快取」的需求之後,最常見的輸出長這樣:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| class UserRepository {
constructor(
private db: Database,
private cache: Cache // 多了這個依賴
) {}
async findById(id: string): Promise<User> {
const key = `user:${id}`
const cached = await this.cache.get(key)
if (cached) return JSON.parse(cached)
const user = await this.db.query('SELECT * FROM users WHERE id = ?', [id])
await this.cache.set(key, JSON.stringify(user), 3600)
return user
}
}
|
從技術上看,這段程式碼沒有問題。但 UserRepository 現在同時負責兩件事:知道怎麼查資料庫,以及知道怎麼用快取。這兩件事放在同一個 class 裡,測試的時候你要 mock 兩個依賴,快取策略一改就要動這個 class,而且任何需要 UserRepository 的地方都強制帶著快取的知識。
AI 知道「職責分離」,但仍然逃不掉
如果你跟 AI 說「職責要分離,不要把快取邏輯放進 Repository」,它通常會拆出一個 CachedUserRepository:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| class UserRepository {
constructor(private db: Database) {}
async findById(id: string): Promise<User> {
return this.db.query('SELECT * FROM users WHERE id = ?', [id])
}
}
class CachedUserRepository {
constructor(
private repo: UserRepository, // 依賴具體實作,不是介面
private cache: Cache
) {}
async findById(id: string): Promise<User> {
const key = `user:${id}`
const cached = await this.cache.get(key)
if (cached) return JSON.parse(cached)
const user = await this.repo.findById(id)
await this.cache.set(key, JSON.stringify(user), 3600)
return user
}
}
|
UserRepository 確實乾淨了。但問題跑到了呼叫端:你的 UserService 到底要用 UserRepository 還是 CachedUserRepository?如果要用帶快取的版本,呼叫端就必須明確知道有這個「有快取的版本」存在——這份知識不應該由使用方來負擔。
更麻煩的是,CachedUserRepository 依賴的是具體的 UserRepository,不是介面。換一個資料來源、加一個 mock,CachedUserRepository 也跟著要改。
裝飾者模式:讓快取對使用方完全透明
正確的做法是先定義介面,再用裝飾者實作快取層:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| // 1. 定義介面,這是唯一的契約
interface IUserRepository {
findById(id: string): Promise<User>
findAll(): Promise<User[]>
}
// 2. 核心實作只負責資料存取,完全不知道快取的存在
class UserRepository implements IUserRepository {
constructor(private db: Database) {}
async findById(id: string): Promise<User> {
return this.db.query('SELECT * FROM users WHERE id = ?', [id])
}
async findAll(): Promise<User[]> {
return this.db.query('SELECT * FROM users')
}
}
// 3. 裝飾者也實作同一個介面,接受任何 IUserRepository
class CachedUserRepository implements IUserRepository {
constructor(
private inner: IUserRepository, // 依賴介面,不是具體實作
private cache: Cache
) {}
async findById(id: string): Promise<User> {
const key = `user:${id}`
const cached = await this.cache.get(key)
if (cached) return JSON.parse(cached)
const user = await this.inner.findById(id)
await this.cache.set(key, JSON.stringify(user), 3600)
return user
}
async findAll(): Promise<User[]> {
const key = 'users:all'
const cached = await this.cache.get(key)
if (cached) return JSON.parse(cached)
const users = await this.inner.findAll()
await this.cache.set(key, JSON.stringify(users), 300)
return users
}
}
|
組合的方式:
1
2
3
4
5
| // 在 DI container 或 composition root 裡組裝
const userRepository: IUserRepository = new CachedUserRepository(
new UserRepository(db),
cache
)
|
UserService 只需要接收 IUserRepository,完全不知道背後有沒有快取:
1
2
3
4
5
6
7
| class UserService {
constructor(private users: IUserRepository) {} // 只知道介面
async getUser(id: string): Promise<User> {
return this.users.findById(id)
}
}
|
快取存不存在、用哪種快取策略,UserService 一概不知道,也不需要知道。
這樣拆開之後,測試也清楚很多
UserRepository 的測試只需要一個 DB 連線或 DB mock:
1
2
3
4
5
6
7
8
| describe('UserRepository', () => {
it('should query user by id', async () => {
const db = createMockDb({ id: '1', name: 'Alice' })
const repo = new UserRepository(db)
const user = await repo.findById('1')
expect(user.name).toBe('Alice')
})
})
|
CachedUserRepository 的測試只需要 mock IUserRepository 和 Cache,完全不碰 DB:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| describe('CachedUserRepository', () => {
it('should return cached result without hitting inner repo', async () => {
const inner = { findById: vi.fn(), findAll: vi.fn() }
const cache = createMockCache({ 'user:1': JSON.stringify({ id: '1', name: 'Alice' }) })
const repo = new CachedUserRepository(inner, cache)
const user = await repo.findById('1')
expect(inner.findById).not.toHaveBeenCalled() // 快取命中,沒打到 inner
expect(user.name).toBe('Alice')
})
it('should call inner and populate cache on miss', async () => {
const inner = { findById: vi.fn().mockResolvedValue({ id: '2', name: 'Bob' }), findAll: vi.fn() }
const cache = createMockCache({})
const repo = new CachedUserRepository(inner, cache)
await repo.findById('2')
expect(inner.findById).toHaveBeenCalledWith('2')
expect(await cache.get('user:2')).not.toBeNull()
})
})
|
兩個 class、兩組獨立測試,每組只負責自己那一層的邏輯。
為什麼 AI 很難自己做到這件事
AI 在收到「加快取」的需求時,訓練資料裡最常出現的模式就是「在原本的方法裡加 cache check」,這是最短路徑,也是最常見的 tutorial 寫法。
就算你在 skill 或 prompt 裡寫了「職責分離」的規則,AI 通常會把規則解讀成「把快取邏輯移到另一個 class」——但這個 class 仍然是一個知道特定資料來源的 wrapper,不是一個可以替換的裝飾者。
真正的裝飾者模式需要先想好介面的邊界在哪裡,再決定哪些行為應該被包在外層。這個設計決策發生在寫程式之前,而 AI 沒有辦法在沒有明確指令的情況下替你做這個決定。
同樣的模式也適用於其他橫切關注點
快取只是其中一個例子。Logging、retry、rate limiting、telemetry——這些都是橫切關注點(cross-cutting concerns),都可以用同樣的方式處理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| // Logging 裝飾者
class LoggingUserRepository implements IUserRepository {
constructor(
private inner: IUserRepository,
private logger: Logger
) {}
async findById(id: string): Promise<User> {
this.logger.info(`findById: ${id}`)
const start = performance.now()
const user = await this.inner.findById(id)
this.logger.info(`findById: ${id} took ${performance.now() - start}ms`)
return user
}
// ...
}
// 可以疊加多層裝飾者
const repo: IUserRepository = new LoggingUserRepository(
new CachedUserRepository(
new UserRepository(db),
cache
),
logger
)
|
每一層只做一件事,可以自由組合,使用方從頭到尾只看到 IUserRepository。
小結
AI 會幫你寫快取,但它寫出來的東西通常是往商業邏輯裡塞依賴,或者換一個地方繼續耦合。
解法不複雜:先定介面,讓快取層以裝飾者的形式包在外面,商業邏輯完全不動。這樣測試獨立、策略可換、使用方零負擔。
下次請 AI 加快取之前,先自己想清楚介面是什麼,再讓它填實作——這才是比較不會踩坑的工作方式。
參考資源