ファイル構成とコード分割
アプリケーションが成長するにつれ、すべてのコードを1つのファイルに書くのは現実的ではありません。適切なファイル構成とコード分割により、保守性と開発効率を大幅に向上させることができます。Honoアプリケーションでの効果的な構成方法について学んでいきましょう。
基本的なプロジェクト構造
小規模プロジェクトの構造
hono-app/
├── package.json
├── tsconfig.json
├── src/
│ ├── index.ts # メインアプリケーション
│ ├── server.ts # サーバー起動
│ ├── routes/ # ルート定義
│ │ ├── users.ts
│ │ ├── posts.ts
│ │ └── auth.ts
│ ├── middleware/ # カスタムミドルウェア
│ │ ├── auth.ts
│ │ ├── validation.ts
│ │ └── logger.ts
│ ├── types/ # 型定義
│ │ ├── user.ts
│ │ └── post.ts
│ └── utils/ # ユーティリティ関数
│ ├── crypto.ts
│ └── validation.ts
└── dist/ # ビルド結果
中・大規模プロジェクトの構造
hono-app/
├── package.json
├── tsconfig.json
├── src/
│ ├── app.ts # アプリケーション設定
│ ├── server.ts # サーバー起動
│ ├── config/ # 設定ファイル
│ │ ├── index.ts
│ │ ├── database.ts
│ │ └── environment.ts
│ ├── modules/ # 機能モジュール
│ │ ├── users/
│ │ │ ├── routes.ts
│ │ │ ├── handlers.ts
│ │ │ ├── types.ts
│ │ │ ├── validation.ts
│ │ │ └── services.ts
│ │ ├── posts/
│ │ │ ├── routes.ts
│ │ │ ├── handlers.ts
│ │ │ ├── types.ts
│ │ │ └── services.ts
│ │ └── auth/
│ │ ├── routes.ts
│ │ ├── handlers.ts
│ │ ├── middleware.ts
│ │ └── types.ts
│ ├── shared/ # 共通コンポーネント
│ │ ├── middleware/
│ │ ├── types/
│ │ ├── utils/
│ │ └── constants/
│ ├── database/ # データベース関連
│ │ ├── models/
│ │ ├── migrations/
│ │ └── seeds/
│ └── tests/ # テストファイル
│ ├── integration/
│ ├── unit/
│ └── helpers/
├── docker/ # Docker設定
├── scripts/ # ビルドスクリプト
└── dist/ # ビルド結果
ルートの分割
基本的なルート分割
src/routes/users.ts:
import { Hono } from 'hono'
const users = new Hono()
users.get('/', (c) => {
return c.json({ message: 'Get all users' })
})
users.get('/:id', (c) => {
const id = c.req.param('id')
return c.json({ message: `Get user ${id}` })
})
users.post('/', async (c) => {
const body = await c.req.json()
return c.json({ message: 'Create user', data: body }, 201)
})
export { users }
src/routes/posts.ts:
import { Hono } from 'hono'
const posts = new Hono()
posts.get('/', (c) => {
return c.json({ message: 'Get all posts' })
})
posts.get('/:id', (c) => {
const id = c.req.param('id')
return c.json({ message: `Get post ${id}` })
})
export { posts }
src/index.ts:
import { Hono } from 'hono'
import { users } from './routes/users'
import { posts } from './routes/posts'
const app = new Hono()
app.route('/api/users', users)
app.route('/api/posts', posts)
export default app
「このようにルートを分割することで、機能ごとにファイルを整理できますね。」
ハンドラーの分離
より複雑なロジックはハンドラー関数として分離しましょう:
src/handlers/userHandlers.ts:
import { Context } from 'hono'
import { User, CreateUserRequest } from '../types/user'
import { UserService } from '../services/userService'
export class UserHandlers {
constructor(private userService: UserService) {}
async getAllUsers(c: Context) {
const page = parseInt(c.req.query('page') || '1')
const limit = parseInt(c.req.query('limit') || '10')
try {
const result = await this.userService.getUsers({ page, limit })
return c.json(result)
} catch (error) {
return c.json({ error: 'Failed to fetch users' }, 500)
}
}
async getUserById(c: Context) {
const id = c.req.param('id')
try {
const user = await this.userService.getUserById(id)
if (!user) {
return c.json({ error: 'User not found' }, 404)
}
return c.json(user)
} catch (error) {
return c.json({ error: 'Failed to fetch user' }, 500)
}
}
async createUser(c: Context) {
try {
const body = await c.req.json<CreateUserRequest>()
const user = await this.userService.createUser(body)
return c.json(user, 201)
} catch (error) {
return c.json({ error: 'Failed to create user' }, 400)
}
}
}
src/routes/users.ts:
import { Hono } from 'hono'
import { UserHandlers } from '../handlers/userHandlers'
import { UserService } from '../services/userService'
const users = new Hono()
const userService = new UserService()
const userHandlers = new UserHandlers(userService)
users.get('/', (c) => userHandlers.getAllUsers(c))
users.get('/:id', (c) => userHandlers.getUserById(c))
users.post('/', (c) => userHandlers.createUser(c))
export { users }
サービス層の実装
ビジネスロジックをサービス層に分離:
src/services/userService.ts:
import { User, CreateUserRequest } from '../types/user'
import { DatabaseConnection } from '../database/connection'
export interface PaginationOptions {
page: number
limit: number
}
export interface PaginatedResult<T> {
data: T[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
}
export class UserService {
constructor(private db: DatabaseConnection) {}
async getUsers(options: PaginationOptions): Promise<PaginatedResult<User>> {
const { page, limit } = options
const offset = (page - 1) * limit
const [users, total] = await Promise.all([
this.db.query('SELECT * FROM users LIMIT ? OFFSET ?', [limit, offset]),
this.db.query('SELECT COUNT(*) as count FROM users')
])
return {
data: users,
pagination: {
page,
limit,
total: total[0].count,
totalPages: Math.ceil(total[0].count / limit)
}
}
}
async getUserById(id: string): Promise<User | null> {
const result = await this.db.query('SELECT * FROM users WHERE id = ?', [id])
return result[0] || null
}
async createUser(userData: CreateUserRequest): Promise<User> {
const id = crypto.randomUUID()
const user = {
id,
...userData,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
await this.db.query(
'INSERT INTO users (id, name, email, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)',
[user.id, user.name, user.email, user.createdAt, user.updatedAt]
)
return user
}
}
型定義の管理
src/types/user.ts:
export interface User {
id: string
name: string
email: string
createdAt: string
updatedAt: string
}
export interface CreateUserRequest {
name: string
email: string
}
export interface UpdateUserRequest {
name?: string
email?: string
}
export interface UserFilters {
name?: string
email?: string
createdAfter?: string
createdBefore?: string
}
src/types/api.ts:
export interface ApiResponse<T> {
data: T
message?: string
}
export interface ErrorResponse {
error: string
message?: string
details?: any[]
timestamp: string
}
export interface PaginationMeta {
page: number
limit: number
total: number
totalPages: number
}
ミドルウェアの分割
src/middleware/validation.ts:
import { Context, Next } from 'hono'
import { z } from 'zod'
export const validate = (schema: z.ZodSchema) => {
return async (c: Context, next: Next) => {
try {
const body = await c.req.json()
const validatedData = schema.parse(body)
c.set('validatedData', validatedData)
await next()
} catch (error) {
if (error instanceof z.ZodError) {
return c.json({
error: 'Validation failed',
details: error.errors.map(err => ({
field: err.path.join('.'),
message: err.message
}))
}, 400)
}
return c.json({ error: 'Invalid request body' }, 400)
}
}
}
src/middleware/auth.ts:
import { Context, Next } from 'hono'
import { jwt } from 'hono/jwt'
export const authenticateUser = async (c: Context, next: Next) => {
const token = c.req.header('Authorization')?.replace('Bearer ', '')
if (!token) {
return c.json({ error: 'Authentication required' }, 401)
}
try {
// JWTトークンの検証ロジック
const payload = await verifyJWT(token)
c.set('user', payload)
await next()
} catch (error) {
return c.json({ error: 'Invalid token' }, 401)
}
}
export const requireRole = (role: string) => {
return async (c: Context, next: Next) => {
const user = c.get('user')
if (!user || user.role !== role) {
return c.json({ error: 'Insufficient permissions' }, 403)
}
await next()
}
}
設定管理
src/config/index.ts:
interface Config {
port: number
database: {
url: string
maxConnections: number
}
jwt: {
secret: string
expiresIn: string
}
cors: {
origins: string[]
}
}
export const config: Config = {
port: parseInt(process.env.PORT || '3000'),
database: {
url: process.env.DATABASE_URL || 'sqlite://./app.db',
maxConnections: parseInt(process.env.DB_MAX_CONNECTIONS || '10')
},
jwt: {
secret: process.env.JWT_SECRET || 'your-secret-key',
expiresIn: process.env.JWT_EXPIRES_IN || '24h'
},
cors: {
origins: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000']
}
}
モジュール化されたアプリケーション
src/modules/users/index.ts:
import { Hono } from 'hono'
import { UserService } from './services'
import { UserHandlers } from './handlers'
import { userValidation } from './validation'
import { authenticateUser } from '../../shared/middleware/auth'
export function createUserModule() {
const app = new Hono()
const userService = new UserService()
const userHandlers = new UserHandlers(userService)
// 公開エンドポイント
app.get('/', (c) => userHandlers.getAllUsers(c))
app.get('/:id', (c) => userHandlers.getUserById(c))
// 認証が必要なエンドポイント
app.use('/*', authenticateUser)
app.post('/', userValidation.create, (c) => userHandlers.createUser(c))
app.put('/:id', userValidation.update, (c) => userHandlers.updateUser(c))
app.delete('/:id', (c) => userHandlers.deleteUser(c))
return app
}
src/app.ts:
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { createUserModule } from './modules/users'
import { createPostModule } from './modules/posts'
import { createAuthModule } from './modules/auth'
import { config } from './config'
export function createApp() {
const app = new Hono()
// グローバルミドルウェア
app.use('*', logger())
app.use('*', cors({
origin: config.cors.origins,
credentials: true
}))
// ルートマウント
app.route('/api/users', createUserModule())
app.route('/api/posts', createPostModule())
app.route('/api/auth', createAuthModule())
// ヘルスチェック
app.get('/health', (c) => c.json({ status: 'ok' }))
// 404ハンドラー
app.notFound((c) => c.json({ error: 'Not Found' }, 404))
return app
}
ユーティリティ関数の整理
src/shared/utils/crypto.ts:
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
export class CryptoUtils {
static async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10)
}
static async verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash)
}
static generateJWT(payload: object, secret: string, expiresIn: string): string {
return jwt.sign(payload, secret, { expiresIn })
}
static verifyJWT(token: string, secret: string): any {
return jwt.verify(token, secret)
}
}
src/shared/utils/validation.ts:
import { z } from 'zod'
export const commonSchemas = {
id: z.string().uuid(),
email: z.string().email(),
password: z.string().min(8),
pagination: z.object({
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(10)
})
}
export const createPaginationSchema = (filters?: z.ZodRawShape) => {
const base = {
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(10)
}
return z.object(filters ? { ...base, ...filters } : base)
}
ビルドとデプロイ設定
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"allowJs": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": "./src",
"paths": {
"@/*": ["*"],
"@/types/*": ["types/*"],
"@/utils/*": ["shared/utils/*"],
"@/middleware/*": ["shared/middleware/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests/**/*"]
}
やってみよう!
実際にコード分割を行ったプロジェクトを作成してみましょう:
-
ブログAPI
- ユーザー管理モジュール
- 記事管理モジュール
- コメント管理モジュール
- 認証モジュール
-
ECサイトAPI
- 商品管理
- 注文管理
- ユーザー管理
- 決済処理
-
タスク管理API
- プロジェクト管理
- タスク管理
- チーム管理
- 通知システム
ベストプラクティス
1. フォルダー構造の一貫性
// ❌ 不一致な構造
src/
├── userRoutes.ts
├── post-handlers.ts
├── auth_middleware.ts
// ✅ 一貫した構造
src/
├── routes/
│ ├── users.ts
│ └── posts.ts
├── handlers/
│ ├── users.ts
│ └── posts.ts
2. 循環依存の回避
// ❌ 循環依存
// userService.ts
import { PostService } from './postService'
// postService.ts
import { UserService } from './userService'
// ✅ 共通インターフェースを使用
// interfaces/index.ts
export interface IUserService { ... }
export interface IPostService { ... }
3. 適切な抽象化レベル
// ✅ レイヤー分離
Controller -> Service -> Repository -> Database
ポイント
- モジュール化:機能ごとにファイルとディレクトリを分割
- レイヤー分離:ハンドラー、サービス、リポジトリの明確な分離
- 型安全性:共通の型定義でタイプセーフティを確保
- 設定管理:環境変数と設定ファイルの適切な管理
- 再利用性:共通コンポーネントとユーティリティの活用