第8回:ウェブアプリケーションの構成技法

はじめに

みなさん、こんにちは!前回は設計パターンとモジュール分割について学びました。今日はより具体的に、ウェブアプリケーションにおけるコードの構成技法について学んでいきます。

現代のウェブアプリケーションは、フロントエンドとバックエンドの両方で適切な構造化が求められます。「大きな1つのファイル」から始まったコードも、機能が増えるにつれて適切に分割していかなければ、保守が困難になります。

今日は、フロントエンドのコンポーネント分割、バックエンドのサービス層設計、そしてAPI設計とモジュール境界について学んでいきましょう。実際の開発現場で使える実践的な知識を身につけていきます。

フロントエンドのコンポーネント分割

コンポーネント志向の考え方

現代のフロントエンド開発では、「コンポーネント」という再利用可能な部品を組み合わせてUIを構築する方法が主流です。ReactやVue、Angularなどの主要フレームワークはすべてコンポーネントベースのアプローチを採用しています。

コンポーネント志向の主なメリットは:

  1. 再利用性:一度作ったコンポーネントは別の場所でも使える
  2. 保守性:関心の分離により、コードの理解と修正が容易になる
  3. テスト容易性:個々のコンポーネントを独立してテストできる
  4. 並行開発:チームメンバーが異なるコンポーネントを同時に開発できる

コンポーネントの分割基準

コンポーネントを分割する際の基準となる考え方をいくつか紹介します:

  1. 単一責任の原則:一つのコンポーネントは一つの責任だけを持つべき
  2. 再利用性:複数の場所で使われる要素はコンポーネント化する
  3. 複雑さ:複雑になりすぎた要素は小さなコンポーネントに分割する
  4. 変更頻度:変更の理由や頻度が異なる部分は分離する

例えば、ECサイトの商品一覧ページを考えると:

// 悪い例:すべてが1つのコンポーネントに
function ProductListPage() {
  // 商品データの取得
  // フィルタリングロジック
  // ソートロジック
  // ページネーションロジック
  // 表示ロジック
  
  return (
    <div>
      {/* ヘッダー */}
      {/* フィルター UI */}
      {/* ソートUI */}
      {/* 商品リスト */}
      {/* ページネーション */}
    </div>
  );
}

// 良い例:適切に分割されたコンポーネント
function ProductListPage() {
  const [products, setProducts] = useState([]);
  const [filters, setFilters] = useState({});
  
  // データ取得ロジック
  
  return (
    <div>
      <Header />
      <FilterPanel filters={filters} onFilterChange={setFilters} />
      <SortControls onSortChange={handleSortChange} />
      <ProductGrid products={filteredProducts} />
      <Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
    </div>
  );
}
Code language: JavaScript (javascript)

コンポーネントの階層設計

コンポーネントは階層構造で整理すると管理しやすくなります。一般的には以下のような階層に分けることが多いです:

  1. ページコンポーネント:ルーティングに対応する最上位のコンポーネント
  2. コンテナコンポーネント:データ取得やロジックを担当
  3. プレゼンテーショナルコンポーネント:見た目のみを担当
  4. 共通コンポーネント:ボタンやフォーム要素など、再利用される基本的なUI要素
src/
  ├── components/              # 共通コンポーネント
  │   ├── Button/
  │   ├── Input/
  │   └── Card/
  ├── features/                # 機能ごとのコンポーネント
  │   ├── products/
  │   │   ├── ProductList/     # コンテナコンポーネント
  │   │   ├── ProductCard/     # プレゼンテーショナルコンポーネント
  │   │   └── ProductFilter/   # 複合コンポーネント
  │   └── cart/
  │       ├── CartSummary/
  │       └── CartItem/
  └── pages/                   # ページコンポーネント
      ├── HomePage.jsx
      ├── ProductListPage.jsx
      └── ProductDetailPage.jsx
Code language: PHP (php)

状態管理の分離

コンポーネント分割において重要なのが状態(state)の管理です。どのレベルの状態をどこで管理するかを適切に決めることで、コードの見通しが良くなります。

  1. ローカル状態:コンポーネント内だけで使う状態
  2. リフトアップした状態:複数のコンポーネントで共有する状態を親コンポーネントで管理
  3. グローバル状態:アプリ全体で共有する状態をReduxやContextなどで管理
// ローカル状態の例
function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

// リフトアップした状態の例
function FilterableProductList() {
  const [filterText, setFilterText] = useState('');
  
  return (
    <div>
      <SearchBar 
        filterText={filterText} 
        onFilterTextChange={setFilterText} 
      />
      <ProductList filterText={filterText} />
    </div>
  );
}
Code language: JavaScript (javascript)

バックエンドのサービス層設計

多層アーキテクチャの基本

バックエンドでは一般的に「多層アーキテクチャ」と呼ばれる構造が用いられます。これはアプリケーションを複数の層に分割し、各層に明確な責任を持たせる方法です。

典型的な多層アーキテクチャは以下のようになります:

  1. プレゼンテーション層:API エンドポイントやコントローラー
  2. サービス層:ビジネスロジックを実装
  3. データアクセス層:データの永続化や取得を担当
src/
  ├── controllers/        # プレゼンテーション層
  │   ├── UserController.js
  │   └── ProductController.js
  ├── services/           # サービス層
  │   ├── UserService.js
  │   └── ProductService.js
  ├── repositories/       # データアクセス層
  │   ├── UserRepository.js
  │   └── ProductRepository.js
  └── models/             # ドメインモデル
      ├── User.js
      └── Product.js
Code language: PHP (php)

サービス層の役割と設計

サービス層は、アプリケーションのビジネスロジックをカプセル化する場所です。主な責任は:

  1. ユースケースの実装
  2. トランザクション管理
  3. 業務ルールの適用
  4. 複数リポジトリの調整

サービス層の設計では以下の点に注意します:

  1. 単一責任:一つのサービスは関連する機能のみを扱う
  2. 依存性の注入:リポジトリなどの依存を外部から注入する
  3. インターフェース分離:インターフェースを明確に定義する
// UserService の例
class UserService {
  constructor(userRepository, emailService) {
    this.userRepository = userRepository;
    this.emailService = emailService;
  }
  
  async registerUser(userData) {
    // 入力の検証
    this.validateUserData(userData);
    
    // 既存ユーザーのチェック
    const existingUser = await this.userRepository.findByEmail(userData.email);
    if (existingUser) {
      throw new Error('このメールアドレスは既に登録されています');
    }
    
    // パスワードのハッシュ化
    const hashedPassword = await bcrypt.hash(userData.password, 10);
    
    // ユーザーの保存
    const user = await this.userRepository.create({
      ...userData,
      password: hashedPassword
    });
    
    // ウェルカムメールの送信
    await this.emailService.sendWelcomeEmail(user.email);
    
    return user;
  }
  
  validateUserData(userData) {
    // バリデーションロジック
  }
}
Code language: JavaScript (javascript)

リポジトリパターンの活用

データアクセス層では「リポジトリパターン」がよく使われます。これはデータの永続化と取得のロジックをカプセル化するパターンです。

リポジトリの主な特徴:

  1. コレクションのようなインターフェースを提供
  2. 永続化の詳細(SQL、NoSQLなど)を隠蔽
  3. ドメインモデルとデータベースの橋渡し
// ProductRepository の例
class ProductRepository {
  constructor(database) {
    this.database = database;
  }
  
  async findById(id) {
    const result = await this.database.query(
      'SELECT * FROM products WHERE id = ?',[id]
); if (result.length === 0) { return null; } return this.mapToEntity(result[0]); } async findAll(options = {}) { let query = 'SELECT * FROM products'; const params = []; if (options.category) { query += ' WHERE category = ?'; params.push(options.category); } const results = await this.database.query(query, params); return results.map(this.mapToEntity); } async create(productData) { const result = await this.database.query( 'INSERT INTO products (name, price, category) VALUES (?, ?, ?)', [productData.name, productData.price, productData.category] ); return { id: result.insertId, ...productData }; } mapToEntity(dbRow) { return { id: dbRow.id, name: dbRow.name, price: dbRow.price, category: dbRow.category }; } }Code language: JavaScript (javascript)

依存性の注入とテスト容易性

サービス層の設計で重要なのが「依存性の注入」です。これにより、テスト容易性と柔軟性が向上します。

// サービスの登録(依存性の注入)
const database = new Database(dbConfig);
const userRepository = new UserRepository(database);
const emailService = new EmailService(emailConfig);
const userService = new UserService(userRepository, emailService);

// コントローラでの使用
class UserController {
  constructor(userService) {
    this.userService = userService;
  }
  
  async register(req, res) {
    try {
      const user = await this.userService.registerUser(req.body);
      res.status(201).json(user);
    } catch (error) {
      res.status(400).json({ error: error.message });
    }
  }
}

// テストでのモック使用
describe('UserService', () => {
  test('新規ユーザー登録', async () => {
    // モックリポジトリとサービスの作成
    const mockUserRepository = {
      findByEmail: jest.fn().mockResolvedValue(null),
      create: jest.fn().mockResolvedValue({ id: 1, name: 'Test User' })
    };
    
    const mockEmailService = {
      sendWelcomeEmail: jest.fn().mockResolvedValue(true)
    };
    
    const userService = new UserService(mockUserRepository, mockEmailService);
    
    // テスト実行
    const result = await userService.registerUser({
      name: 'Test User',
      email: 'test@example.com',
      password: 'password123'
    });
    
    // 検証
    expect(result.id).toBe(1);
    expect(mockUserRepository.create).toHaveBeenCalled();
    expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalled();
  });
});
Code language: JavaScript (javascript)

API設計とモジュール境界

APIをモジュール境界として設計する

モジュール間の連携において、APIは重要な「境界」の役割を果たします。適切なAPI設計により、モジュールの独立性と再利用性が高まります。

APIをモジュール境界として設計する際の原則:

  1. 明確なインターフェース定義:何を提供し、何を期待するかを明確に
  2. 最小限の公開:必要なものだけを公開する
  3. 一貫性:命名や構造の一貫性を保つ
  4. バージョニング:変更があっても互換性を保つ仕組み

例えば、ユーザー管理モジュールのAPIを考えてみましょう:

// users/index.js(公開インターフェース)
import UserService from './services/UserService';
import { userServiceConfig } from './config';

// モジュールの内部実装をカプセル化
const userService = new UserService(userServiceConfig);

// 公開API
export const getUser = (id) => userService.getUser(id);
export const createUser = (userData) => userService.createUser(userData);
export const updateUser = (id, userData) => userService.updateUser(id, userData);
export const deleteUser = (id) => userService.deleteUser(id);

// モジュール内のプライベートな実装は直接公開しない
Code language: JavaScript (javascript)

RESTful APIの設計原則

Web APIでは、RESTful設計が広く採用されています。RESTの主な原則は:

  1. リソース指向:URLはリソース(名詞)を表す
  2. HTTPメソッドの適切な使用:GET(取得)、POST(作成)、PUT(更新)、DELETE(削除)
  3. ステートレス:各リクエストは自己完結している
  4. 統一インターフェース:一貫した操作方法
# リソース指向のURL設計
GET /users                  # ユーザー一覧の取得
GET /users/123              # ID 123のユーザー取得
POST /users                 # 新規ユーザー作成
PUT /users/123              # ID 123のユーザー更新
DELETE /users/123           # ID 123のユーザー削除

# リレーションシップの表現
GET /users/123/orders       # ユーザー123の注文一覧
POST /users/123/orders      # ユーザー123の新規注文作成
Code language: PHP (php)

マイクロサービスにおけるモジュール設計

マイクロサービスアーキテクチャでは、モジュールがサービスとして独立します。このような設計では以下が重要です:

  1. 境界設計:ビジネスドメインに基づく適切な境界設定
  2. サービス間通信:API、メッセージキューなどの適切な通信方法
  3. データの独立性:各サービスが自身のデータを管理
+----------------+      +----------------+      +----------------+
|                |      |                |      |                |
|  User Service  | <--> | Order Service  | <--> | Product Service|
|                |      |                |      |                |
+----------------+      +----------------+      +----------------+
       |                       |                       |
+----------------+      +----------------+      +----------------+
|                |      |                |      |                |
|  User Database |      | Order Database |      |Product Database|
|                |      |                |      |                |
+----------------+      +----------------+      +----------------+
Code language: HTML, XML (xml)

各サービスは独自のデータベースを持ち、明確に定義されたAPIを通じて他のサービスと通信します。

共有ライブラリと依存関係の管理

複数のモジュールで共有されるコードは、共有ライブラリとして切り出すことができます。ただし、共有ライブラリの管理には注意が必要です:

  1. 適切な粒度:小さすぎると管理コストが増大、大きすぎると結合度が高まる
  2. バージョン管理:互換性を保ちながら更新する仕組み
  3. 依存方向:低レベルモジュールが高レベルモジュールに依存しないようにする
// 共有ライブラリの例
// @myapp/utils パッケージ
export function formatDate(date) {
  // 日付フォーマット処理
}

export function validateEmail(email) {
  // メールアドレス検証処理
}

// 使用側
import { formatDate, validateEmail } from '@myapp/utils';

function registerUser(userData) {
  if (!validateEmail(userData.email)) {
    throw new Error('Invalid email');
  }
  
  const user = {
    ...userData,
    createdAt: formatDate(new Date())
  };
  
  // ユーザー登録処理
}
Code language: JavaScript (javascript)

実習:小規模ウェブアプリをクリーンに構造化する

では、実習として小規模なタスク管理アプリケーションを構造化してみましょう。

要件

  • ユーザーはタスクの作成、表示、更新、削除ができる
  • タスクはタイトル、説明、期限、優先度、ステータスを持つ
  • タスクをカテゴリで分類できる
  • タスクの一覧表示とフィルタリング機能がある

フロントエンド構造

src/
  ├── components/              # 共通コンポーネント
  │   ├── Button/
  │   │   ├── Button.jsx
  │   │   └── Button.css
  │   ├── Input/
  │   │   ├── Input.jsx
  │   │   └── Input.css
  │   └── Select/
  │       ├── Select.jsx
  │       └── Select.css
  ├── features/                # 機能ごとのコンポーネント
  │   ├── tasks/
  │   │   ├── TaskList/
  │   │   │   ├── TaskList.jsx
  │   │   │   └── TaskList.css
  │   │   ├── TaskItem/
  │   │   │   ├── TaskItem.jsx
  │   │   │   └── TaskItem.css
  │   │   ├── TaskForm/
  │   │   │   ├── TaskForm.jsx
  │   │   │   └── TaskForm.css
  │   │   └── TaskFilter/
  │   │       ├── TaskFilter.jsx
  │   │       └── TaskFilter.css
  │   └── categories/
  │       ├── CategoryList/
  │       └── CategorySelector/
  ├── pages/                   # ページコンポーネント
  │   ├── HomePage.jsx
  │   ├── TasksPage.jsx
  │   └── TaskDetailPage.jsx
  ├── services/                # API通信
  │   ├── api.js
  │   ├── taskService.js
  │   └── categoryService.js
  ├── store/                   # 状態管理
  │   ├── taskSlice.js
  │   └── categorySlice.js
  └── utils/                   # ユーティリティ
      ├── date.js
      └── validation.js
Code language: PHP (php)

バックエンド構造

src/
  ├── controllers/             # API エンドポイント
  │   ├── taskController.js
  │   └── categoryController.js
  ├── services/                # ビジネスロジック
  │   ├── taskService.js
  │   └── categoryService.js
  ├── repositories/            # データアクセス
  │   ├── taskRepository.js
  │   └── categoryRepository.js
  ├── models/                  # ドメインモデル
  │   ├── Task.js
  │   └── Category.js
  ├── middleware/              # ミドルウェア
  │   ├── auth.js
  │   └── validation.js
  ├── utils/                   # ユーティリティ
  │   └── errorHandler.js
  └── config/                  # 設定
      └── database.js
Code language: PHP (php)

コード例:フロントエンド

// features/tasks/TaskList/TaskList.jsx
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchTasks } from '../../../store/taskSlice';
import TaskItem from '../TaskItem/TaskItem';
import TaskFilter from '../TaskFilter/TaskFilter';
import './TaskList.css';

const TaskList = () => {
  const dispatch = useDispatch();
  const { tasks, loading, error, filter } = useSelector(state => state.tasks);
  
  useEffect(() => {
    dispatch(fetchTasks(filter));
  }, [dispatch, filter]);
  
  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error}</div>;
  
  return (
    <div className="task-list">
      <TaskFilter />
      
      {tasks.length === 0 ? (
        <p>タスクがありません</p>
      ) : (
        <ul>
          {tasks.map(task => (
            <TaskItem key={task.id} task={task} />
          ))}
        </ul>
      )}
    </div>
  );
};

export default TaskList;

// services/taskService.js
import api from './api';

export const getTasks = async (filter = {}) => {
  // クエリパラメータの構築
  const queryParams = new URLSearchParams();
  if (filter.status) queryParams.append('status', filter.status);
  if (filter.priority) queryParams.append('priority', filter.priority);
  
  const query = queryParams.toString() ? `?${queryParams.toString()}` : '';
  
  // API呼び出し
  const response = await api.get(`/tasks${query}`);
  return response.data;
};

export const getTaskById = async (id) => {
  const response = await api.get(`/tasks/${id}`);
  return response.data;
};

export const createTask = async (taskData) => {
  const response = await api.post('/tasks', taskData);
  return response.data;
};

export const updateTask = async (id, taskData) => {
  const response = await api.put(`/tasks/${id}`, taskData);
  return response.data;
};

export const deleteTask = async (id) => {
  await api.delete(`/tasks/${id}`);
  return id;
};
Code language: JavaScript (javascript)

コード例:バックエンド

// controllers/taskController.js
const TaskService = require('../services/taskService');

class TaskController {
  constructor() {
    this.taskService = new TaskService();
  }
  
  async getTasks(req, res) {
    try {
      const filter = {
        status: req.query.status,
        priority: req.query.priority,
        category: req.query.category
      };
      
      const tasks = await this.taskService.getTasks(filter);
      
      res.json(tasks);
    } catch (error) {
      res.status(500).json({ error: error.message });
    }
  }
  
  async getTaskById(req, res) {
    try {
      const task = await this.taskService.getTaskById(req.params.id);
      
      if (!task) {
        return res.status(404).json({ error: 'タスクが見つかりません' });
      }
      
      res.json(task);
    } catch (error) {
      res.status(500).json({ error: error.message });
    }
  }
  
  async createTask(req, res) {
    try {
      const taskData = {
        title: req.body.title,
        description: req.body.description,
        dueDate: req.body.dueDate,
        priority: req.body.priority,
        status: req.body.status || 'pending',
        categoryId: req.body.categoryId
      };
      
      const task = await this.taskService.createTask(taskData);
      
      res.status(201).json(task);
    } catch (error) {
      res.status(400).json({ error: error.message });
    }
  }
  
  // 他のメソッド...
}

module.exports = new TaskController();

// services/taskService.js
const TaskRepository = require('../repositories/taskRepository');
const CategoryRepository = require('../repositories/categoryRepository');

class TaskService {
  constructor() {
    this.taskRepository = new TaskRepository();
    this.categoryRepository = new CategoryRepository();
  }
  
  async getTasks(filter = {}) {
    return this.taskRepository.findAll(filter);
  }
  
  async getTaskById(id) {
    return this.taskRepository.findById(id);
  }
  
  async createTask(taskData) {
    // バリデーション
    this.validateTaskData(taskData);
    
    // カテゴリの存在確認
    if (taskData.categoryId) {
      const category = await this.categoryRepository.findById(taskData.categoryId);
      if (!category) {
        throw new Error('指定されたカテゴリが存在しません');
      }
    }
    
    // タスクの作成
    return this.taskRepository.create(taskData);
  }
  
  validateTaskData(taskData) {
    if (!taskData.title) {
      throw new Error('タイトルは必須です');
    }
    
    if (taskData.title.length > 100) {
      throw new Error('タイトルは100文字以内で指定してください');
    }
    
    // 他のバリデーション...
  }
  
  // 他のメソッド...
}

module.exports = TaskService;

// repositories/taskRepository.js
const db = require('../config/database');
const Task = require('../models/Task');

class TaskRepository {
  async findAll(filter = {}) {
    let query = 'SELECT * FROM tasks WHERE 1=1';
    const params = [];
    
    if (filter.status) {
      query += ' AND status = ?';
      params.push(filter.status);
    }
    
    if (filter.priority) {
      query += ' AND priority = ?';
      params.push(filter.priority);
    }
    
    if (filter.category) {
      query += ' AND category_id = ?';
      params.push(filter.category);
    }
    
    const results = await db.query(query, params);
    return results.map(this.mapToTask);
  }
  
  async findById(id) {
    const result = await db.query('SELECT * FROM tasks WHERE id = ?', [id]);
    
    if (result.length === 0) {
      return null;
    }
    
    return this.mapToTask(result[0]);
  }
  
  async create(taskData) {
    const result = await db.query(
      'INSERT INTO tasks (title, description, due_date, priority, status, category_id) VALUES (?, ?, ?, ?, ?, ?)',
      [
        taskData.title,
        taskData.description,
        taskData.dueDate,
        taskData.priority,
        taskData.status,
        taskData.categoryId
      ]
    );
    
    return {
      id: result.insertId,
      ...taskData
    };
  }
  
  mapToTask(dbRow) {
    return new Task(
      dbRow.id,
      dbRow.title,
      dbRow.description,
      dbRow.due_date,
      dbRow.priority,
      dbRow.status,
      dbRow.category_id
    );
  }
  
  // 他のメソッド...
}

module.exports = TaskRepository;
Code language: JavaScript (javascript)

まとめ

今日の講義では、ウェブアプリケーションの構成技法について学びました:

  1. フロントエンドのコンポーネント分割
    • コンポーネント志向の考え方
    • コンポーネントの分割基準
    • コンポーネントの階層設計
    • 状態管理の分離
  2. バックエンドのサービス層設計
    • 多層アーキテクチャの基本
    • サービス層の役割と設計
    • リポジトリパターンの活用
    • 依存性の注入とテスト容易性
  1. API設計とモジュール境界
    • APIをモジュール境界として設計する
    • RESTful APIの設計原則
    • マイクロサービスにおけるモジュール設計
    • 共有ライブラリと依存関係の管理

適切に構造化されたウェブアプリケーションは、保守性、拡張性、テスト容易性が高まります。フロントエンドではコンポーネント分割、バックエンドでは多層アーキテクチャを意識し、それらをつなぐAPIをクリーンに設計することが重要です。

大規模なアプリケーションでも、今日学んだ原則を適用することで、複雑さを管理しやすくなります。コードの分割は一度で完璧にする必要はなく、継続的に改善していくものです。

次回は「リファクタリングの実践ケーススタディ」として、実際の大規模リファクタリング事例を分析し、現場で役立つ戦略について学びます。

質問タイム

みなさん、今日の講義内容について質問はありますか?

質問タイム

質問1:林さんからの質問

質問:「フロントエンドのコンポーネント分割で、『細かすぎず、大きすぎない適切なサイズ』を判断するコツはありますか?実際のプロジェクトでは、どのような基準で分割するのが良いのでしょうか?」

回答: とても実践的な質問ですね、林さん。コンポーネントの適切なサイズは、確かに判断が難しいところです。いくつかの基準を紹介します:

  1. 行数による目安
    • 200行を超えるコンポーネントは、大きすぎる可能性が高いです
    • 一方、10行以下のコンポーネントが多数あると、管理コストが増える場合があります
  2. 単一責任の原則
    • コンポーネントの役割を一文で説明できるか考えてみましょう
    • 「〜と〜と〜をする」と複数の役割が出てくるなら分割すべきサインです
  3. 変更の理由
    • 異なる理由で変更される部分は分割すべきです
    • 例えば、デザインの変更とデータ処理の変更が別々に起こりうる場合は分離した方が良いでしょう
  4. 再利用性
    • 複数の場所で使う可能性がある部分は、独立したコンポーネントにすべきです
    • 例えば、フォーム要素やリストアイテムなど

実際のプロジェクトでは、これらのバランスを取ることが重要です。また、チームの経験レベルも考慮すべき要素です。経験の少ないチームでは、最初は大きめのコンポーネントから始めて、徐々に分割していく方が理解しやすいかもしれません。

私の経験では、「このコンポーネントを他の人に説明するのに時間がかかるようになってきた」という感覚が、分割のタイミングを示す良いサインです。コンポーネントの責任範囲が説明しづらくなったら、分割を検討する時かもしれません。

質問2:鈴木さんからの質問

質問:「バックエンドのサービス層とリポジトリ層の違いがまだよく理解できていません。どちらもデータ操作をしていると感じるのですが、具体的にどのようなコードをどちらに書くべきなのでしょうか?」

回答: 鋭い質問ですね、鈴木さん。確かにサービス層とリポジトリ層は混同しやすいものです。それぞれの役割を明確にしましょう:

リポジトリ層は「どのように」データを保存・取得するかを担当します:

  • データベースへのクエリの実行
  • データの永続化と取得の具体的な方法
  • データモデルとデータベースのマッピング
  • 基本的なCRUD操作の提供

サービス層は「何を」するかというビジネスロジックを担当します:

  • 複数のリポジトリの調整・連携
  • トランザクション管理
  • ビジネスルールの適用
  • 入力バリデーション
  • 高レベルの処理フロー

具体例で考えてみましょう。ユーザー登録の処理では:

// リポジトリ層のコード例
class UserRepository {
  async findByEmail(email) {
    // SQLクエリやORMを使ってメールアドレスでユーザーを検索
    return db.query('SELECT * FROM users WHERE email = ?', [email]);
  }
  
  async create(userData) {
    // ユーザーデータをデータベースに挿入
    return db.query(
      'INSERT INTO users (name, email, password) VALUES (?, ?, ?)',
      [userData.name, userData.email, userData.password]
    );
  }
}

// サービス層のコード例
class UserService {
  constructor(userRepository, emailService) {
    this.userRepository = userRepository;
    this.emailService = emailService;
  }
  
  async registerUser(userData) {
    // 入力バリデーション(ビジネスルール)
    this.validateUserData(userData);
    
    // 既存ユーザーチェック(ビジネスルール)
    const existingUser = await this.userRepository.findByEmail(userData.email);
    if (existingUser) {
      throw new Error('このメールアドレスは既に登録されています');
    }
    
    // パスワードハッシュ化(ビジネスルール)
    userData.password = await bcrypt.hash(userData.password, 10);
    
    // トランザクション開始
    const user = await this.userRepository.create(userData);
    
    // 別のサービスとの連携
    await this.emailService.sendWelcomeEmail(user.email);
    
    return user;
  }
}
Code language: JavaScript (javascript)

この例では、リポジトリはデータベース操作に集中していますが、サービスはより高レベルなロジック(バリデーション、重複チェック、ハッシュ化、メール送信の連携など)を扱っています。

簡単な判断基準として:「データベースを別のものに切り替えても変更が必要ないコード」はサービス層、「データの保存方法に直接関わるコード」はリポジトリ層に書くべきだと考えるとわかりやすいかもしれません。

質問3:佐藤さんからの質問

質問:「実際の開発ではフロントエンドとバックエンドの境界設計が難しいと感じています。特にAPIの粒度をどう決めるべきか悩みます。細かすぎるとリクエスト数が増え、大きすぎると柔軟性が下がると思うのですが、良いバランスの見つけ方はありますか?」

回答: 佐藤さん、フロントエンドとバックエンドの境界設計、特にAPI粒度の決定は多くの開発者が悩むポイントです。以下にバランスを見つけるためのアプローチをいくつか紹介します:

  1. ユースケース駆動
    • ユーザーがアプリで何をするかに基づいてAPIを設計する
    • 例えば「タスク一覧の表示」「タスクの詳細表示」「タスクの作成」など、ユーザーの行動に合わせたエンドポイントを設計
  2. 画面/ビュー単位でのAPI設計
    • 1つの画面に必要なデータをまとめて取得できるエンドポイントを用意
    • 例:/api/tasks/dashboardで、ダッシュボード画面に必要なタスク一覧、統計情報、カテゴリ情報などを一度に返す
  3. パフォーマンスを考慮
    • モバイル環境では特にリクエスト数を減らすことが重要
    • 必要なデータのみを返す(オーバーフェッチの防止)
    • クエリパラメータで返すフィールドを制御する仕組み(GraphQLのようなアプローチ)
  4. キャッシュ戦略
    • 頻繁に変わらないデータは適切にキャッシュする
    • リアルタイム性が求められるデータは個別エンドポイントにする

具体的な例で考えてみましょう。タスク管理アプリでは:

粒度が細かすぎる例

GET /api/tasks            # タスク一覧
GET /api/categories       # カテゴリ一覧
GET /api/users            # ユーザー一覧
GET /api/tasks/stats      # タスク統計Code language: PHP (php)

粒度が大きすぎる例

GET /api/dashboard        # ダッシュボードに関連するすべてのデータCode language: PHP (php)

バランスの取れた例

GET /api/dashboard?include=tasks,categories,statsCode language: PHP (php)

実務では、最初から完璧なAPI設計を目指すのではなく、ユースケースの変化に応じて進化させていくことが重要です。また、BFFパターン(Backend For Frontend)を採用して、フロントエンド専用のAPIレイヤーを設けることで、バックエンドのドメインモデルとフロントエンドの表示モデルの違いを吸収することもできます。

結局のところ、「どのようなユーザー体験を提供したいか」「どのような開発体験を実現したいか」のバランスから最適な粒度を見つけていくのがベストだと思います。