エンジニアとしての強み

各カテゴリの詳細を確認できます

ソフトウェアアーキテクチャ

私が設計・実装するソフトウェアアーキテクチャの概要と構成要素について紹介します。

アーキテクチャ図
アーキテクチャ構成要素同士の関係を図解します。構成要素の名称、データフロー(及びそのオブジェクト)を示しています。
Loading diagram...
アーキテクチャの特徴
このアーキテクチャの主な特徴と利点について説明します。

責務の明確な分離

各コンポーネントは明確な責務を持ち、単一責任の原則に従って設計されています。これにより、コードの可読性、保守性、テスト容易性が向上します。

変更頻度に基づく分類

構成要素は変更頻度(温度)によって分類されており、変更の影響範囲を最小限に抑える設計となっています。低温の要素はドメインルールやインフラストラクチャなど、変更頻度が低く安定した部分です。

フロントエンドとバックエンドの明確な境界

フロントエンド、バックエンド、およびその境界となる要素が明確に定義されており、各層の責務が明確です。これにより、フロントエンドとバックエンドの開発を並行して進めることができます。

スキーマ駆動開発

Resource、DomainSchema、Client-Resourceなどのスキーマ定義を中心に開発を進めることで、型安全性を確保し、バグの早期発見と開発効率の向上を実現しています。

アーキテクチャ構成要素

アーキテクチャを構成する各要素の詳細情報と実装例です。各要素は明確な責務を持ち、単一責任の原則に従って設計されています。

Page

境界
温度:
コンポーネント

フォルダ

app

責務

エンドポイントに配置する画面コンポーネントと、SSR(サーバーサイドレンダリング)時の認証/認可、初期データ取得制御を行う

オブジェクトの送信イベント

  • 初期データの転送
    タイミング: 初期表示
    送信先: Feature
    オブジェクト: Resourceのレスポンス

実装例

// app/games/memory/page.tsx
import { getServerSession } from "next-auth"
import { redirect } from "next/navigation"
import { authOptions } from "@/infrastructure/auth"
import { MemoryGameFeature } from "@/features/memory-game/memory-game-feature"
import { trpc } from "@/infrastructure/trpc/server"

export default async function MemoryGamePage() {
  // 認証チェック
  const session = await getServerSession(authOptions)
  if (!session) {
    redirect("/auth/signin")
  }

  // 初期データ取得
  const initialGameState = await trpc.memoryGame.getInitialState.query({
    userId: session.user.id,
  })

  // Featureコンポーネントに初期データを渡す
  return <MemoryGameFeature initialState={initialGameState} />
}

Batch

バックエンド
温度:
コンポーネント

フォルダ

batch

責務

バッチ処理メソッドの定義

オブジェクトの送信イベント

  • バッチの実行
    タイミング: バッチの実行
    送信先: Infrastructure
    オブジェクト: -

実装例

// batch/leaderboard-update.ts
import { db } from "@/infrastructure/db"
import { games, userStats, leaderboard } from "@/infrastructure/db/schema"
import { eq, desc, and, lt, gt } from "drizzle-orm"
import { sql } from "drizzle-orm/sql"

export async function updateDailyLeaderboard() {
  // 昨日の日付範囲を計算
  const yesterday = new Date()
  yesterday.setDate(yesterday.getDate() - 1)
  yesterday.setHours(0, 0, 0, 0)
  
  const today = new Date()
  today.setHours(0, 0, 0, 0)
  
  // 昨日完了したゲームの集計
  const dailyResults = await db.execute(
    sql`
      SELECT 
        "userId", 
        MAX("score") as "highScore", 
        AVG("score") as "avgScore", 
        COUNT(*) as "gamesPlayed"
      FROM ${games}
      WHERE 
        "gameType" = 'memory' AND
        "status" = 'completed' AND
        "completedAt" >= ${yesterday} AND
        "completedAt" < ${today}
      GROUP BY "userId"
      ORDER BY "highScore" DESC
      LIMIT 100
    `
  )
  
  // 日次ランキングテーブルの更新
  for (const result of dailyResults) {
    await db.insert(leaderboard)
      .values({
        userId: result.userId,
        score: result.highScore,
        gameType: "memory",
        period: "daily",
        metadata: {
          avgScore: result.avgScore,
          gamesPlayed: result.gamesPlayed,
          date: yesterday.toISOString().split('T')[0]
        }
      })
  }
  
  return {
    success: true,
    processedCount: dailyResults.length,
    date: yesterday.toISOString().split('T')[0]
  }
}

Component

フロントエンド
温度:
コンポーネント

フォルダ

components

責務

特定の画面に限らない画面コンポーネントの定義

実装例

// components/game-card.tsx
"use client"

import { useState, useEffect } from "react"
import { Card } from "@/components/ui/card"
import { cn } from "@/lib/utils"

interface GameCardProps {
  id: string
  value: string
  isFlipped: boolean
  isMatched: boolean
  onClick: () => void
  disabled?: boolean
}

export function GameCard({
  id,
  value,
  isFlipped,
  isMatched,
  onClick,
  disabled = false,
}: GameCardProps) {
  const [isFlippedState, setIsFlippedState] = useState(isFlipped)
  
  // 外部のisFlippedプロパティが変更されたら内部状態も更新
  useEffect(() => {
    setIsFlippedState(isFlipped)
  }, [isFlipped])
  
  // マッチした場合のスタイル
  const matchedStyle = isMatched
    ? "bg-green-100 dark:bg-green-900/20 border-green-300 dark:border-green-800"
    : ""
  
  // カードクリック時の処理
  const handleClick = () => {
    if (disabled || isFlippedState || isMatched) return
    onClick()
  }
  
  return (
    <div
      className={cn(
        "relative w-full h-full perspective-1000 cursor-pointer",
        disabled ? "opacity-70 cursor-not-allowed" : "hover:scale-105",
      )}
      onClick={handleClick}
    >
      {/* カード内容 */}
    </div>
  )
}

Data

バックエンド
温度:
コンポーネント

フォルダ

data

責務

ビルド/デプロイよりも変更頻度が低く、インメモリで管理するデータの定義

実装例

// data/game-achievements.ts
import { z } from "zod"
import { achievementSchema } from "@/domain/schema/achievement"

// 実績データの型
const achievementDataSchema = z.array(
  achievementSchema.extend({
    id: z.string(),
    name: z.string(),
    description: z.string(),
    icon: z.string(),
    requirement: z.record(z.any()),
    points: z.number(),
    rarity: z.enum(["common", "uncommon", "rare", "epic", "legendary"]),
  })
)

type AchievementData = z.infer<typeof achievementDataSchema>

// メモリーゲームの実績データ
export const memoryGameAchievements: AchievementData = [
  {
    id: "memory_first_win",
    name: "初めての勝利",
    description: "メモリーゲームを初めてクリアする",
    icon: "🏆",
    gameType: "memory",
    requirement: {
      gamesCompleted: 1
    },
    points: 5,
    rarity: "common"
  },
  {
    id: "memory_perfect_game",
    name: "パーフェクトマッチ",
    description: "ミスなしでゲームをクリアする",
    icon: "✨",
    gameType: "memory",
    requirement: {
      mistakeCount: 0
    },
    points: 20,
    rarity: "rare"
  }
]

DomainRule

バックエンド
温度:
コンポーネント

フォルダ

domain/rules

責務

ドメインルールの定義

実装例

// domain/rules/memory-game-rules.ts
import { nanoid } from "nanoid"
import type { Card } from "@/resource/memory-game-resource"

// カードの値のセット
const cardValues = ["🍎", "🍌", "🍇", "🍊", "🍓", "🍉", "🍍", "🥝", "🥥", "🍒", "🥭", "🍑"]

// カードをシャッフルする関数
function shuffleArray<T>(array: T[]): T[] {
  const newArray = [...array]
  for (let i = newArray.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1))
    ;[newArray[i], newArray[j]] = [newArray[j], newArray[i]]
  }
  return newArray
}

// カードを生成する関数
export function generateCards(pairCount = 8): Card[] {
  // 使用する値を選択
  const selectedValues = cardValues.slice(0, pairCount)
  
  // 各値のペアを作成
  const cards: Card[] = []
  for (const value of selectedValues) {
    cards.push(
      { id: nanoid(), value, isFlipped: false, isMatched: false },
      { id: nanoid(), value, isFlipped: false, isMatched: false }
    )
  }
  
  // カードをシャッフル
  return shuffleArray(cards)
}

Feature

フロントエンド
温度:
コンポーネント

フォルダ

features

責務

エンドポイントに対応した画面コンポーネントの定義

オブジェクトの送信イベント

  • 初期データの転送
    タイミング: 初期表示
    送信先: Hook
    オブジェクト: Client-Resource
  • 状態とハンドラの転送
    タイミング: -
    送信先: View
    オブジェクト: Client-Resource、関数

実装例

// features/memory-game/memory-game-feature.tsx
"use client"

import { useMemoryGame } from "@/hooks/use-memory-game"
import { MemoryGameView } from "@/views/memory-game-view"
import type { MemoryGameInitialState } from "@/resource/memory-game-resource"

interface MemoryGameFeatureProps {
  initialState: MemoryGameInitialState
}

export function MemoryGameFeature({ initialState }: MemoryGameFeatureProps) {
  // Hookを使用してロジックと状態を管理
  const {
    gameState,
    cards,
    selectedCards,
    matchedPairs,
    isGameOver,
    score,
    handleCardClick,
    handleRestart,
  } = useMemoryGame(initialState)

  // Viewコンポーネントに状態とハンドラを渡す
  return (
    <MemoryGameView
      cards={cards}
      selectedCards={selectedCards}
      matchedPairs={matchedPairs}
      isGameOver={isGameOver}
      score={score}
      onCardClick={handleCardClick}
      onRestart={handleRestart}
    />
  )
}

Hook

フロントエンド
温度:
コンポーネント

フォルダ

hooks

責務

フロントエンドロジックの定義

実装例

// hooks/use-memory-game.ts
"use client"

import { useState, useEffect, useCallback } from "react"
import { trpc } from "@/infrastructure/trpc/client"
import { pusherClient } from "@/infrastructure/pusher/client"
import type { MemoryGameInitialState, Card } from "@/resource/memory-game-resource"
import type { MemoryGameState } from "@/client-resource/memory-game-state"

export function useMemoryGame(initialState: MemoryGameInitialState) {
  // クライアント状態の初期化
  const [gameState, setGameState] = useState<MemoryGameState>({
    cards: initialState.cards,
    selectedCards: [],
    matchedPairs: [],
    isGameOver: false,
    score: initialState.score,
  })

  // tRPCクライアントの設定
  const saveScore = trpc.memoryGame.saveScore.useMutation()

  // カード選択ハンドラ
  const handleCardClick = useCallback((card: Card) => {
    if (
      gameState.selectedCards.length === 2 ||
      gameState.selectedCards.some(c => c.id === card.id) ||
      gameState.matchedPairs.includes(card.value)
    ) {
      return
    }

    setGameState(prev => ({
      ...prev,
      selectedCards: [...prev.selectedCards, card],
    }))
  }, [gameState.selectedCards, gameState.matchedPairs])

  // 他のロジックとハンドラ...

  return {
    ...gameState,
    handleCardClick,
    handleRestart,
  }
}

Infrastructure

境界
温度:
コンポーネント

フォルダ

infrastructure

責務

ライブラリ、フレームワークのWrapperの定義

実装例

// infrastructure/db/index.ts
import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres"
import * as schema from "./schema"
import { env } from "@/infrastructure/env"

// PostgreSQLクライアントの初期化
const connectionString = env.DATABASE_URL
const client = postgres(connectionString)
export const db = drizzle(client, { schema })

// infrastructure/trpc/router.ts
import { initTRPC, TRPCError } from "@trpc/server"
import { getServerSession } from "next-auth"
import { authOptions } from "@/infrastructure/auth"
import superjson from "superjson"

// tRPCの初期化
const t = initTRPC.create({
  transformer: superjson,
})

// ミドルウェア: 認証チェック
const isAuthenticated = t.middleware(async ({ next, ctx }) => {
  const session = await getServerSession(authOptions)
  
  if (!session || !session.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "認証が必要です",
    })
  }
  
  return next({
    ctx: {
      ...ctx,
      session,
    },
  })
})

Logic

バックエンド
温度:
コンポーネント

フォルダ

logics

責務

バックエンドロジックの定義

オブジェクトの送信イベント

  • 外部データへのアクセス
    タイミング: 外部データへのアクセス
    送信先: Repository
    オブジェクト: -
  • フロントエンドへの転送
    タイミング: フロントエンドへの転送
    送信先: Hook
    オブジェクト: Resourceのメッセージ

実装例

// logics/memory-game-logic.ts
import { memoryGameRepository } from "@/repository/memory-game-repository"
import { memoryGameTrigger } from "@/trigger/memory-game-trigger"
import { generateCards } from "@/domain/rules/memory-game-rules"
import type { Card } from "@/resource/memory-game-resource"

export const memoryGameLogic = {
  // 初期状態取得
  async getInitialState(userId: string) {
    // 既存のゲーム状態を取得
    const existingGame = await memoryGameRepository.getGameByUserId(userId)
    
    if (existingGame && !existingGame.isCompleted) {
      return {
        userId,
        cards: existingGame.cards,
        score: existingGame.score,
      }
    }
    
    // 新しいゲームを作成
    const cards = generateCards()
    await memoryGameRepository.createGame(userId, cards)
    
    return {
      userId,
      cards,
      score: 0,
    }
  },

  // スコア保存
  async saveScore(userId: string, score: number) {
    // ゲームを完了状態に更新
    await memoryGameRepository.updateGame(userId, {
      score,
      isCompleted: true,
      completedAt: new Date(),
    })
    
    // 他のロジック...
    
    return { success: true }
  }
}

Repository

バックエンド
温度:
コンポーネント

フォルダ

repository

責務

外部データへのアクセスメソッドの定義

オブジェクトの送信イベント

  • インフラへのアクセス
    タイミング: インフラへのアクセス
    送信先: Infrustructure
    オブジェクト: -

実装例

// repository/memory-game-repository.ts
import { db } from "@/infrastructure/db"
import { games, userStats, leaderboard } from "@/infrastructure/db/schema"
import { eq, desc, and, gt } from "drizzle-orm"
import type { Card } from "@/resource/memory-game-resource"

export const memoryGameRepository = {
  // ゲーム取得
  async getGameByUserId(userId: string) {
    const result = await db.query.games.findFirst({
      where: and(
        eq(games.userId, userId),
        eq(games.gameType, "memory")
      ),
      orderBy: [desc(games.createdAt)]
    })
    
    return result
  },
  
  // ゲーム作成
  async createGame(userId: string, cards: Card[]) {
    await db.insert(games).values({
      userId,
      gameType: "memory",
      status: "in_progress",
      startedAt: new Date(),
      metadata: { cards },
      score: 0,
    })
  },
  
  // 他のメソッド...
}

Service

バックエンド
温度:
コンポーネント

フォルダ

services

責務

画面ごとのtRPCルーター定義

オブジェクトの送信イベント

  • バックエンドロジック実行
    タイミング: バックエンドロジック実行
    送信先: Logic
    オブジェクト: リクエストスキーマ

実装例

// services/memory-game-service.ts
import { z } from "zod"
import { router, publicProcedure, protectedProcedure } from "@/infrastructure/trpc/router"
import { memoryGameLogic } from "@/logics/memory-game-logic"
import { memoryGameSchema } from "@/resource/memory-game-resource"

export const memoryGameRouter = router({
  // 初期状態取得
  getInitialState: protectedProcedure
    .input(z.object({
      userId: z.string(),
    }))
    .query(async ({ input, ctx }) => {
      return memoryGameLogic.getInitialState(input.userId)
    }),

  // スコア保存
  saveScore: protectedProcedure
    .input(z.object({
      score: z.number(),
    }))
    .mutation(async ({ input, ctx }) => {
      const userId = ctx.session.user.id
      return memoryGameLogic.saveScore(userId, input.score)
    }),

  // ゲームリスタート
  restartGame: protectedProcedure
    .mutation(async ({ ctx }) => {
      const userId = ctx.session.user.id
      return memoryGameLogic.restartGame(userId)
    }),

  // ランキング取得
  getLeaderboard: publicProcedure
    .query(async () => {
      return memoryGameLogic.getLeaderboard()
    }),
})

View

フロントエンド
温度:
コンポーネント

フォルダ

views

責務

画面UIの構成定義

実装例

// views/memory-game-view.tsx
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { type Card as CardType } from "@/resource/memory-game-resource"
import { Trophy } from 'lucide-react'

interface MemoryGameViewProps {
  cards: CardType[]
  selectedCards: CardType[]
  matchedPairs: string[]
  isGameOver: boolean
  score: number
  onCardClick: (card: CardType) => void
  onRestart: () => void
}

export function MemoryGameView({
  cards,
  selectedCards,
  matchedPairs,
  isGameOver,
  score,
  onCardClick,
  onRestart,
}: MemoryGameViewProps) {
  return (
    <div className="container py-8 max-w-4xl">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-3xl font-bold">神経衰弱ゲーム</h1>
        <div className="flex items-center gap-4">
          <div className="text-xl font-semibold">スコア: {score}</div>
          <Button onClick={onRestart}>リスタート</Button>
        </div>
      </div>

      {isGameOver ? (
        <div className="flex flex-col items-center justify-center p-8 bg-primary/10 rounded-lg">
          <Trophy className="h-16 w-16 text-primary mb-4" />
          <h2 className="text-2xl font-bold mb-2">ゲームクリア!</h2>
          <p className="text-xl mb-4">最終スコア: {score}</p>
          <Button onClick={onRestart} size="lg">
            もう一度プレイ
          </Button>
        </div>
      ) : (
        <div className="grid grid-cols-4 md:grid-cols-6 gap-4">
          {/* カードのレンダリング */}
        </div>
      )}
    </div>
  )
}

Trigger

バックエンド
温度:
コンポーネント

フォルダ

trigger

責務

画面ごとのサーバープッシュの定義

オブジェクトの送信イベント

  • フロントエンドへの転送
    タイミング: フロントエンドへの転送
    送信先: Hook
    オブジェクト: Resourceのメッセージ

実装例

// trigger/memory-game-trigger.ts
import { pusher } from "@/infrastructure/pusher/server"
import type { Card } from "@/resource/memory-game-resource"

export const memoryGameTrigger = {
  // 特定ユーザーのゲーム状態更新
  async updateGameState(userId: string, data: {
    cards?: Card[],
    score?: number,
    isGameOver?: boolean,
    matchedPairs?: string[]
  }) {
    await pusher.trigger(
      `memory-game-${userId}`,
      "game-update",
      {
        type: "game-update",
        ...data
      }
    )
  },
  
  // 全ユーザーへのリーダーボード更新通知
  async broadcastLeaderboardUpdate(leaderboard: any[]) {
    await pusher.trigger(
      "memory-game-leaderboard",
      "leaderboard-update",
      {
        type: "leaderboard-update",
        leaderboard
      }
    )
  }
}

Client-Resource

フロントエンド
温度:
オブジェクト

フォルダ

client-resource

責務

クライアント状態のスキーマの定義

実装例

// client-resource/memory-game-state.ts
import { z } from "zod"

// カードの型定義
export const cardSchema = z.object({
  id: z.string(),
  value: z.string(),
  isFlipped: z.boolean().default(false),
  isMatched: z.boolean().default(false),
})

export type Card = z.infer<typeof cardSchema>

// メモリーゲームのクライアント状態スキーマ
export const memoryGameStateSchema = z.object({
  cards: z.array(cardSchema),
  selectedCards: z.array(cardSchema).default([]),
  matchedPairs: z.array(z.string()).default([]),
  isGameOver: z.boolean().default(false),
  score: z.number().default(0),
})

export type MemoryGameState = z.infer<typeof memoryGameStateSchema>

DomainSchema

バックエンド
温度:
オブジェクト

フォルダ

domain/schema

責務

ドメインオブジェクトのスキーマ定義

実装例

// domain/schema/game.ts
import { z } from "zod"

// ゲームの状態を表すスキーマ
export const gameStatusSchema = z.enum([
  "waiting",
  "in_progress",
  "completed",
  "cancelled"
])

export type GameStatus = z.infer<typeof gameStatusSchema>

// ゲームのドメインスキーマ
export const gameSchema = z.object({
  id: z.string().uuid(),
  userId: z.string(),
  gameType: z.string(),
  status: gameStatusSchema,
  startedAt: z.date().nullable(),
  completedAt: z.date().nullable(),
  score: z.number().default(0),
  metadata: z.record(z.any()).optional(),
})

export type Game = z.infer<typeof gameSchema>

// ユーザーの統計情報スキーマ
export const userStatsSchema = z.object({
  userId: z.string(),
  gamesPlayed: z.number().default(0),
  gamesWon: z.number().default(0),
  totalScore: z.number().default(0),
  highScore: z.number().default(0),
  averageScore: z.number().default(0),
  lastPlayedAt: z.date().nullable(),
})

export type UserStats = z.infer<typeof userStatsSchema>

Resource

境界
温度:
オブジェクト

フォルダ

resource

責務

フロントエンド/バックエンド間でやり取りされるオブジェクトのスキーマ定義

実装例

// resource/memory-game-resource.ts
import { z } from "zod"

// カードのスキーマ
export const cardSchema = z.object({
  id: z.string(),
  value: z.string(),
  isFlipped: z.boolean().optional(),
  isMatched: z.boolean().optional(),
})

export type Card = z.infer<typeof cardSchema>

// リクエストスキーマ
export const getInitialStateRequestSchema = z.object({
  userId: z.string(),
})

export type GetInitialStateRequest = z.infer<typeof getInitialStateRequestSchema>

export const saveScoreRequestSchema = z.object({
  score: z.number(),
})

export type SaveScoreRequest = z.infer<typeof saveScoreRequestSchema>

// レスポンススキーマ
export const memoryGameInitialStateSchema = z.object({
  userId: z.string(),
  cards: z.array(cardSchema),
  score: z.number().default(0),
})

export type MemoryGameInitialState = z.infer<typeof memoryGameInitialStateSchema>

export const gameResultSchema = z.object({
  success: z.boolean(),
  message: z.string().optional(),
})

export type GameResult = z.infer<typeof gameResultSchema>