🛒

HonoXで構築する学園祭の模擬店向け注文管理システム

に公開

はじめに

私たちは東京理科大学の公認学生団体RICORA Programming Teamです。今年度の学園祭にて、学内のサークル向けに模擬店のための注文管理システムをHonoXで開発しました。本記事では、開発したアプリケーションの概要と、開発の際の技術選定やアーキテクチャ設計、テスト方法、運用事例について紹介します。

https://alg.tus-ricora.com/

https://nodaridaisai.com/2025/

アプリケーションの概要

アプリケーションのソースコードはOSSとして公開しています。

https://github.com/ricora/order

ダッシュボード

ダッシュボード画面
ダッシュボード画面

ログイン後に表示されるトップページです。注文一覧、注文登録、注文進捗管理、商品管理、売上分析、設定への各機能にクイックアクセスできます。

商品登録

商品登録画面
商品登録画面

商品の登録画面です。画面上部には在庫状況のサマリーが表示され、総商品数や在庫切れの商品数をひと目で把握できます。登録フォームでは商品名、画像、価格、在庫数、タグを設定できます。

商品一覧画面
商品一覧画面

登録済みの商品一覧です。各商品の画像、名前、タグ、価格、在庫数、在庫状態を確認でき、編集、削除の操作が可能です。テーブル表示とカード表示の切り替えに対応しています。

注文登録

注文登録画面
注文登録画面

レジ担当者が使用する注文登録画面です。左側の商品一覧から商品をクリックしてカートに追加し、数量を調整できます。顧客名や備考欄も入力可能で、注文内容を確定すると厨房に注文データが送信されます。

注文進捗管理

注文進捗管理画面
注文進捗管理画面

厨房や配膳担当者が使用するカンバン形式の進捗管理画面です。「処理待ち」「処理中」「完了」「取消済」の4つのステータスで注文を管理し、ボタン操作で状態を遷移させることができます。各カードには注文番号、注文内容、経過時間が表示されます。

注文履歴

注文履歴画面
注文履歴画面

過去の注文履歴をテーブル形式で一覧表示する画面です。注文ID、登録日時、更新日時、顧客名、注文内容、合計金額、コメント、ステータスを確認でき、個別の編集、削除も可能です。

背景

きっかけは、あるサークルから学園祭の模擬店で使用する注文管理システムの開発を依頼されたことでした。

依頼元のサークルでは、古くからExcel VBAによるレジプログラムとPHP/MySQLによるサーバープログラムを組み合わせ、XAMPPでローカルサーバーを立ち上げて運用する注文管理システムが存在していました。しかし、コロナ禍による学園祭の中止期間を経て、運用方法の引き継ぎが途絶えてしました。

その後はGoogle FormsとSpreadsheetで運用を続けていましたが、この方法では注文受付から調理、提供までの状態管理ができないという課題がありました。学園祭の模擬店では、どの注文が調理中でどの注文が提供済みかをリアルタイムに把握できることが重要です。

こうした経緯から、私たちが新たな注文管理システムの開発を引き受けました。

技術選定

HonoX(HonoとVite)を中心に、導入によってもたらされる複雑さに見合った、本当に必要だと思う最小限の依存関係を選定しました。

HonoX

HonoXは、WebフレームワークのHonoとフロントエンドビルドツールのViteを組み合わせたメタフレームワークです。HonoXはViteのプラグインとして提供されており、HonoやViteの豊富なエコシステムを柔軟に活用できる点が特徴です。

https://github.com/honojs/honox

https://hono.dev/

https://vite.dev/

HonoXの特徴や設計思想については、作者様による解説記事がありますので、未読の方はぜひそちらもご一読ください。

https://zenn.dev/yusukebe/articles/724940fa3f2450

https://zenn.dev/yusukebe/articles/4d6297f3be121a

当初はNext.jsなどの強力なフルスタックフレームワークも選択肢にありました。しかし、多機能であるがゆえに生じる複雑さから、学習負荷が高くなる点は無視できないと考えています。また、手軽さを実現するために、内部で隠蔽された複雑な処理や多くの依存関係が存在するため、予期せぬトラブルや対処の難しさにつながることが懸念されます。さらに、こうした大規模なフレームワークでは、破壊的なアップデートがあった場合の対応が難しく、継続的な保守の観点でもリスクがあると考えています。

一方で、Honoのエコシステムは現在も活発にメンテナンスされていますが、ルーターなどのコアの機能については十分に枯れていると考えています。私たちの要件では高度な最適化は不要であり、シンプルなコアに対して、用途に応じて必要な分だけ機能(複雑さ)を追加できるPluggableなフレームワークは、合理的な選択であると判断しました。

Drizzle ORM

Drizzle ORMはTypeScriptファーストなORMです。スキーマをTypeScriptで定義できること、SQLライクな低レベルAPIとテーブル結合などを隠蔽した高レベルAPIの両方が使えること、さらにマイグレーションツールのDrizzle Kitが実行時のランタイムから独立しておりバンドルサイズが小さいことに魅力を感じました。同じくTypeScriptファーストなHonoとの相性も良いと考え、Drizzle ORMを採用しています。

https://orm.drizzle.team/

RDBMSについては、将来的にモバイルオーダーやマルチテナント機能を追加し、同時接続数の増加に対応することを見据えて、PostgreSQLを採用しています。

https://www.postgresql.org/

当初はSQLiteも検討しましたが、同時書き込みに制約があります。学園祭の模擬店では注文が短時間に集中することが想定されるため、同時書き込み性能に優れたPostgreSQLが適していると判断しました。

なお、開発と一部のテスト環境ではPGliteを利用しています。これにより、PostgreSQL互換の環境を高速に立ち上げることができます。

https://pglite.dev/

Tailwind CSS

HonoXのStarter templateにはデフォルトでTailwind CSSが組み込まれています。CSSフレームワークの選定には特に強いこだわりがなかったため、そのままTailwind CSSを利用しています。また、宣言的にスタイルを定義できるようにするため、追加でtailwind-variantsを採用しています。

https://tailwindcss.com/

https://www.tailwind-variants.org/

アーキテクチャ

継続的な開発と保守がしやすいコードを目指して試行錯誤を重ねた結果、次のような構成に落ち着きました。

まず、プロジェクトのディレクトリ構成は次のようになっています。

app
├── domain
│   └── ${domain-name}
│       ├── adapters.ts
│       ├── constants.ts
│       ├── entities.ts
│       └── repositories.ts
├── libs
│   └── ${lib-name}
├── routes
│   └── ${path}
│       ├── -components
│       ├── -helpers
│       ├── -hooks
│       └── index.tsx
├── usecases
│   ├── commands
│   │   └── ${use-case-name}.ts
│   └── queries
│       └── ${use-case-name}.ts
└── utils
    └── ${util-name}.ts

次に、私たちが定義したレイヤードアーキテクチャの各レイヤーの役割と、ディレクトリとの対応関係について説明します。

レイヤーの役割

Utility, Library

UtilityとLibraryは、特定のレイヤーに依存しない、グローバルに使用される汎用的な関数やオブジェクトを管理します。

Utilityでは、日付のフォーマットやURLのクエリ文字列の操作などの、頻出するユースケースに応じて標準APIをラップした小さな関数群を管理します。対応するディレクトリはapp/utilsです。

Libraryでは、特定のサードパーティライブラリに依存するコード群を管理します。具体的には、Drizzle ORMのDBクライアントの初期化処理や型定義、テーブルのスキーマ定義などを管理しています。対応するディレクトリはapp/libsです。

なお、DBクライアントの型定義については、DBのトランザクション管理の都合上、グローバルに参照されることを認めています。

app/libs/db/client.ts
import type { PgliteDatabase } from "drizzle-orm/pglite"
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"
import * as schema from "./schema"

/** Query系で利用するDBクライアント */
export type DbClient =
  | PgliteDatabase<typeof schema>
  | PostgresJsDatabase<typeof schema>

/** Command系で利用するDBクライアント */
export type TransactionDbClient = Parameters<
  Parameters<DbClient["transaction"]>[0]
>[0]

Domain

Domainは、アプリケーションの核となる「主要な概念をモデル化したオブジェクトの構造」や「永続化するオブジェクトの入出力の最小単位」、「オブジェクト同士の整合性を保つための連動的な操作」等を管理するレイヤーです。例えば、「1つの商品に最大20個まで商品タグを付与できる」や「商品を削除した際に孤立した商品タグも自動で削除する」などの整合性のルールの検証を担います。

「モデル化したオブジェクト」や「永続化するオブジェクトの入出力の最小単位」は、アプリケーションが解決しようとする課題ごとにドメインとして分割されます。例えば、商品を管理する「商品」ドメインや、注文を管理する「注文」ドメインなどです。

対応するディレクトリはapp/domainで、各ドメイン毎にサブディレクトリapp/domain/${domain-name}を持ちます。ただし、Adaptersであるapp/domain/${domain-name}/adapters.tsは例外的に後述するInfrastructure層に属します。Adaptersの詳細やこのようにしている理由についても後述します。

ここからはDomainの構成要素について説明します。

まず、「ある概念をモデル化した一意な識別子を持つオブジェクト」をEntityと定義します。EntityはTypeScriptの型で表現します。例えば、「注文」と「注文の変更履歴」を表すEntityは次のように定義します。

app/domain/order/entities.ts
export type Order = {
  id: number
  customerName: string | null
  comment: string | null
  createdAt: Date
  updatedAt: Date
  status: "pending" | "processing" | "completed" | "cancelled"
  orderItems: {
    productId: number | null
    productName: string
    unitAmount: number
    quantity: number
  }[]
  totalAmount: number
}

export type OrderEditHistory = {
  id: number
  orderId: number
  createdAt: Date
  customerName: string | null
  comment: string | null
  status: "pending" | "processing" | "completed" | "cancelled" | null
}

次に、「Entityの参照と更新操作の抽象」をRepositoryと定義します。Repositoryはデータをどのように永続化するか(実装詳細)については関知せず、入出力の型やデータのバリデーション、オブジェクト同士の整合性チェックといった、ドメインのルールを担保する役割を持ちます。

ここで、Repository内で発生するエラーについては、ユーザーフレンドリーなエラーとクライアントに秘匿したい内部エラーを安全に分離したいです。しかし、TypeScriptでは例外(throwされたエラー)を型として表現できないため、失敗情報を他のレイヤーへ伝搬するには、戻り値として成功・失敗を明示的にオブジェクトで表現する必要があります。そこで、汎用的な成功・失敗の結果を表すためのResult<T, E>型を用意しています。これにより、okプロパティで成否を判定し、失敗時は型安全にエラーメッセージを伝えることができます。なお、想定外のエラーが発生した場合は、クライアント向けに汎用のエラーメッセージを返す方針としています。

app/domain/types.ts
type Success<T> = {
  ok: true
  value: T
}

type Failure<E extends string> = {
  ok: false
  message: E
}

type UnexpectedError = Failure<"エラーが発生しました。">

/**
 * 汎用的な成功・失敗の結果を表す型。
 * 成功時には`Success<T>`型、失敗時には`Failure<E>`型を返す。
 * @template T 成功時の値の型
 * @template E 失敗時のエラーメッセージの型
 * @example
 * type FetchResult = Result<User, "not found" | "unauthorized">
 */
export type Result<T, E extends string> =
  | Success<T>
  | Failure<E>
  | UnexpectedError

このResult<T, E>型を用いて、Repository関数の型を次のように定義します。DB参照系のQuery系Repository関数と、DB更新系のCommand系Repository関数で分けて定義しています。さらに、Query系Repository関数については、ページネーション対応のために複数要素を取得する場合の型定義を用意しています。

app/domain/types.ts
import type { DbClient, TransactionDbClient } from "../libs/db/client"

/**
 * 単数の要素を取得するQuery系リポジトリ関数の型定義。
 * DB参照系のRepository関数はこの型を使用して定義する。
 * @template P paramsの型
 * @template T 戻り値の型
 * @example
 * type FindById = QueryRepositoryFunction<{ id: number }, Product | null, 'not found' | 'unauthorized'>
 * type FindAll = QueryRepositoryFunction<unknown, Product[]>
 */
export type QueryRepositoryFunction<P, T, E extends string> = (
  params: P & { dbClient: DbClient | TransactionDbClient },
) => Promise<Result<T, E>>

/**
 * 複数の要素を取得するQuery系リポジトリ関数の型定義。
 * DB参照系のRepository関数はこの型を使用して定義する。
 * Paginationのためのパラメータが自動的に追加される。
 * @template P paramsの型
 * @template T 戻り値の型
 * @example
 * type FindAllProducts = PaginatedQueryRepositoryFunction<unknown, Product, 'not found' | 'unauthorized'>
 */
export type PaginatedQueryRepositoryFunction<P, T, E extends string> = (
  params: P & {
    pagination: PaginationParams
    dbClient: DbClient | TransactionDbClient
  },
) => Promise<Result<T[], E>>

/**
 * Command系リポジトリ関数の型定義。
 * DB更新系のRepository関数はこの型を使用して定義する。
 * @template P paramsの型
 * @template T 戻り値の型
 * @example
 * type CreateProduct = CommandRepositoryFunction<Omit<Product, "id">, Product | null, 'validation error' | 'unauthorized'>
 * type DeleteAll = CommandRepositoryFunction<unknown, void>
 */
export type CommandRepositoryFunction<P, T, E extends string> = (
  params: P & { dbClient: TransactionDbClient },
) => Promise<Result<T, E>>

/**
 * ページネーション用パラメータ型。
 * Query系リポジトリ関数で一括取得時に使用する。
 * @example
 * type FindAllProducts = PaginatedQueryRepositoryFunction<{ pagination: PaginationParams }, Product[]>
 */
type PaginationParams = {
  /**
   * 取得を開始するオフセット(0-indexed)。
   */
  offset: number
  /**
   * 取得する件数の上限。
   */
  limit: number
}

これらの型定義を用いて、例えば「注文」ドメインのRepositoryは次のように定義します。

各DomainのRepositoryは、参照または更新操作のための関数をオブジェクトとして一括管理します。createRepository関数は、後述するInfrastructure層のAdaptersを受け取り、Domain層で定義したRepository型のオブジェクトを生成します。Repositoryでは、内部の関数を組み合わせてバリデーションや整合性チェックなどのドメインのルールを実装します。検証がすべて通った場合のみ、最終的にAdaptersの同名の関数を呼び出してデータの永続化を行います。

例えば、updateOrder関数では、まずfindOrderByIdを使って更新対象の注文が存在するかを確認します。次に、入力のバリデーションを行い、問題がなければ注文情報の変更履歴をcreateOrderEditHistoryで保存し、最後にadapters.updateOrderを呼び出しています。

なお、実装例では説明のために意図的に実装を簡略化しており、バリデーションは不十分です。

app/domain/order/repositories.ts
import { countStringLength } from "../../utils/text"
import type {
  CommandRepositoryFunction,
  PaginatedQueryRepositoryFunction,
  QueryRepositoryFunction,
} from "../types"
import type { Order, OrderEditHistory } from "./entities"

export type Repository = {
  // Query
  findOrderById: QueryRepositoryFunction<
    { order: Pick<Order, "id"> },
    Order,
    "注文が見つかりません。"
  >
  findAllOrdersOrderByIdAsc: PaginatedQueryRepositoryFunction<
    unknown,
    Order,
    never
  >

  // Command
  createOrder: CommandRepositoryFunction<
    { order: Omit<Order, "id"> },
    Order,
    | "新規に登録する注文の状態は'pending'である必要があります。"
    | "注文項目は1種類以上20種類以下である必要があります。"
    | "合計金額が正しくありません。"
  >
  updateOrder: CommandRepositoryFunction<
    {
      order: Pick<Order, "id" | "updatedAt"> &
        Partial<Pick<Order, "customerName" | "comment" | "status">>
    },
    Order,
    | "注文が見つかりません。"
    | "顧客名は50文字以内である必要があります。"
    | "コメントは250文字以内である必要があります。"
  >
  deleteOrder: CommandRepositoryFunction<
    { order: Pick<Order, "id"> },
    void,
    never
  >
  createOrderEditHistory: CommandRepositoryFunction<
    { orderHistory: Omit<OrderEditHistory, "id"> },
    OrderEditHistory,
    never
  >
}

export const createRepository = (adapters: Repository) => {
  const repository = {
    findOrderById: async ({ dbClient, order }) => {
      return adapters.findOrderById({ dbClient, order })
    },
    findAllOrdersOrderByIdAsc: async ({ dbClient, pagination }) => {
      return adapters.findAllOrdersOrderByIdAsc({ dbClient, pagination })
    },
    createOrder: async ({ dbClient, order }) => {
      if (order.status !== "pending") {
        return {
          ok: false,
          message: "新規に登録する注文の状態は'pending'である必要があります。",
        }
      }
      if (order.orderItems) {
        if (
          order.totalAmount !==
          order.orderItems.reduce(
            (sum, item) => sum + item.unitAmount * item.quantity,
            0,
          )
        ) {
          return {
            ok: false,
            message: "合計金額が正しくありません。",
          }
        }
        if (order.orderItems.length < 1 || order.orderItems.length > 20) {
          return {
            ok: false,
            message: "注文項目は1種類以上20種類以下である必要があります。",
          }
        }
      }
      return adapters.createOrder({ order, dbClient })
    },
    updateOrder: async ({ dbClient, order }) => {
      const foundOrder = await repository.findOrderById({
        dbClient,
        order: { id: order.id },
      })
      if (!foundOrder.ok) {
        return {
          ok: false,
          message: "注文が見つかりません。",
        }
      }
      if (
        order.comment !== undefined &&
        order.comment !== null &&
        countStringLength(order.comment) > 250
      ) {
        return {
          ok: false,
          message: "コメントは250文字以内である必要があります。",
        }
      }
      if (
        order.customerName !== undefined &&
        order.customerName !== null &&
        countStringLength(order.customerName) > 50
      ) {
        return {
          ok: false,
          message: "顧客名は50文字以内である必要があります。",
        }
      }
      const createOrderEditHistoryResult =
        await repository.createOrderEditHistory({
          dbClient,
          orderHistory: {
            orderId: order.id,
            createdAt: new Date(),
            customerName:
              order.customerName !== undefined ? order.customerName : null,
            comment: order.comment !== undefined ? order.comment : null,
            status: order.status !== undefined ? order.status : null,
          },
        })
      if (!createOrderEditHistoryResult.ok) {
        return {
          ok: false,
          message: "エラーが発生しました。",
        }
      }
      return adapters.updateOrder({ order, dbClient })
    },
    deleteOrder: async ({ dbClient, order }) => {
      return adapters.deleteOrder({ dbClient, order })
    },
    createOrderEditHistory: async ({ dbClient, orderHistory }) => {
      return adapters.createOrderEditHistory({ dbClient, orderHistory })
    },
  } satisfies Repository

  return repository
}

バリデーションで使用する定数値のうち、クライアントとサーバーの両方で参照したいものは、app/domain/${domain-name}/constants.tsでまとめて管理します。

Infrastructure

Infrastructureは、データの参照や更新操作の実装詳細を管理するレイヤーです。具体的には、Drizzle ORMを利用したDBアクセスの実装を管理します。対応するディレクトリはapp/domain/${domain-name}/adapters.tsです。

ここでは、「Entityの参照と更新操作の抽象に対する実装」をAdaptersとして定義し、Domain層で定義されたRepositoryの型を満たすオブジェクトとして実装します。例えば、「注文」ドメインのAdaptersは次のように実装します。

app/domain/order/adapters.ts
import { asc, eq } from "drizzle-orm"
import {
  orderEditHistoryTable,
  orderItemTable,
  orderTable,
} from "../../libs/db/schema"
import type { Repository } from "./repository"

export const adapters = {
  findOrderById: async ({ dbClient, order }) => {
    const dbOrder = await dbClient.query.orderTable.findFirst({
      where: eq(orderTable.id, order.id),
      with: {
        orderItems: true,
      },
    })
    if (!dbOrder)
      return {
        ok: false,
        message: "注文が見つかりません。",
      }

    return {
      ok: true,
      value: {
        id: dbOrder.id,
        customerName: dbOrder.customerName,
        comment: dbOrder.comment,
        createdAt: dbOrder.createdAt,
        status: dbOrder.status,
        updatedAt: dbOrder.updatedAt,
        orderItems: dbOrder.orderItems.map((item) => ({
          productId: item.productId,
          productName: item.productName,
          unitAmount: item.unitAmount,
          quantity: item.quantity,
        })),
        totalAmount: dbOrder.totalAmount,
      },
    }
  },

  findAllOrdersOrderByIdAsc: async ({ dbClient, pagination }) => {
    const dbOrders = await dbClient.query.orderTable.findMany({
      with: {
        orderItems: true,
      },
      orderBy: [asc(orderTable.id)],
      offset: pagination.offset,
      limit: pagination.limit,
    })
    return {
      ok: true,
      value: dbOrders.map((dbOrder) => ({
        id: dbOrder.id,
        customerName: dbOrder.customerName,
        comment: dbOrder.comment,
        createdAt: dbOrder.createdAt,
        status: dbOrder.status,
        updatedAt: dbOrder.updatedAt,
        orderItems: dbOrder.orderItems.map((item) => ({
          productId: item.productId,
          productName: item.productName,
          unitAmount: item.unitAmount,
          quantity: item.quantity,
        })),
        totalAmount: dbOrder.totalAmount,
      })),
    }
  },

  createOrder: async ({ dbClient, order }) => {
    const dbOrder = (
      await dbClient
        .insert(orderTable)
        .values({
          customerName: order.customerName,
          comment: order.comment,
          createdAt: order.createdAt,
          status: order.status,
          totalAmount: order.totalAmount,
          updatedAt: order.createdAt,
        })
        .returning()
    )[0]
    if (!dbOrder) {
      return {
        ok: false,
        message: "エラーが発生しました。",
      }
    }

    const dbOrderItems = await dbClient
      .insert(orderItemTable)
      .values(
        order.orderItems.map((item) => ({
          orderId: dbOrder.id,
          productId: item.productId,
          quantity: item.quantity,
          unitAmount: item.unitAmount,
          productName: item.productName,
        })),
      )
      .returning()

    return {
      ok: true,
      value: {
        id: dbOrder.id,
        customerName: dbOrder.customerName,
        comment: dbOrder.comment,
        createdAt: dbOrder.createdAt,
        status: dbOrder.status,
        updatedAt: dbOrder.updatedAt,
        orderItems: dbOrderItems.map((item) => ({
          productId: item.productId,
          productName: item.productName,
          unitAmount: item.unitAmount,
          quantity: item.quantity,
        })),
        totalAmount: dbOrder.totalAmount,
      }
    }
  },

  updateOrder: async ({ dbClient, order }) => {
    const updatedOrder = (
      await dbClient
        .update(orderTable)
        .set({
          customerName: order.customerName,
          comment: order.comment,
          status: order.status,
          updatedAt: order.updatedAt,
        })
        .where(eq(orderTable.id, order.id))
        .returning()
    )[0]
    if (!updatedOrder) {
      return {
        ok: false,
        message: "注文が見つかりません。",
      }
    }

    const dbOrderItems = await dbClient
      .select()
      .from(orderItemTable)
      .where(eq(orderItemTable.orderId, updatedOrder.id))

    return {
      ok: true,
      value: {
        id: updatedOrder.id,
        customerName: updatedOrder.customerName,
        comment: updatedOrder.comment,
        createdAt: updatedOrder.createdAt,
        status: updatedOrder.status,
        updatedAt: updatedOrder.updatedAt,
        orderItems: dbOrderItems.map((item) => ({
          productId: item.productId,
          productName: item.productName,
          unitAmount: item.unitAmount,
          quantity: item.quantity,
        })),
        totalAmount: updatedOrder.totalAmount,
      },
    }
  },

  deleteOrder: async ({ dbClient, order }) => {
    await dbClient
      .delete(orderItemTable)
      .where(eq(orderItemTable.orderId, order.id))
    await dbClient.delete(orderTable).where(eq(orderTable.id, order.id))
    return {
      ok: true,
      value: undefined,
    }
  },

  createOrderEditHistory: async ({ dbClient, orderHistory }) => {
    const dbOrderEditHistory = (
      await dbClient
        .insert(orderEditHistoryTable)
        .values({
          orderId: orderHistory.orderId,
          createdAt: orderHistory.createdAt,
          customerName: orderHistory.customerName,
          comment: orderHistory.comment,
          status: orderHistory.status,
        })
        .returning()
    )[0]
    if (!dbOrderEditHistory) {
      return {
        ok: false,
        message: "エラーが発生しました。",
      }
    }
    return {
      ok: true,
      value: {
        id: dbOrderEditHistory.id,
        orderId: dbOrderEditHistory.orderId,
        createdAt: dbOrderEditHistory.createdAt,
        customerName: dbOrderEditHistory.customerName,
        comment: dbOrderEditHistory.comment,
        status: dbOrderEditHistory.status,
      },
    }
  },
} satisfies Repository

なお、AdaptersはDomain層のRepositoryと同じ階層にコロケーションしています。理想的には、Repositoryは技術的な詳細に依存しません。しかし実際には、Repositoryの各関数は、インデックスやキャッシュの戦略などの使用するデータベースの仕様や制約に依存することが多いです。RepositoryとAdaptersは密接に連動して変更されることが多いため、保守性の観点から同じ階層に配置しています。

Use-case

Use-caseは、DomainのRepository関数を組み合わせてユーザーが求める機能を実現するレイヤーです。Domainを横断した処理を実装し、DBのトランザクションを管理することで機能の原子性を保証します。また、クライアントに返すべきエラーメッセージのバリデーションも担います。対応するディレクトリはapp/usecasesです。Query系のUse-caseはapp/usecases/queries、Command系のUse-caseはapp/usecases/commandsにそれぞれ配置します。

Use-case関数を実装する際には、Adaptersを注入したRepositoryオブジェクトと共通の型定義を利用します。

app/usecases/repositories-provider.ts
import { adapters as orderAdapters } from "../domain/order/adapters"
import { createRepository as createOrderRepository } from "../domain/order/repository"
import { adapters as productAdapters } from "../domain/product/adapters"
import { createRepository as createProductRepository } from "../domain/product/repository"
// ...

export const orderRepository = createOrderRepository(orderAdapters)
export const productRepository = createProductRepository(productAdapters)
// ...
app/usecases/types.ts
import type { Result } from "../domain/types"
import type { DbClient } from "../libs/db/client"

/**
 * ユースケース関数の型定義。
 * ユースケース関数はこの型を使用して定義する。
 * @template P paramsの型
 * @template T 成功時の戻り値の型
 * @template E 失敗時のエラーメッセージの型
 * @example
 * type RegisterProduct = UsecaseFunction<{ name: string; price: number }, { id: number }, 'validation error' | 'conflict'>
 */
export type UsecaseFunction<P, T, E extends string> = (
  params: P & { dbClient: DbClient },
) => Promise<Result<T, E>>

Query系のUse-case関数では、ページやアイランドコンポーネント単位で必要なデータをまとめて取得する処理を実装します。ここで扱うのはデータの構造や内容であり、UIのレイアウトなどのデータの表示方法については関知しません。例えば、商品の編集ページで必要な商品データを取得するUse-case関数は次のように実装します。

app/usecases/queries/getProductEditPageData.ts
import type { Product } from "../../domain/product/entities"
import { productRepository } from "../repositories-provider"
import type { UsecaseFunction } from "../types"

const { findProductById } = productRepository

export type GetProductEditPageData = UsecaseFunction<
  { product: Pick<Product, "id"> },
  { product: Product },
  "商品が見つかりません。"
>

export const getProductEditPageData: GetProductEditPageData = async ({
  dbClient,
  product,
}) => {
  try {
    const foundProductResult = await findProductById({ dbClient, product })
    if (!foundProductResult.ok) {
      if (foundProductResult.message === "商品が見つかりません。") {
        return { ok: false, message: "商品が見つかりません。" }
      }
      return { ok: false, message: "エラーが発生しました。" }
    }
    return { ok: true, value: { product: foundProductResult.value } }
  } catch {
    return { ok: false, message: "エラーが発生しました。" }
  }
}

Command系のUse-case関数では、ユーザーによる更新系の操作に対応した一連の処理を実装します。Drizzle ORMのtransaction()では、ハンドラー内でJavaScriptの標準のエラーが投げられた場合にRollbackがされます。返却する値は、利用する場面や要件に応じて柔軟に設計します。例えば、注文の状態を更新するUse-case関数は次のように実装します。

app/usecases/commands/setOrderStatus.ts
import type { Order } from "../../domain/order/entities"
import { orderRepository } from "../repositories-provider"
import type { UsecaseFunction } from "../types"

const { updateOrder } = orderRepository

export type SetOrderStatusError =
  | "エラーが発生しました。"
  | "注文が見つかりません。"
export type SetOrderStatus = UsecaseFunction<
  { order: Pick<Order, "id" | "status"> },
  Order,
  SetOrderStatusError
>

export const setOrderStatus: SetOrderStatus = async ({ dbClient, order }) => {
  let errorMessage: SetOrderStatusError = "エラーが発生しました。"
  try {
    const txResult = await dbClient.transaction(async (tx) => {
      const result = await updateOrder({
        dbClient: tx,
        order: { id: order.id, status: order.status, updatedAt: new Date() },
      })

      if (!result.ok) {
        if (result.message === "注文が見つかりません。") {
          errorMessage = "注文が見つかりません。"
        }
        throw new Error()
      }
      return { ok: true, value: result.value } as const
    })

    return txResult
  } catch {
    return { ok: false, message: errorMessage } as const
  }
}

Interface

Interfaceは、HTTPリクエストを受け取り、適切なUse-case関数を呼び出してレスポンスを作成し、クライアントへ返すレイヤーです。HonoXでは、app/routesディレクトリでファイルベースルーティングを行うため、対応するディレクトリはapp/routesです。

例えば、注文編集ページのルートハンドラーは次のように実装します。UIはHono JSXで構築します。必要なコンポーネントやロジックは、まずルートハンドラーと同じファイル内でPrivateに定義し、複数ページで使い回す必要が出てきた場合に-components-helpersディレクトリへ分離します。クライアント側のインタラクションが必要な場合は、アイランドコンポーネントを追加します。アイランドコンポーネントはHonoXの仕様に従い、ファイル名の先頭に$を付けて作成します。

https://hono.dev/docs/guides/jsx

https://hono.dev/docs/guides/jsx-dom

app/routes/staff/orders/[id]/edit/index.tsx
import { validator } from "hono/validator"
import { createRoute } from "honox/factory"
import { setOrderDetails } from "../../../../../usecases/commands/setOrderDetails"
import { getOrderEditPageData } from "../../../../../usecases/queries/getOrderEditPageData"
import { setToastCookie } from "../../../../-helpers/ui/toast"
import Layout from "../../../-components/layout"
import OrderSummary from "../-components/orderSummary"
import OrderEditForm from "./-components/$orderEditForm"

export const POST = createRoute(
  validator("form", (value, c) => {
    const customerNameRaw = value.customerName
    const commentRaw = value.comment
    const statusRaw = value.status
    const customerName =
      typeof customerNameRaw === "string"
        ? customerNameRaw.trim().length > 0
          ? customerNameRaw.trim()
          : null
        : null

    const comment =
      typeof commentRaw === "string"
        ? commentRaw.trim().length > 0
          ? commentRaw.trim()
          : null
        : null

    const allowedStatuses = [
      "pending",
      "processing",
      "completed",
      "cancelled",
    ] as const
    type OrderStatus = (typeof allowedStatuses)[number]
    const isOrderStatus = (v: unknown): v is OrderStatus =>
      typeof v === "string" && allowedStatuses.some((s) => s === v)

    if (!isOrderStatus(statusRaw)) {
      setToastCookie(c, "error", "不正なリクエストです")
      return c.redirect(c.req.url)
    }
    const status = statusRaw
    return { order: { customerName, comment, status } }
  }),
  async (c) => {
    const id = Number(c.req.param("id"))
    if (!Number.isInteger(id) || id <= 0) {
      return c.notFound()
    }

    const { order } = c.req.valid("form")
    const res = await setOrderDetails({
      dbClient: c.get("dbClient"),
      order: { id, ...order },
    })
    if (!res.ok) {
      setToastCookie(c, "error", res.message)
      return c.redirect(c.req.url)
    }
    setToastCookie(c, "success", "注文を更新しました")
    return c.redirect("/staff/orders")
  },
)

export default createRoute(async (c) => {
  const idParam = c.req.param("id")
  const id = Number(idParam)
  if (!Number.isInteger(id) || id <= 0) {
    return c.notFound()
  }

  const res = await getOrderEditPageData({
    order: { id },
    dbClient: c.get("dbClient"),
  })
  if (!res.ok) {
    if (res.message === "注文が見つかりません。") return c.notFound()
    throw new Error(res.message)
  }
  const { order } = res.value
  if (!order) return c.notFound()

  return c.render(
    <Layout title="注文編集" description="注文情報の編集を行います。">
      <div class="rounded-lg border bg-bg p-6">
        <h2 class="mb-2 font-bold text-lg">注文編集</h2>
        <div class="p-4">
          <OrderSummary order={order} />
          <div class="mt-4">
            <OrderEditForm initialValues={order} />
          </div>
        </div>
      </div>
    </Layout>,
  )
})

Drizzle ORMのDBクライアントは、Honoのミドルウェアでリクエストコンテキストに注入しているため、c.get("dbClient")で取得できます。

app/global.d.ts
import type {} from "hono"

declare module "hono" {
  interface Env {
    Variables: {
      dbClient: import("./libs/db/client").DbClient
    }
  }
}
app/routes/_middleware.ts
import { createMiddleware } from "hono/factory"
import { createRoute } from "honox/factory"
import { createDbClient } from "../libs/db/client"

export default createRoute(
  createMiddleware(async (c, next) => {
    c.set("dbClient", await createDbClient())
    await next()
  }),
)

また、アイランドコンポーネントで使用するAPIは、Hono RPCを利用するために、次のようにapp/routes/api/index.tsxで一括管理します。

https://hono.dev/docs/guides/rpc

app/routes/api/index.tsx
import type { Env } from "hono"
import { Hono } from "hono"
import { validator } from "hono/validator"
import type { Order } from "../../domain/order/entities"
import { setOrderStatus } from "../../usecases/commands/setOrderStatus"
import { getOrderProgressManagerComponentData } from "../../usecases/queries/getOrderProgressManagerComponentData"
import { getOrderRegistrationFormComponentData } from "../../usecases/queries/getOrderRegistrationFormComponentData"
import { getProductRegistrationFormComponentData } from "../../usecases/queries/getProductRegistrationFormComponentData"

/**
 * Web API for island components.
 */
const app = new Hono<Env>()
const routes = app
  .get("/order-registration-form", async (c) => {
    const res = await getOrderRegistrationFormComponentData({
      dbClient: c.get("dbClient"),
    })
    if (!res.ok) {
      throw new Error(res.message)
    }
    return c.json(res.value)
  })
  .get("/order-progress-manager", async (c) => {
    const res = await getOrderProgressManagerComponentData({
      dbClient: c.get("dbClient"),
    })
    if (!res.ok) throw new Error(res.message)
    return c.json(res.value)
  })
  .post(
    "/order-progress-manager/set-status",
    validator("json", (value, c) => {
      const orderId = value.orderId
      const status = value.status

      const isValidStatus = (value: unknown): value is Order["status"] => {
        return (
          typeof value === "string" &&
          ["pending", "processing", "completed", "cancelled"].includes(value)
        )
      }
      if (
        typeof orderId !== "number" ||
        !Number.isInteger(orderId) ||
        orderId < 1 ||
        typeof status !== "string" ||
        !isValidStatus(status)
      ) {
        return c.text("Invalid request", 400)
      }
      return { orderId, status }
    }),
    async (c) => {
      const { orderId, status } = await c.req.valid("json")
      const setRes = await setOrderStatus({
        dbClient: c.get("dbClient"),
        order: { id: orderId, status },
      })
      if (!setRes.ok) {
        if (setRes.message === "注文が見つかりません。") {
          return c.json({ message: setRes.message }, 404)
        }
        throw new Error(setRes.message)
      }
      const payloadRes = await getOrderProgressManagerComponentData({
        dbClient: c.get("dbClient"),
      })
      if (!payloadRes.ok) throw new Error(payloadRes.message)
      return c.json(payloadRes.value, 200)
    },
  )
  .get("/product-registration-form", async (c) => {
    const res = await getProductRegistrationFormComponentData({
      dbClient: c.get("dbClient"),
    })
    if (!res.ok) throw new Error(res.message)
    return c.json(res.value, 200)
  })

export default app
export type AppType = typeof routes

クライアントからは次のように型安全なAPIを呼び出せます。

$orderRegistrationForm.tsx
const honoClient = hc<AppType>("/api")
const response = await honoClient["order-registration-form"].$get()
if (!response.ok) {
  throw new Error(
    `Failed to fetch products: ${response.status} ${response.statusText}`,
  )
}
const { products: fetchedProducts, tags: fetchedTags } = await response.json()

/*
Promise<{
  products: {
      id: number;
      name: string;
      price: number;
      stock: number;
      tags: string[];
  }[];
  tags: {
      id: number;
      name: string;
  }[];
}>
*/

レイヤーの依存関係

これらのレイヤーの依存関係は変更頻度の高い(不安定な)ものが低い(安定な)ものを一方的に参照する形になっています。これにより、変更の影響範囲を小さく抑えることができます。

さらに、Domain層はInfrastructure層への依存を直接持ちません。Use-case層がInfrastructure層のAdapterを受け取ることで、依存性逆転(Dependency Inversion)を実現しています。これにより、ドメインロジックが実装の詳細から切り離され、テストや保守が容易になります。また、Infrastructure層の変更がDomain層に波及しにくく、柔軟な拡張や差し替えが可能です。

なお、今回のような要件においては、このアーキテクチャは過剰に感じられるかもしれません。しかし、来年度以降の引継ぎを見据えた保守性や拡張性の確保と、メンバーの技術力向上のための教育機会の提供を重視し、さらには強力なAIエージェントの普及が進む中で、このような厳格な設計こそがAIに対するガードレールやコンテキストとして機能すると考え、あえてこの構成を採用しています。

システム運用

ホスティング

HonoはCloudflare Workersとの相性が良いため、当初はCloudflare Workersを使いたいと考えていました。しかし、PostgreSQLを外部で構築しなければならず、マルチクラウド構成はAttack Surfaceを増やすことになるため避けたいと考えました。

そこでAWSに寄せてLambda + Aurora Serverless v2での構成を検討しましたが、予算の制約から断念しました。

最終的にVPSを採用しました。数ドルの追加でフルバックアップが取得でき、機能も充実しているAkamai Cloudを選択し、Docker Composeでアプリケーションとデータベースを同一サーバー上で運用する構成にしました。

また、BCP(事業継続計画)の観点も考慮しています。データベースのバックアップは、rcloneを使って1時間ごとにAkamai CloudのObject Storageへ自動的に保存しています。加えて、Akamai Cloudのフルバックアップ機能を使い、システムを利用していない深夜帯に毎日VPS全体のバックアップを取得しています。これにより、万が一の障害時にも迅速な復旧が可能です。さらに、Docker Composeで構成しているため、万が一の場合は部屋にミニPCとアクセスポイントを設置してローカルネットワークを構築し、その場でシステムを稼働させることもできます。

https://rclone.org

https://www.akamai.com/ja/products/object-storage

監視

Docker Composeが出力するコンテナのログは、Fluent Bitを使って収集しています。Fluent BitはC言語で実装された軽量なログ収集・転送ツールです。低リソースで動作するため、VPS上での運用に適しています。また、VPSのCPU・メモリ・ディスクなどのメトリクスはGrafana Alloyで収集しています。

https://fluentbit.io

https://grafana.com/ja/oss/alloy-opentelemetry-collector/

収集したログとメトリクスはGrafana Cloudに送信し、可視化・検索できるようにしています。また、errorレベル以上の重大なログはDiscord Webhookに通知することで、問題の早期発見に役立てています。

なお、Grafana Cloudのダッシュボード構築やクエリ作成には、Grafana MCP Serverを活用しました。これはModel Context Protocol(MCP)を通じてLLMとGrafanaを連携させるツールです。「昨日のCPU使用率が一番高かった時間帯を教えてください」のような自然言語での質問に対して、LLMが適切なPromQLクエリを生成・実行し、結果を分かりやすく要約してくれます。これにより、問題の調査やダッシュボードの作成を効率的に行うことができました。

Grafana MCP Serverを用いたログ分析の様子
Grafana MCP Serverを用いたログ分析の様子

https://github.com/grafana/mcp-grafana

CI/CD

Continuous Integration

学園祭当日にシステムが動かないことは絶対に避けなければなりません。しかし、トラブルを恐れて変更に対して慎重になりすぎると、必要な修正や機能追加が遅れてしまいます。そこで、開発者が常に安全かつ迅速にコードを変更できるように、動作を容易に検証できる仕組みとしてCIパイプラインを導入しています。

CIワークフローは、静的解析・ビルド・テストを並列で実行するciジョブ、PostgreSQLコンテナを使用したE2Eテストのci-e2eジョブ、GitHub Actionsのワークフロー自体のリントを行うci-github-actionsジョブ、Dockerfileのリントを行うci-dockerfileジョブの4つに分かれています。

テスト

前述のレイヤードアーキテクチャにより各レイヤーの責務が明確に分離されているため、レイヤーごとに適切な粒度でテストが可能です。

Domain層とUse-case層の単体テストにはBun testを採用しています。Jest互換のAPIを提供しており、高速にテストを実行できます。

https://bun.sh/docs/cli/test

HonoXで構築したHonoインスタンスに対するリクエストとレスポンスを検証する統合テストにはVitestを採用しています。当初はBun testを検討していましたが、HonoXは内部でViteの独自拡張仕様に依存しており、当時のBunはこれをサポートしていませんでした。そのため、この方針は諦め、Viteとの親和性が高いVitestを利用することにしました。

https://vitest.dev/

Webブラウザを通じたE2EテストにはPlaywrightを採用しています。HonoXは開発サーバーと本番ビルドで挙動が異なるため、本番ビルド後のアプリケーションを対象にE2Eテストを実行しています。またデータベースについても、PGliteではなくPostgreSQLコンテナを起動して利用する形にしています。テストケースを細かく網羅すると冗長かつ実行時間が長くなってしまうため、最低限のテストシナリオの検証と、アイランドコンポーネントの動作確認に利用しています。

https://playwright.dev/

静的解析

アプリケーションのコードについては、Biomeを用いてコードスタイルの統一と静的解析を行い、コード品質を維持しています。

https://biomejs.dev/

当時のBiomeはYAMLやMarkdownなどのフォーマットに対応していないため、一部Prettierも併用しています。

TypeScriptの型検査には、実験的にtsgoを導入しました。tscに比べて2-3倍程度高速に型検査が完了します。tsgo特有の不具合は今のところ発生していません。

https://github.com/microsoft/typescript-go

レイヤー間の依存関係の検証にはdependency-cruiserを使用し、不正なインポートを禁止することでアーキテクチャの崩壊を防いでいます。

https://github.com/sverweij/dependency-cruiser

アプリケーション以外については、Dockerfileに対してHadolintを、GitHub Actionsのワークフローに対してactionlint, ghalint, zizmorを用いて静的解析を行っています。

https://hadolint.com/

https://github.com/rhysd/actionlint

https://github.com/suzuki-shunsuke/ghalint

https://github.com/zizmorcore/zizmor

Continuous Deployment

アプリケーションはOSSとして公開していますが、個別のシステム運用についてはクローズドに管理しています。アプリケーションのコードとインフラ設定の関心を分離するため、デプロイ管理用のリポジトリをアプリケーションとは別に作成しました。このリポジトリでは、Docker Composeファイル、DBのマイグレーションファイル、ログ収集の設定ファイルなどを管理しています。

order-deployments
├── .github/workflows
│   ├── deploy.yml
│   └── migrate-database.yml
└── *-club
    ├── compose.yaml
    ├── drizzle
    │   └── *.sql
    └── fluent-bit.conf

デプロイツールとしてはKamalやArgo CDも検討しました。Kamalは37signalsが開発したSSHベースのデプロイツールで、Traefikによるトラフィック切り替えでゼロダウンタイムデプロイを実現できます。しかし、Ruby/Railsエコシステムに馴染みがなく、トラブル発生時の調査や対応に時間を要するリスクがありました。

https://kamal-deploy.org

https://traefik.io

Argo CDはKubernetes向けのGitOpsツールで、Gitリポジトリの状態とクラスタの状態を自動的に同期できます。マニフェストファイルで宣言的にインフラの状態を定義でき、構成管理の観点からは魅力的な選択肢でした。しかし、microk8sやk3sなどのKubernetesクラスタをセルフホストする必要があり、単一サーバーで運用するこの規模ではオーバーエンジニアリングと判断しました。

https://argoproj.github.io/cd/

最終的に、VPS上にGitHub Self-hosted Runnerを構築し、GitHub Actionsのworkflow dispatchでデプロイを実行する形にしました。

DBスキーマの変更を伴うデプロイでは、マイグレーションファイルの生成とデプロイを分離しています。デプロイワークフローを実行すると、まずアプリケーションリポジトリからマイグレーションファイルを生成し、変更があればPRを作成します。PRをマージした後、別途マイグレーションワークフローを実行することで、バックアップ取得後にマイグレーションが適用されます。

おわりに

学園祭の模擬店向け注文管理システムをHonoXで開発した際の技術選定やアーキテクチャ設計、テスト方法、運用事例について紹介しました。

本システムは学園祭の2日間を通して問題なく運用でき、注文の受付から調理、提供までの状態管理をスムーズに行うことができました。今回の取り組みを通じて、HonoXでも実用的なWebサービスを十分に構築できることを実感しました。来年度以降も継続して提供する予定のため、HonoやHonoXの今後のアップデートに注目しつつ、システムの改善を続けていきたいと考えています。

この記事が、HonoXを用いたWebサービス開発に興味を持つ方々の参考になれば幸いです。

RICORA Programming Team

Discussion