0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIにも人間にもやさしいアーキテクチャ考えようぜ

0
Posted at

はじめに

どうもみなさんこんにちは、ゆきおです。
最近はAIエージェントの開発に携わるようになり、乗るしかないビッグウェーブに巻き込まれております。

今まで3Dやモバイル関連のconnpassイベントをやってたんですが、「AIエージェントでボートレースの予想しようぜ!」というイベントを冗談半分で考えたら過去イチ参加が多くてとても嬉しかったです。
やはりみなさんギャンブルに対する興味や好奇心は非常に強いということが分かりました。
余談ですがAIエージェントはMastraというTypeScript製のフレームワークについて紹介させていただきました。

ということで最近はもうAIAIと一言目二言目には聞こえてくるこの時代、コーディングのほとんどをAIがやってくれちゃいます。
そこでまたいろいろな問題が噴出しているわけですがその中でも「爆速で生成したコードを人間が追っかけるのが大変」とか「うまく指示出しができなくてしっちゃかめっちゃか」とかだと僕は思います。

というか僕がそうなのでいまいちバイブコーディングというものを使いこなせていません。

なのでここはひとつ、人間が追っかけやすくてAIも理解しやすい、そんな設計があっても良いじゃないと思ったわけです。

機能ベース設計

そこで辿り着いたのが「機能ベースの設計」です。
これまではいわゆるオブジェクト指向とかクリーンアーキテクチャに則っていたりすると、Controller,Usecase,Repositoryなんかがディレクトリとしてあって、その中にゴチャゴチャとファイルが並んでいるかと思います。

機能ベースにおいては、「機能ごとにディレクトリを切る」ということをやってみようかなと思いました。

src/
 ├── index.ts                 # エントリポイント(各機能を合体させる場所)
 ├── db/                      # データベースの初期設定
 ├── types/                   # アプリ全体の型定義(Contextの環境変数など)
 │
 └── features/                # ★機能ごとのまとまり
      ├── users/              # ユーザー機能に関するすべてをここに収める
      │    ├── index.ts       # ルーター(Honoアプリのインスタンス・RPC型のエクスポート)
      │    ├── schema.ts      # バリデーション(Zod)とDBスキーマ(Drizzle)
      │    ├── service.ts     # ビジネスロジック(純粋な関数)
      │    └── repository.ts  # DB操作(純粋な関数)
      │
      └── posts/              # 投稿機能
           ├── index.ts
           ├── schema.ts
           ...

こんな感じですね。
しれっと「Hono」と書いてありますが、最近大注目の国産JSフレームワークのHonoについてキャッチアップしており、これに合わせた設計など何となくやっていたところで機能ベース設計の話が出てきたという経緯です。

コンセプトとしてはタイトル通り、そして主流のTypeScript向け関数型機能ベースの設計
といったところでしょうか。

そのアプリの機能ごとにディレクトリを切って、その中にロジックや型定義などを入れ込む形ですね。
こうすることでAIにコーディングしてもらっても
「ログイン機能の実装だからauthディレクトリ内だな」とか
「authディレクトリ内のここを修正してもらおう」とか
お互いがファイルやディレクトリの参照しやすくなり、こちらからの指示もしやすくなるんじゃないかなという狙いです。

奇しくもだいーぶ前に記事にしたフロントエンド向けアーキテクチャの話と一緒な気がしてきました。
https://qiita.com/kazuyuki_11E0/items/1a83dcbfaa41c5a6fc5b

当然フロントエンドも機能ごとに画面を作れば、多少ディレクトリは深くなりそうですが把握がしやすくなるように個人的には思います。

バックエンドとフロントエンドでディレクトリ構成が似通うので、backend/src/postsとfrontend/src/postsで直感的に参照しやすくなりそうな気がしています。

サンプル

というわけでHono(+Drizzle,SQLite)の前提でこの設計に基づき、ユーザーの登録機能を実装する想定をしてみます。

src/features/users/
 ├── schema.ts      # ① 型とDB定義(Zod & Drizzle)
 ├── repository.ts  # ② DB操作(純粋な関数)
 ├── service.ts     # ③ ビジネスロジック(純粋な関数)
 └── index.ts       # ④ ルーター(Honoアプリ・DI・型エクスポート)

schema.ts (データ定義とバリデーション)

Drizzleを使ったデータベースのテーブル定義と、Zodを使ったユーザーからの入力値の検証ルール(スキーマ)を定義

import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
import { z } from 'zod'

// 1. Drizzle ORMのテーブル定義(ピュアなTypeScript!)
export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
})

// 2. Zodのバリデーションスキーマ(入力チェック用)
export const createUserSchema = z.object({
  name: z.string().min(1, '名前は必須です'),
  email: z.string().email('正しいメールアドレス形式で入力してください'),
})

// スキーマからTypeScriptの型を抽出(Service層などで使います)
export type CreateUserInput = z.infer<typeof createUserSchema>

repository.ts (データアクセス層)

次に、DBと直接やり取りする処理を書きます。ここは「Honoを知らない純粋な関数」にするのがポイントです。

import { eq } from 'drizzle-orm'
import type { DrizzleD1Database } from 'drizzle-orm/d1'
import { users } from './schema'

// ※引数の db の型はDrizzleのインスタンス型を指定します
export const findUserByEmail = async (db: DrizzleD1Database, email: string) => {
  const result = await db.select().from(users).where(eq(users.email, email))
  return result[0] 
}

export const insertUser = async (db: DrizzleD1Database, name: string, email: string) => {
  const result = await db.insert(users).values({ name, email }).returning()
  return result[0] 
}

service.ts (ビジネスロジック層)

ここもHonoには依存しません。「すでに同じメールアドレスがないか?」などのビジネスルールをチェックし、問題なければリポジトリを呼び出します。

import type { DrizzleD1Database } from 'drizzle-orm/d1'
import { findUserByEmail, insertUser } from './repository'
import type { CreateUserInput } from './schema'

export const registerUserService = async (db: DrizzleD1Database, input: CreateUserInput) => {
  // 1. ビジネスルール: 既存ユーザーのチェック
  const existingUser = await findUserByEmail(db, input.email)
  if (existingUser) {
    throw new Error('このメールアドレスは既に登録されています')
  }

  // 2. 問題なければDBに保存
  const newUser = await insertUser(db, input.name, input.email)
  
  return newUser
}

index.ts (ルーター層 & エントリポイント)

最後にHonoのルーターです。ここが「入力の受付」「バリデーション」「依存関係(DB)の注入」「型の書き出し(Hono RPC用)」という重要な役割を担います。

import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import type { DrizzleD1Database } from 'drizzle-orm/d1'

import { createUserSchema } from './schema'
import { registerUserService } from './service'

// DrizzleD1Database を指定!
// これで c.get('db') が何を返すか、TypeScriptが理解します。
type Env = { Variables: { db: DrizzleD1Database } }

const usersRoute = new Hono<Env>()

const route = usersRoute.post(
  '/',
  zValidator('json', createUserSchema),
  async (c) => {
    const validData = c.req.valid('json')
    
    // ★ ここで取得する db は自動的に `DrizzleD1Database` 型になります
    const db = c.get('db')

    try {
      const user = await registerUserService(db, validData)
      
      return c.json({ success: true, user }, 201)
    } catch (error: any) {
      return c.json({ success: false, message: error.message }, 400)
    }
  }
)

export type UsersRouteType = typeof route
export { usersRoute }

Honoでとは言ってますが実際Honoが出てくるのはルーター層ですね。
Honoには特殊なルーターやRPCが備わっており、その辺は調べてみるととても面白いのでぜひ。

んでアプリ自体のエントリーポイントが以下のようになります
「グローバルなDI(依存性の注入)」と「ルートのマウント(ガッチャンコ)」です。

src/index.ts(アプリの全体エントリポイント)

import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { drizzle } from 'drizzle-orm/d1'
import type { DrizzleD1Database } from 'drizzle-orm/d1'

// 機能(ルーター)をインポート
import { usersRoute } from './features/users'

// 1. アプリ全体で使うContextの「型」を厳密に定義
type AppEnv = {
  Bindings: {
    // Cloudflare Workersの環境変数(wrangler.tomlで設定したD1のバインディング)
    DB: D1Database 
  },
  Variables: {
    // Drizzleのインスタンス型
    db: DrizzleD1Database 
  }
}

const app = new Hono<AppEnv>()

app.use('*', logger())

// 2. ミドルウェアでDrizzleを初期化してDI(依存性の注入)
app.use('*', async (c, next) => {
  // ★ c.env.DB (Cloudflareの生D1オブジェクト) をDrizzleでラップする
  const dbInstance = drizzle(c.env.DB)

  // Contextにセット! 
  // ※ Variablesの型を DrizzleD1Database に指定しているため、ここで別のものを入れようとするとTypeScriptが怒ってくれます。
  c.set('db', dbInstance)

  await next()
})

// 3. ルーターのマウント(ガッチャンコ)
const routes = app
  .basePath('/api')
  .route('/users', usersRoute)

// 4. Hono RPCの「型」をエクスポート
export type AppType = typeof routes

export default app

(Geiminiと会話してる流れのせいでCloudflare Workersが前提になってますね
こんな感じで、「機能を実装して、ルートを定義して、親ルーターにガッチャンコ」というのが基本の作業になります。
ここから機能を追加したらルーターのインポート&マウントの繰り返しです、多分。

終わり

正直なところ、設計しただけなのでここからアプリが肥大化したり共通モジュールみたいなものが出てきたらどうなるかという観点で設計やサンプルの実装を進めていきたいなと思います。

こういう単純めな実装フローを設計すれば、言語化の下手くそな私でもAI様に指示が出しやすいんじゃなかろうかと思っています。

ということで本日はこの辺で。
ご覧いただきありがとうございました。
皆様のAIコーディングライフに幸あれ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?