個人開発に「クリーンアーキテクチャ」は過剰では?という気持ちはある。
ただ実際にやってみたら、テストが書きやすい・外部APIの差し替えが楽という恩恵がちゃんとあった。
Hono + TypeScript (ESM) でどう組んだかをメモしておく。
ディレクトリ構成
backend/src/
├── domain/ # エンティティ・リポジトリ Interface
│ ├── entity/
│ └── repository/
├── usecase/ # ビジネスロジック
├── infrastructure/ # DB・外部API の実装
│ ├── postgres/
│ ├── edinet/
│ └── gemini/
├── api/ # Hono ルーター
└── job/ # JobRunner
依存の方向
api / job
↓
usecase ← domain (Interface)
↓
infrastructure → domain (Interface を実装)
usecase は domain の Interface にしか依存しない。
infrastructure が Interface を実装する。これだけ守れば十分。
Repository Interface の例
// domain/repository/documentRepository.ts
export interface DocumentRepository {
save(document: Document): Promise<void>
exists(docId: string): Promise<boolean>
findUnanalyzedOne(): Promise<Document | null>
findById(docId: string): Promise<Document | null>
}
PostgreSQL 実装
// infrastructure/postgres/postgresDocumentRepository.ts
export class PostgresDocumentRepository implements DocumentRepository {
constructor(private pool: Pool) {}
async exists(docId: string): Promise<boolean> {
const res = await this.pool.query(
'SELECT 1 FROM documents WHERE doc_id = $1',
[docId]
)
return (res.rowCount ?? 0) > 0
}
// ...
}
UseCase でビジネスロジックを書く
// usecase/fetchDocuments.ts
export class FetchDocumentsUseCase {
constructor(
private documentRepository: DocumentRepository,
private edinetClient: EdinetClient
) {}
async execute(date: string): Promise<void> {
const results = await this.edinetClient.listDocuments(date)
for (const res of results) {
if (await this.documentRepository.exists(res.docID)) continue
await this.documentRepository.save(/* ... */)
}
}
}
UseCase は DocumentRepository の Interface しか知らない。
テストでは vi.fn() でモックしたオブジェクトを渡すだけ。
テストが書きやすい
describe('FetchDocumentsUseCase', () => {
it('already existing docs are skipped', async () => {
const mockRepo = {
exists: vi.fn().mockResolvedValue(true),
save: vi.fn(),
// ...
}
const useCase = new FetchDocumentsUseCase(mockRepo, mockEdinetClient)
await useCase.execute('2026-01-01')
expect(mockRepo.save).not.toHaveBeenCalled()
})
})
DB を立ち上げなくてもロジックをテストできる。
Hono ルーターとの繋ぎ方
// api/routes/documents.ts
const router = new Hono()
router.get('/', async (c) => {
const docs = await documentRepo.findAll()
return c.json(docs)
})
router.post('/:docId/analyze', async (c) => {
const { docId } = c.req.param()
const jobId = await jobRunner.createAndRun('analyze', { docId })
return c.json({ jobId })
})
ルーターは Repository・JobRunner を直接受け取る。DI コンテナは使わず、server.ts でインスタンスを組み立てて渡している。個人開発ならこれで十分。
まとめ
- Interface → UseCase → Infrastructure の依存方向を守るだけでかなり効果がある
- DI コンテナは不要。
server.tsで手で組み立てる - テストが書きやすくなるのは本当
- Hono は軽くて Bun / Node.js 両方で動くので個人開発に向いている