🍋

【Next.js】タスク管理アプリで学ぶアーキテクチャ

2024/05/22に公開
2

はじめに

この記事は「ビジネスロジックがフレームワークやDBに依存せず、変更や拡張に強い設計であること」に重点を置いてタスク管理アプリを作成する過程を紹介したものです。

現在フロントエンドエンジニア2年目で、DDD(ドメイン駆動設計)の開発手法やクリーンアーキテクチャ、オニオンアーキテクチャといった設計方法の歴史や概念を勉強したので、実際に個人開発アプリに組み込んでみました。
まだ知識が浅く我流な部分もあるかと思いますが理解を深めるために記事に残します。

対象者

  • アーキテクチャの概要を知っている人

解説しないこと

  • Next.jsの機能の説明
  • アーキテクチャの詳しい説明(今回はアプリの実装の紹介です)

アーキテクチャを意識しタスク管理アプリを実装する

アプリ概要

このアプリは、エンジニアが日々のタスクを管理できるシンプルなタスクマネージャーです。ユーザーはタスクを追加、編集、削除でき、それぞれのタスクには期限や優先度を設定できます。

機能要件

  • タスク一覧の表示: ユーザーはタスク一覧を表示できる
  • タスク詳細の表示: ユーザーはタスクの詳細を表示できる
  • タスクの追加: Admin権限のユーザーは新しいタスクを追加できる。タスクにはタイトル、説明、期限、優先度(高、中、低)を含む
  • タスクの編集: Adminユーザーは既存のタスクの詳細を編集できる
  • タスクの削除: Admin権限のユーザーはタスクを削除できる
    Admin権限しか追加編集できないのは不便ですが今回は勉強のため。。。

使用技術

言語・フレームワーク:Typescript、Next.js(app Router)
DB:json-server(今回はダミーとしてローカルDBとして使えて、簡単にREST APIを構築することができるライブラリを用いました)

ディレクトリ構成

以下のようなディレクトリ構成です。

├── README.md
├── src
│   ├── app // ルーティング
│   ├── components // コンポーネント
│   ├── domain // ドメイン層
│   ├── application // アプリケーション層(ユースケース)
│   ├── infrastructure // インフラストラクチャ層
│   └── screen // プレゼンテーション層(UI)
└── package.json

手順

「ドメイン層」、「アプリケーション層(ユースケース)」、「インフラストラクチャ層」、「プレゼンテーション層(UI)」の4層に分けて実装しています。(オニオンアーキテクチャに近い)

ドメインエンティティの定義(ドメイン層)

ドメインエンティティはアプリの核となるデータを表します。
タスク管理アプリでは、「Task」と「User」がドメインエンティティになります。
なぜなら、タスク管理アプリの主目的はユーザーがタスクを効率的に管理することにあるため、「タスク」がアプリの中心的な要素となります。さらに、今回は操作者の識別と権限管理を加えたため、「ユーザー」も中心的な要素として扱われます。

属性(データ)と振る舞い(メソッド)を用意
  1. Task

    • 属性:
      • id: タスクの一意識別子
      • title: タスクのタイトル
      • description: タスクの詳細説明
      • dueDate: タスクの期限
      • priority: タスクの優先度(高、中、低)
      • status: タスクの現在のステータス
    • 振る舞い:
      • 属性のバリデーション:
        タイトル、説明は必須、ステータス必須
        クラス内でのみ使用するためprivateをつける
      • ステータス更新
      • タスク更新:編集項目があれば更新する
  2. User

    • 属性:
      • ID: ユーザーの一意識別子
      • username:ユーザーネーム
      • passwordHash:ハッシュ化済みパスワード
      • Role: ユーザーの役割(Admin、Member)
    • 振る舞い:
      • 有効なパスワードか検証(private):バリデーション8文字以上の英数字か検証
      • パスワードのハッシュ化(private):ハッシュ化する
      • パスワード検証: 提供されたパスワードがユーザーの保存されたパスワードハッシュと一致するかを検証
      • 管理者かどうかチェック:管理者かどうかを確認
      • ユーザー情報の更新: ユーザーネームを更新することができる

以下は「Task」クラスの実装例です。

src/domain/task.ts

export type Priority = "高" | "中" | "低";
export type Status = "未着手" | "進行中" | "完了";

export type TaskDetail = {
  title?: string;
  description?: string;
  dueDate?: string;
  priority?: Priority;
};

export class Task {
  public id: string;
  public title: string;
  public description: string;
  public dueDate: string;
  public priority: Priority;
  public status: Status;

  constructor(
    title: string,
    description: string,
    dueDate: string,
    priority: Priority,
    id?: string
  ) {
    this.id = id || randomUUID();
    this.title = title;
    this.description = description;
    this.dueDate = dueDate;
    this.priority = priority;
    this.status = "未着手";

    this.validate();
  }

  // 属性のバリデーション
  private validate() {
    if (!this.title) {
      throw new Error("Title cannot be empty.");
    }
    if (!this.description) {
      throw new Error("Description cannot be empty.");
    }
    if (!["高", "中", "低"].includes(this.priority)) {
      throw new Error("Invalid priority.");
    }
  }

  // ステータスの更新
  public updateStatus(newStatus: Status): void {
    this.status = newStatus;
  }

  // タスクの更新
  // 編集項目があれば更新する
  public updateTask({
    title,
    description,
    dueDate,
    priority,
  }: TaskDetail): void {
    if (title !== undefined) {
      this.title = title;
    }
    if (description !== undefined) {
      this.description = description;
    }
    if (dueDate !== undefined) {
      this.dueDate = dueDate;
    }
    if (priority !== undefined) {
      this.priority = priority;
    }

    this.validate();
  }
}


リポジトリインターフェイスの定義(アプリケーション層(ユースケース))

今回は主にDB操作のためのインターフェイスです。ここで重要なことは具体的なDB操作の実装は後回しにすることです。(インフラストラクチャ層で実装します。)
そのため、MySQLやFirebaseなど、使用するDBを容易に変更することが可能です。

リポジトリインターフェイスが必要な理由は、次項のアプリケーション層(ビジネスロジックを処理する層)がデータベースの具体的な実装に依存しないようにするためです。
もし、リポジトリインターフェイスがない場合、アプリケーション層は直接インフラストラクチャ層(具体的なデータベース技術)に依存する形となります。
リポジトリインターフェイスがあることによってアプリケーション層はデータ操作のためにリポジトリインターフェイスを通じてデータベースとやり取りを行うことで、依存を逆転することができます。

src/application/interface/task.repository.ts
import { Task } from "@/domain/task";

export interface TaskRepositoryInterface {
  save(task: Task): Promise<Task>;
  update(task: Task): Promise<Task>;
  findById(taskId: string): Promise<Task | null>;
  findAllTasks(): Promise<Task[] | null>;
  delete(taskId: string): void;
}

アプリケーション層の実装(アプリケーション層(ユースケース))

ユーザーシナリオに関連する処理、主にCRUD操作はアプリケーション層に含めます。
ユーザーのリクエストに応じてデータの流れや処理の順序をコントロールし、必要な入力検証、権限確認、データの永続化、外部サービスとの連携などを行います。

今回の機能要件に基づいたユースケースの例
  1. タスクの新規作成:Adminユーザーのみ新しいタスクを追加可能
    • ユーザーの存在とユーザーの権限の確認
    • DBに保存
  2. タスク一覧の取得
    • タスク一覧をDBから検索
  3. タスクの取得
    • タスクをDBから検索
  4. タスクの編集:Adminがタスクの全データを編集可能
    • タスクの存在確認
    • ユーザーの存在とユーザーの権限の確認
    • 編集内容に応じてタスクデータを更新
    • DBに保存
  5. タスクの削除:Adminのみが削除可能
    • タスクの存在確認
    • ユーザーの存在とユーザーの権限の確認
    • DBからタスク削除
src/application/service/task.ts
import { User } from "@/domain/user";
import { TaskRepositoryInterface } from "../interfaces/task.repository";
import { UserRepositoryInterface } from "../interfaces/user.repository";
import { Priority, Status, Task, TaskDetail } from "@/domain/task";

export class TaskService {
  constructor(
    private taskRepository: TaskRepositoryInterface,
    private userRepository: UserRepositoryInterface
  ) {}

  // タスクの新規作成
  async createTask(req: {
    userId: string;
    title: string;
    description: string;
    dueDate: string;
    priority: Priority;
  }) {
    // ユーザーの存在とユーザーの権限の確認
    const user = await this.userRepository.findById(req.userId);
    if (!user) throw new Error("User not found.");
    if (!User.isAdmin(user.role)) throw new Error("Only admins can add tasks.");
    const task = new Task(
      req.title,
      req.description,
      req.dueDate,
      req.priority
    );
    // DBに保存
    return await this.taskRepository.save(task);
  }


  // タスク一覧の取得
  async getAllTasks(): Promise<Task[] | null> {
    // タスク一覧をDBから検索
    return await this.taskRepository.findAllTasks();
  }


  // タスクの取得
  async getTaskById(taskId: string): Promise<Task | null> {
    // タスクをDBから検索
    return await this.taskRepository.findById(taskId);
  }


  // タスクの編集
  async editTask(req: {
    userId: string;
    taskId: string;
    title?: string;
    description?: string;
    dueDate?: string;
    priority?: Priority;
  }) {
    // タスクの存在確認
    const task = await this.taskRepository.findById(req.taskId);
    if (!task) throw new Error("Task not found.");
    // ユーザーの存在とユーザーの権限の確認
    const user = await this.userRepository.findById(req.userId);
    if (!user) throw new Error("User not found.");
    if (!User.isAdmin(user.role)) throw new Error("Only admins can add tasks.");
    if (
      req.title !== undefined ||
      req.description !== undefined ||
      req.dueDate !== undefined ||
      req.priority !== undefined
    ) {
      const updateTask: TaskDetail = {
        title: req.title,
        description: req.description,
        dueDate: req.dueDate,
        priority: req.priority,
      };
      // 編集内容に応じてタスクデータを更新
      task.updateTask(updateTask);
      // DBに保存
      return await this.taskRepository.update(task);
    } else {
      throw new Error("At least one edit task detail required.");
    }
  }


  // タスクの削除
  async deleteTask(req: { userId: string; taskId: string }) {
    // タスクの存在確認
    const task = await this.taskRepository.findById(req.taskId);
    if (!task) throw new Error("Task not found.");
    // ユーザーの存在とユーザーの権限の確認
    const user = await this.userRepository.findById(req.userId);
    if (!user) throw new Error("User not found.");
    if (!User.isAdmin(user.role))
      throw new Error("Only admins can delete tasks.");
    // DBから削除
    return await this.taskRepository.delete(req.taskId);
  }
}

リポジトリの本実装(インフラストラクチャ層)

DB操作に関する具体的な実装を行います。使用するDBによって異なる実装になります。また、前項でも述べたとおり、DBを別のものに差し替える場合、インフラストラクチャ層のみの修正で済みます。

今回のDB操作の例 ※今回はダミーのDB(json-server)を用いた実装
  • タスクの保存
  • タスク一覧の取得
  • タスクの取得
  • タスクの更新
  • タスクの削除
src/infrastructure/repositories/task.repository.ts
import { TaskRepositoryInterface } from "@/application/interfaces/task.repository";
import { Task } from "@/domain/task";

export class TaskRepository implements TaskRepositoryInterface {
  private baseUrl: string = "http://localhost:3001";

  // タスクの保存
  async save(task: Task): Promise<Task> {
    // DBにタスクを保存する処理の具体的な実装
    const response = await fetch(`${this.baseUrl}/tasks`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(task),
    });
    const data = await response.json();
    return new Task(data.title, data.description, data.dueDate, data.priority);
  }

  // タスク一覧の取得
  async findAllTasks(): Promise<Task[]> {
    const response = await fetch(`${this.baseUrl}/tasks`);
    const tasks = await response.json();
    return tasks;
  }

  // タスクの取得
  async findById(taskId: string): Promise<Task | null> {
    const response = await fetch(`${this.baseUrl}/tasks/${taskId}`);
    if (!response.ok) return null;
    const taskData = await response.json();
    // Task クラスのコンストラクタを使用してインスタンスを作成
    const task = new Task(
      taskData.title,
      taskData.description,
      taskData.dueDate,
      taskData.priority,
      taskData.id
    );
    return task;
  }

  // タスクの更新
  async update(task: Task): Promise<Task> {
    // DBにタスクを保存する処理の具体的な実装
    const response = await fetch(`${this.baseUrl}/tasks/${task.id}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(task),
    });
    const data = await response.json();
    return new Task(
      data.title,
      data.description,
      data.dueDate,
      data.priority,
      data.id
    );
  }

  // タスクの削除
  async delete(taskId: string): Promise<void> {
    await fetch(`${this.baseUrl}/tasks/${taskId}`, {
      method: "DELETE",
    });
  }
}

プレゼンテーション層の実装(プレゼンテーション層(UI))

プレゼンテーション層はレイヤーの中で最も外側に位置し、ユーザーインターフェースの実装を担います。プレゼンテーション層でようやくReactなどのUIライブラリの出番となります。

タスク一覧の取得

src/screen/tasks/index.tsx
import { TaskService } from "@/application/service/task";
import { AddTodo } from "@/components/tasks/AddTodo";
import { TodoList } from "@/components/tasks/TodoList";
import { TaskRepository } from "@/infrastructure/repositories/task.repository";
import { UserRepository } from "@/infrastructure/repositories/user.repository";
import React from "react";

const getAllTask = async () => {
  const taskRepository = new TaskRepository();
  const userRepository = new UserRepository();
  const taskService = new TaskService(taskRepository, userRepository);
  // DB処理を含む操作
  const tasks = await taskService.getAllTasks();
  return tasks;
};

export const TasksScreen = async () => {
  const tasks = await getAllTask();

  return (
    <div className="flex justify-center min-h-screen bg-gray-200">
      <div className="w-10/12 m-5  p-8  flex flex-col text-center  rounded-md bg-white">
        <h1 className="font-bold text-4xl text-gray-700">タスク一覧</h1>
        <div>
          <AddTodo />
          <TodoList tasks={tasks || []} />
        </div>
      </div>
    </div>
  );
};

こちらはNext.js 13以降のServer Componentsを用いています。
Server ComponentはgetAllTaskのような関数の中にサーバー側の処理を直接書いてコンポーネントの中で直接呼び出すことができます。

タスク新規作成などのフォームを使用したユーザーのインタラクティブな操作は'use client'を用いてクライアントコンポーネントを使用する必要があるので直接サーバー側の処理を書くことはできません。
(Next.js 14のServer Actionsの機能を使用すればサーバー側の処理も書くことができる)
そのため、Next.jsのRoute Handlersの機能を用いてAPIを作成し、UI側でそのAPIを呼び出す必要があります。

まとめ

DBやフレームワークのの差し替えがあってもドメイン層の変更が不要な設計でアプリケーションの実装をすることができました。

アーキテクチャは、システム全体の構造やビジネスロジックの整理に重点を置いており、これらは主にバックエンドの設計で考慮されることが多いです。
フロントエンドの開発はUIに集中しているため、このようなアーキテクチャを意識することが少なかったように思えます。
今回はNext.jsでビジネスロジックからUIまでの実装を行いました。フロントエンドとバックエンドが明確に分かれたプロジェクトの場合はもっと良い設計方法があるかもしれません。

今回のアプリのリポジトリはこちらです。記事では紹介しきれなかったタスク登録、更新、削除などの操作も実装しています。
https://github.com/kiwichan101kg/clean_architecture_todoapp

Discussion

食パン🍞食パン🍞

こんにちは!
記事の方、大変興味深く拝読いたしました。
DDD的な設計について、フロントエンドにおける具体的な実装例とともに示している記事は決して多くないため、とても勉強になりました🏜

一点、src/domain/task.tsのコード例において、

export class Task {
  public id: string;
  public title: string;
  public description: string;
  public dueDate: string;
  public priority: Priority;
  public status: Status;

上記部分が重複して記述されているため、こちらはもしや誤記ではないかなと考えました。
ご確認いただければ幸いです。

kiwichankiwichan

コメントありがとうございます!
完全に誤記でした...修正いたしました。ご指摘ありがとうございます!