🔌

TypeScriptで学ぶデザインパターン 〜Adapter編〜

に公開

はじめに

この記事は前回に引き続き、Java 言語で学ぶデザインパターンで学んだ内容のアウトプットです。フロントエンド開発における「Adapter パターン」の活用例として、何かのヒントになれば幸いです。

Adapter パターンとは?

Adapter パターンは、異なるインターフェースを持つクラスや関数を統一的なインターフェースで扱う方法を提供するデザインパターンです。本の中ではACアダプターによる電圧の変換を例として、異なる規格 (インターフェース) を持つもの同士を適合させる方法が紹介されています。こちらの記事にある「デザインパターンの例」を確認いただくと、イメージが湧くと思います。

Adapter パターンでは、次のような登場人物が出てきます。

Target (対象): いま必要になっているインターフェース
Client (依頼者): Targetで定められたインターフェースを必要としているプログラム
Adaptee (適合される側): 既に用意されているクラス・関数
Adapter (適合させる側): Adapteeを使ってTargetのインターフェースを満たすクラス・関数

具体例: APIのインターフェース変更に対応

ここではバックエンドAPIの仕様変更を例として、Adapter パターンの活用方法を考えましょう。

シナリオとしては、変更前のAPIを呼び出す getTodos 関数はよくテストされており、多数のコンポーネントから呼び出されている (依存先になっている) とします。フロントエンドの改修には時間を要するため、「バックエンドAPIの変更を先行リリースして、既存のコンポーネントへの影響は局所的に抑えたい」となるかもしれません。そんなときがAdapter パターンの出番です。

変更前のコード

変更前の /toods APIは、次のようなTODOの配列を返します。

type Todo = {
  id: number;
  title: string;
  status: "not_started" | "in_progress" | "completed";
};

こちらは、変更に対応する必要がある関数 (Adaptee) である getTodos 関数です。引数にTODOの状態を指定することができて、指定された場合はその状態のTODOに絞り込んで結果を返します。これらのテストケースが完備されており、再利用できる関数となっています。

// Adaptee
export const getTodos = (filter?: Todo["status"]) => {
  const res = await fetch("http://localhost:3000/todos");
  const { todos } = (await res.json()) as { todos: Todo[] };

  return filter ? todos.filter((todo) => todo.status === filter) : todos;
};
getTodos関数のテスト
import { describe, it, expect, beforeEach } from "vitest";
import { http, HttpResponse } from "msw";
import { Todo } from "@/features/todo/types";
import { server } from "@/mock/node";
import { getTodos } from ".";

describe("getTodos", () => {
  const fakeTodos: Todo[] = [
    { id: 1, title: "Learn TypeScript", status: "in_progress" },
    { id: 2, title: "Build API", status: "completed" },
    { id: 3, title: "Write tests", status: "in_progress" },
    { id: 4, title: "Deploy app", status: "not_started" },
  ];

  beforeEach(() => {
    // MSWでAPIレスポンスをモックする
    server.use(
      http.get<never, Todo[]>("/todos", () => {
        return HttpResponse.json({
          todos: fakeTodos,
        });
      })
    );
  });

  const testCases: {
    filter?: Todo["status"];
    expectedIds: number[];
  }[] = [
    {
      filter: undefined,
      expectedIds: [1, 2, 3, 4],
    },
    {
      filter: "in_progress",
      expectedIds: [1, 3],
    },
    {
      filter: "completed",
      expectedIds: [2],
    },
  ];

  // Vitestの `test.each` を使って、TODOの各状態をパラメーターとしてテストする
  test.each(testCases)(
    "returns todos filtered by status: $filter",
    async ({ filter, expectedIds }) => {
      const todos = await getTodos(filter);

      expect(todos).toHaveLength(expectedIds.length);
      expect(todos.map((todo) => todo.id)).toEqual(expectedIds);
    }
  );
});

getTodos 関数は多数のコンポーネント (Client) から利用されています。

todo-list.tsx
import { getTodos } from "@/features/todo/api";

// Client
export const TodoList = () => {
  const todos = await getTodos();

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <span className={todo.status === "completed" ? "line-through" : ""}>
            {todo.title}
          </span>
        </li>
      ))}
    </ul>
  );
};

変更後のコード

続いて、仕様変更後のコードを見ていきましょう。todos APIのレスポンスが返すTODOには次の変更が入りました。

  • 完了状態を表現する文字列は done とする
  • 完了の場合は、完了日 completedAt を持つ
type DuplicatedTodo = {
  id: number;
  title: string;
  status: "not_started" | "in_progress" | "completed";
};

type Todo = UncompletedTodo | CompletedTodo;

type BaseTodo = {
  id: number;
  title: string;
};

type UncompletedTodo = BaseTodo & {
  status: "not_started" | "in_progress";
};

type CompletedTodo = BaseTodo & {
  status: "done";
  completedAt: string;
};

Clientである todo-list.tsx は、変更前の getTodos 関数のインターフェースに依存しています。視点を変えると、インターフェースに変更がなければClientに変更は入らないため、Adapterを間に挟むことで変更の影響を getTodos 関数を定義したファイル内に留めます。

// Step 1: `getTodos` 関数を変更後のAPIレスポンスに合わせる
export const getTodosAdaptee = async (filter?: DuplicatedTodo["status"]) => {
  const res = await fetch("http://localhost:3000/todos");
  const { todos } = (await res.json()) as { todos: Todo[] };

  return filter ? todos.filter((todo) => todo.status === filter) : todos;
};

// Step 2: Target (変更前のインターフェース) を定義する
type GetTodos = (
  filter?: DuplicatedTodo["status"]
) => Promise<DuplicatedTodo[]>;

// Step 3: Adapterのファクトリー関数を実装する
const createGetTodosAdapter = (): GetTodos => {
  // クロージャの内部にAdapteeインスタンスを持つ
  const getTodos = getTodosAdaptee;

  return async (filter?: DuplicatedTodo["status"]) => {
    const todos = await getTodos(filter);

    return todos.map((todo) => {
      // ここでTODOオブジェクトの構造をゴニョゴニョしている
      if (todo.status === "done") {
        const { status: _status, completedAt: _completedAt, ...rest } = todo;
        const adaptedTodo: DuplicatedTodo = {
          status: "completed",
          ...rest,
        };

        return adaptedTodo;
      }

      return todo;
    });
  };
};

// Step 4: `getTodosAdaptee` に代わって、Adapterを定義して外部に公開する
export const getTodos: GetTodos = createGetTodosAdapter();

ポイントとして、「createGetTodosAdapter 内部に持つAdapteeを用いてTargetのインターフェースを実装する関数」を返しています。これにより、外から見た振る舞いを維持したまま、バックエンドAPIの仕様変更に対応できました。また、getTodos 関数のテストについても、リネームは必要ですがそのまま使えています。

おわりに

Adapter パターンを使うことで、異なるインターフェースを持つAPIやライブラリを統一的に扱えるようになります。この記事で紹介したサンプルでは、フロントエンドの変更を局所的に留め、よくテストされたコードを再利用できるといった利点がありました。その他にも、様々な形で応用可能なパターンだと思うので、色々試してみると面白そうです。

https://github.com/yuki-yamamura/learn-adapter-pattern

株式会社FLAT テックブログ

Discussion