iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

最初から完璧は目指すな──初めて設計するあなたへ伝えたい、TypeScript設計の最低ライン

はじめに

本記事はiimon Advent Calendar 2025 21日目の記事です。

今年も12月21日生まれの私、株式会社iimonのエンジニアマネージャー、まつだが担当させていただきます。

今回は社内でも複数の新規プロジェクトが立ち上がりつつある状況ということで、初めて0→1の設計を任された人へのアドバイスという形で記事を書いてみました。

弊社ではTypeScriptを使うことが多いのでタイトルにもTypeScriptと入れましたが、言語が違っても役に立つことはあると思いますので、ぜひご一読いただければ幸いです。

まず最初に伝えたいこと

誰でも最初は、「とにかくきれいな構造にしないと」、「機能拡張しやすい設計にしないと」と力が入ってしまいがちです。

ですが、私の20数年のキャリアの中でも最初から理想どおりの設計にできたことなどない⋯いや、「これが理想だ!」というものにたどり着けたことは、ただの1度もないと言ってもいいでしょう。

設計は”最初から完成させるもの”ではありません。

Kent Beckが著書「Tidy First?」で「Tidying(整頓、片付け)」と称したような小さな変更を繰り返しながら、育てていくものなのです。

ただ、最初から意識できることもあります。

それが、今回紹介するようなテクニックを使った、”壊れにくい設計” であり、"品質=スピードを上げる設計”です。

ソフトウェア品質特性を意識する

ソフトウェアの良し悪しを評価するための指標として使えるのが、ISO/IEC 25010(JIS X 25010)などで規定されている「ソフトウェア品質特性」です。

今回は1つずつ詳しく説明はしませんが、設計に迷ったときはこの指標に照らして判断するといいでしょう。

早速、実際にソフトウェア品質特性を意識した設計テクニックを紹介していきましょう。

これだけ意識して!(1):依存方向は必ず1方向に

設計に携わる人なら、1度は読んだことがあるかもしれない「Clean Architecture 達人に学ぶソフトウェアの構造と設計」に有名な同心円の図があります。

画像出典: Robert C. Martin 「The Clean Architecture」

blog.cleancoder.com

よく意味が誤解されがちなこの同心円ですが、この図は一例であり、この通りにする必要はありません。

大事なのは、「依存方向を1方向にすること」、そして「変わりやすい”詳細”(UIやDBなど)からビジネスロジックという変わりにくい”ルール”に依存する」ということです。

TypeScriptで具体例を見てみましょう。(本記事中のコードはイメージだけをシンプルに掴んでいただくため、一部初期化処理等を意図的に省いています。そのままでは動作しませんのでご留意ください)

// domain/user.ts

// ❌ 外部技術(firestore)に依存
import { doc, getDoc } from "firebase/firestore"; 

export async function loadUser(id: string) {
  const snap = await getDoc(doc(db, "users", id));
  return snap.data();
}

domain(変わりにくいもの)が外部技術(ここではFirestore)を知ってしまっています。

これではもしFirebaseからSupabaseに変更したい、などという要望があるたびにdomainを修正する必要があります。

テストを書くのも大変ですね。

// domain/user.ts
export type User = {
  id: string;
  displayName: string;
};

このようにdomainは何にも依存しないようにし、ドメインのルール、ロジック(今回は何もないですが)だけを持つようにし、

// usecase/loadUser.ts
import { User } from "../domain/user";

export interface UserRepository {
  findById(id: string): Promise<User | null>;
}

export async function loadUser(
  repo: UserRepository,
  id: string
): Promise<User | null> {
  return repo.findById(id);
}

usecaseは「ユーザーを取得する」というユースケースだけを表現しましょう。(ここでもFirestoreのことは知らない)

Firestoreのことを知っているのはinfra層です。

// infra/firebaseUserRepository.ts
import { doc, getDoc, Firestore } from "firebase/firestore";
import { User } from "../domain/user";
import { UserRepository } from "../usecase/loadUser";

type UserDoc = {
  displayName: string;
};

export class FirebaseUserRepository implements UserRepository {
  constructor(private readonly db: Firestore) {}

  async findById(id: string): Promise<User | null> {
    const ref = doc(this.db, "users", id);
    const snap = await getDoc(ref);

    if (!snap.exists()) return null;

    const data = snap.data() as UserDoc;
    return {
      id,
      displayName: data.displayName,
    };
  }
}

もしデータベースのスキーマが変更されても、影響を受けるのはこのinfra層だけです。

このように依存方向を1方向にすることで、変更の影響範囲が最小限になり、ソフトウェア品質特性でいう「保守性」「変更容易性」「再利用性」などを上げることができます。

これだけ意識して!(2):I/O(副作用を起こすところ)は隔離する

// usecase/updateUser.ts
import { User } from "../domain/user";

export async function updateUser(user: User) {
  // ❌ usecaseで直接API呼び出し
  await fetch("/api/users", { method: "POST", body: JSON.stringify(user) });
}

依存方向を1方向にしました!usecaseからはdomainしかimportしていないので、”変わらないルール”側にしか依存していません。めでたしめでたし⋯とはなりません。

上記の例では、usecaseに副作用(同じ入力でも、結果や周囲の状態が変わる処理)が含まれてしまっています。

これでは自動テストも書きづらいですし、責務も曖昧(usecaseがfetchするという責務も負ってしまっている)です。

// usecase/updateUser.ts
import { User } from "../domain/user";

export interface UserRepository {
  save(user: User): Promise<void>;
}

export async function updateUser(
  repo: UserRepository,
  user: User
): Promise<void> {
  // ここでは「保存する」というユースケースの実現だけ
  await repo.save(user);
}

こうすることで副作用をinterface越しに起こさせることができるようになり、自動テストでは repo を差し替えて(UserRepositoryをモックするだけで)テストすることも可能になり、「保守性」(テスト容易性)を上げることにつながります。

// infra/apiUserRepository.ts
import { User } from "../domain/user";
import { UserRepository } from "../usecase/updateUser";

export class ApiUserRepository implements UserRepository {
  async save(user: User): Promise<void> {
    await fetch("/api/users", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(user),
    });
  }
}

副作用はinfra層に隔離することで、APIの仕様変更時にはここだけ修正すればよくなりますね。

これだけ意識して!(3):自動テストを最初に書く(テストファースト

実装より先に、必ず最低1つ以上の自動テストを書いてください。

そのテストが、プログラムで実現したい「振る舞い」が変わらないことを保証してくれます。

冒頭にも書いたように、設計の完成度を高めていくには幾度も「Tidying(整理、片付け)」や「リファクタリング」を行うことになります。

その時に、振る舞いが壊れていないことを裏付けてくれる自動テストは絶大な威力を発揮します。

既存の実装にテストを書こうとして悩むとき、頑張ってモックを作らないと⋯と思うとき、そんなときは「これだけ意識して!(1):依存方向は必ず1方向に」が正しくできているか疑ってみてください。意外と依存が逆転しているケースも多いです。

言うに及ばず、自動テストはソフトウェア品質特性の「信頼性」を上げてくれます。

これだけ意識して!(4):仕様変更が入ったときに修正する箇所を最小限にする

こんなコードを書いてしまったことはありませんか?私はあります⋯

// a.ts
if (user.role === "admin") { /* ... */ }

// b.ts
const canEdit = user.role !== "viewer";

// c.ts
if (user.role === "editor" || user.role === "admin") { /* ... */ }

ここに仕様変更が入りました。「roleに readonly を追加してください」「viewerも一部のフィールドだけ編集可能にしてください」⋯大変です。

こんなときは、仕様変更の際にさわるファイルをできるだけ少なく(1〜2個程度に)する工夫をしましょう。

// rules/permission.ts
export type Role = "viewer" | "editor" | "admin";

export function canEdit(role: Role): boolean {
  return role === "editor" || role === "admin";
}
// feature/editButton.ts
import { canEdit } from "../rules/permission";

if (canEdit(user.role)) {
  // edit buttonを表示する
}

このようにすれば、仕様変更が入ったときは1箇所(permission.ts)だけさわれば大丈夫です。

実はこのコード、まだまだ良くできるのですが、その説明は次項(判別可能なユニオン型)で行います。

これだけ意識して!(5):判別可能なユニオン型 (discriminated union)で分岐を表現

TypeScriptのプロジェクトでは、こんな型を見ることも多いのではないでしょうか。

type User = {
  isLoading: boolean;
  hasError: boolean;
  data?: UserData;
};

パッと見て、このコードの問題点に気づいた方は読み飛ばしていただいて大丈夫ですが、実によく見かけるコードです。

このコードの問題点は、”ありえない組み合わせが作れる”(isLoadingtrueなのに data があるなど)ことです。

状態が増えたときにわかりづらかったり、分岐がたくさん散らばったりといった問題もあります。

こんなときは、判別可能なユニオン型 (discriminated union)を使いましょう。

type UserState =
  | { type: "loading" }
  | { type: "success"; data: UserData }
  | { type: "error"; error: Error };

状態ごとに持てるデータが決まるので、先ほどのような isLoadingtrue なのに data があるような不整合状態になることはありません。

function render(state: UserState) {
  switch (state.type) {
    case "loading":
      return "Loading...";
    case "success":
      return state.data.name;
    case "error":
      return state.error.message;
    default:
      return assertNever(state);
  }
}

function assertNever(x: never): never {
  throw new Error("Unhandled state");
}

こうすることで、状態を追加したときに対応を忘れてしまっても必ずここでエラーが発生しますので、修正漏れもありません。

最後に、これを「仕様変更が入ったときに修正する箇所を最小限にする」でご紹介した例に適用してみましょう。

// rules/permission.ts
export type Role =
  | { type: "viewer" }
  | { type: "editor" }
  | { type: "admin" };

export function canEdit(role: Role): boolean {
  switch (role.type) {
    case "viewer":
      return false;
    case "editor":
      return true;
    case "admin":
      return true;
    default:
      return assertNever(role);
  }
}

function assertNever(x: never): never {
  throw new Error("Unhandled role");
}

この場合も、新しく readonly という Role を追加したときに canEdit を設定し忘れるとエラーが発生するので便利ですね。

まとめ

ここまで、ソフトウェア品質特性に注目した上で、”壊れにくい設計”にする方法をいくつか見てきました。

これらは最低限のラインですが、意外と意識できていないプロジェクトは多いですし、これらを意識するだけで自信を持って設計を変更し、より高みを目指していけるはずです。

繰り返しになりますが、最初から完璧を目指す必要はありません。

小さなTidying(整頓、片付け)を繰り返しても振る舞いを壊さない状況を作り出し、じっくり設計を育てていきましょう。

おわりに

ここまで読んでくださってありがとうございます!

明日の担当は、頼りになるチームリーダーkogureさんです。

弊社ではエンジニアを募集しています!少しでもご興味がありましたら、ぜひカジュアル面談でお話しましょう!(私が担当させていただきます!)

iimon採用サイト / Wantedly

参考

Tidy First? ―個人で実践する経験主義的ソフトウェア設計 Kent Beck著 オライリー・ジャパン