❤️‍🔥

新・パッション駆動開発

に公開

こんにちは。
4月から熱い会社に入社する、一児の母エンジニアです。
日々スピード感をもってプロダクトを作るなかで開発手法にも“熱さ”が必要だと感じ、熱い会社ならではの開発駆動として『パッション駆動開発』を提唱することにしましたので、備忘録としてまとめたいと思います。

現状

「パッション駆動開発」は「情熱だけを持って雑にシステム作る」という皮肉的な意味合いで使われているようです。
それを、弊社のMVVに絡めた、本気の開発手法として定義し直したいと思いました。

新・パッション駆動開発とは

主に2つのポイントがあります。

①世の中の課題を解決するサービス開発

世の中は課題だらけです。課題を解決するシステム、そしてそれを開発するエンジニアも不足している状況です。一体生きているうちに幾つサービスを開発できるのか。いますぐ行動して、スピード感を持って開発、運用しないと人生あっという間に終わってしまいます。ウォーターフォール?アジャイル?いいえ、パッション駆動開発です!

②ファイヤーベースな開発

パッション!! = ファイヤー!!
ファイヤー!!なベースで取り組むプロジェクト。
そう、ファイヤーベースを使った開発です。

つまり、Firebase のような NoSQLなデータベースを使ってスキーマレスにスピード感を持って開発していきます。

ファイヤーベースなパッション駆動開発の進め方

実際の開発の進め方を説明します。
今回は例として以下のようなフロントエンドの技術スタックで 「授乳室を検索するサービス」 を開発します。

  • 裏側アプリ(管理画面): Nuxt3, SPAモード
  • 表側サイト: Remix, SSRモード
  • UI: Primevue, Radix, Tailwind,
  • バリデーション: Zod
  • その他: TypeScript, Vite
  • DB: Firestore(NoSQL)
  • モバイル: Flutterなど

今回はモバイルには触れず、WEB開発について諸々環境構築が終わった後の流れを説明していきます。

大まかな流れ

  1. 型を定義
  2. フォームを作成
  3. Doc 作成時の変換処理
  4. Doc 取得時の加工処理
  5. 一覧テーブルへの表示

①型を定義

Firestore ドキュメント共通の型定義

まずは共通の型である、Firestoreのための型をTypeScriptで定義します。

import type { Timestamp } from "firebase/firestore";

export interface RawTimestamps {
  created_at: Timestamp;
  updated_at: Timestamp;
  deleted_at?: Timestamp;
}

created_at, updated_at が必須、deleted_at がオプションで、型は Firestore の Timestamp 型。これが全てのドキュメントのベースとなります。

Placeエンティティ

次に授乳室のある場所である「Place」エンティティについて考えていきます。

export interface RawPlace extends RawTimestamps {
  /**
   * 場所の名前
   */
  name: string;
}

RawTimestampsを拡張して、RawPlaceと定義し、nameを追加しました。場所名です。「ららぽーと東京ベイ」みたいな感じです。
また、RawPlaceのようなrawがつくものは、Firestoreに直接保存する時の型という意味になります。このプレフィックスはお好みで大丈夫です。interfaceで定義するのもポイントです。

rawが付かない型は、つまり、アプリ上で使うために加工された型、ということになります。
タイムスタンプを例にとると、Firestore の Timestamp 型はそのままでは使えないので、stringに変換(加工)します。その加工後の型がこちらです。

export type Timestamps = {
  createdAt: string;
  updatedAt: string;
  deletedAt?: string;
};

raw が外れて Timestamps となり、プロパティはスネークケースからキャメルケースになりました。また、RawTimestamps は interface で定義でしたが、Timestamps は type で定義していることもポイントです。これに、プロパティ id を加えたら、共通のDocBaseの出来上がりです。

export type DocBase = {
  id: string;
} & Timestamps;

次に、rawではないPlaceを定義します。アプリ上で使うためにRawPlaceを加工した後の型です。

export type Place = Omit<RawPlace, keyof RawTimestamps> & {} & DocBase;

RawPlaceからRawTimestampsを取り除き、代わりにDocBaseを追加したものを Place としました。
先ほどRawPlacenameを追加しましたが、今回Placeではnameは変換する必要はないので、特にここでは追加するプロパティはありません。
変換する必要があるものは、Omit で取り除き、{}内にキャメルケースで追加する、そして共通のプロパティDocBaseを付け加える、という流れになります。

ちょっとわかりにくいので、最終的に出来上がったPlaceエンティティの型を見たいと思います。

/** 加工前 */
interface RawPlace {
  name: string;
  created_at: Timestamp;
  updated_at: Timestamp;
  deleted_at?: Timestamp;
}

/** 加工後 */
type Place = {
  id: string;
  name: string;
  createdAt: string;
  updatedAt: string;
  deletedAt?: string;
}

まとめると、

  • Firestore型(加工前)
    • Firestoreに保存するときの型
    • プレフィックスにrawなどをつける
    • プロパティはスネークケース
    • interface で定義
  • 加工後の型
    • Firestoreのデータ加工後の型
    • rawなどはつけない
    • プロパティはキャメルケース
    • type で定義

最初はとりあえずnameだけで進めます。

②フォームを作成

データを作らなければ何もできません。
モックデータを作るのもいいですが、パッション駆動開発は全て同時進行で行います!

まずはデータを作成するためのフォームを作っていきます。
primevue の Form を使います。

テンプレートはこのようになりました。

<Form :initialValues="initialValues" :resolver="resolver" v-slot="$form" @submit="handleSubmit">
    <div>
      <label for="name">名前</label>
      <InputText name="name" id="name" placeholder="名前を入力" fluid />
      <Message v-if="$form.name?.invalid" severity="error" size="small" variant="simple">
        {{ $form.name.error.message }}
      </Message>
    </div>
  </Form>

これでnameを入力することができますね。
テンプレート内で使っている変数を<script setup lang="ts">内で定義していきます。
まずはinitialValues。フォームの初期値です。

const initialValues = ref({
  name: undefined,
})

次に resolver です。Zod でフォームの値のバリデーションを定義できます。

const resolver = zodResolver(
  z.object({
    name: z.string()
      .min(2, '2文字以上で入力してください')
      .max(50, '50文字以内で入力してください'),
  }),
);

name は必須であり、空白または2文字〜50文字の間でなければエラーを出すようにします。

最後に handleSubmit で、作成ボタンを押した時の処理を書きます。
今回は Primevue の <ConfirmDialog /> を使って確認モーダルを出しています。

const confirmSubmit = async (values) => {
  confirm.require({
    message: "本当に作成しますか?",
    header: "確認",
    icon: "pi pi-exclamation-triangle",
    accept: async () => {
      try {
        await addPlaceDoc(values);
        toast.add({
          severity: "success",
          summary: "保存完了",
          detail: "新規 Place の保存に成功しました。",
          life: 3000,
        });
      } catch (error) {
        console.error("Error creating place:", error);
        toast.add({
          severity: "error",
          summary: "作成失敗",
          detail: "新規 Place の保存に失敗しました。",
          life: 3000,
        });
      }
    },
    reject: () => {
      console.log("作成がキャンセルされました");
    },
  });
};

const handleSubmit = async ({ values, valid }) => {
  if (!valid) return;
  await confirmSubmit(values);
};

await addPlaceDoc(values) ここでは実際にFirestoreのplacesコレクションにplaceドキュメントを新規作成する処理をしています。この処理は次のステップでNuxt3のcomposablesに書いていきます。

③FirestoreへのCRUD操作

Nuxt3 の composables ディレクトリに usePlace.ts を作って、PlaceエンティティのCRUD操作や状態管理を書いていきます。まずはRを除いてCUDを書いてみます。

// ここに必要なものをimport(省略)

export const usePlace = () => {
  const { app, db } = useNuxtApp().$firebase;

  /**
   * Firestore の places コレクションに新規ドキュメントを追加する
   * @param data
   * @returns 追加されたドキュメントの参照を返す
   */
  const addPlaceDoc = async (data) => {
    try {
      const now = serverTimestamp();
      const docData: RawPlace = {
        ...data,
        created_at: now,
        updated_at: now,
      };
      const docRef = await addDoc(collection(db, "places"), docData);
      return docRef;
    } catch (error) {
      console.error("Error creating place:", error);
      throw error;
    }
  };

  /**
   * Firestore の places コレクション内のドキュメントを更新。
   *
   * @param {string} id - 更新対象のドキュメントID
   * @param {PlacePertial} data - 更新するフィールドと値のオブジェクト
   * @returns {Promise<void>}
   */
  const updatePlaceDoc = async (
    id: string,
    data: PlacePertial,
  ): Promise<void> => {
    try {
      const placeDoc = doc(db, "places", id);
      await updateDoc(placeDoc, {
        ...data,
        updated_at: serverTimestamp(),
      });
    } catch (error) {
      console.error("Error updating place:", error);
      throw error;
    }
  };

  /**
   * Firestore の places コレクション内のドキュメントを削除。
   *
   * @param {string} id - 削除対象のドキュメントID
   * @returns {Promise<void>}
   */
  const deletePlaceDoc = async (id: string): Promise<void> => {
    try {
      const placeDoc = doc(db, "places", id);
      await deleteDoc(placeDoc);
    } catch (error) {
      console.error("Error deleting place:", error);
      throw error;
    }
  };

  return {
    addPlaceDoc,
    updatePlaceDoc,
    deletePlaceDoc,
  };
};

これで name の情報を持つ Firestore のドキュメントが作成(または更新、削除が)できるようになりました。

次に、composables の usePlace.ts で Place データの取得と状態管理を書いて行きます。

④Firestoreからのデータ取得と加工

今回は、Firestoreの購読機能を使って、リアルタイムのデータを取得することにしました。
usePlace の続きから書きます。

export const usePlace = () => {

  // ...

  const placeTreeNodeList = useState<TreeNode[] | undefined>(
    "placeTreeNode",
    () => undefined,
  );

  /**
   * Firestore の places コレクションの変更をリアルタイムに購読。
   * 既に購読中の場合は処理を中断。
   *
   * @returns {void}
   */
  const isSubscribed = useState("isPlaceSubscribed", () => false);
  const subscribeToPlaces = () => {
    if (isSubscribed.value) return;

    const placesCol = collection(db, "places");

    onSnapshot(
      placesCol,
      async (snapshot) => {
        placeTreeNodeList.value = createPlaceTreeNodeList(snapshot);
      },
      (error) => {
        console.error("Error fetching places in realtime:", error);
      },
    );
    isSubscribed.value = true;
  };

  // ...
  return {
    // ...
    placeTreeNodeList,
    subscribeToPlaces,
    // ...
  };
}

購読処理の中で使われている createPlaceTreeNodeList という関数では、受け取った RawPlacePlace へと加工し、それをTreeNodeListとして整形する処理をしています。

今回は自己参照型なので、placeTreeNodeList として primevue の TreeNode に対応して作っていますが、単純なリストなら普通の配列で良いと思います。

//ここに必要なimport

/**
 * Firestore の QuerySnapshot を元に、places のツリー構造を表現する TreeNode のリストを生成。
 *
 * 以下のステップで処理を実行:
 * 1. snapshot 内の各ドキュメントを RawPlace として取得し、Place オブジェクトに変換。
 * 2. 各 Place オブジェクトから TreeNode を生成し、treeNodeMap に登録。
 * 3. 各 TreeNode の parentId を参照し、親が存在する場合は、treeNodeMaから対応する親ノードを探してその children 配列へ追加、
 *    親が存在しない場合はルートノードとして treeNodeList に追加。
 *
 * @param {QuerySnapshot} snapshot - Firestore から取得した QuerySnapshot。各ドキュメントは RawPlace 型のデータを含む。
 * @returns {TreeNode[]} 生成されたツリー構造のルートノードのリスト。
 */
const createPlaceTreeNodeList = (snapshot: QuerySnapshot) => {
  const treeNodeMap: Record<string, TreeNode> = {};
  const treeNodes = snapshot.docs.map((docSnapshot) => {
    const rawPlaceData = docSnapshot.data() as RawPlace;
    const place: Place = {
      ...rawPlaceData,
      id: docSnapshot.id,
      createdAt: formatTimestampToString(rawPlaceData.created_at),
      updatedAt: formatTimestampToString(rawPlaceData.updated_at),
      deletedAt: rawPlaceData.deleted_at
        ? formatTimestampToString(rawPlaceData.deleted_at)
        : undefined,
    };
    const treeNode: TreeNode = {
      id: place.id,
      key: place.id,
      parentId: place.parent_id,
      parent: undefined,
      data: place,
      children: [], // 後にセット
    };
    treeNodeMap[place.id] = treeNode;
    return treeNode;
  });

  const treeNodeList: TreeNode[] = [];
  for (const treeNode of treeNodes) {
    const parentId = treeNode.parentId;
    if (parentId && treeNodeMap[parentId]) {
      // 親を持っている場合はtreeNodeMapから親を探してそのchildrenへ追加
      treeNode.parent = treeNodeMap[parentId].data;
      treeNodeMap[parentId].children?.push(treeNode);
    } else {
      // 親が存在しない場合はルートノードとしてtreeに追加
      treeNodeList.push(treeNode);
    }
  }

  return treeNodeList;
};

⑤場所一覧テーブルを作成

最後に、データを一覧表示するためのテーブルを作成します。
今回の Place は自己参照型の設計のため、Primevue の TreeTable を使いましたが、そうじゃない場合は普通に DataTable で良いと思います。

  <TreeTable :value="placeTreeNodeList" sortMode="multiple" removableSort :filters="filters" filterMode="lenient"
    scrollable size="small">
    <!-- カラム -->
    <!-- ID -->
    <Column key="id" header="ID" expander sortable>
      <template #filter>
        <InputText v-model="filters['id']" placeholder="IDで絞り込み" />
      </template>
      <template #body="{ node }">
        <div @click.stop="router.push(`/places/${node.data.id}`)" class="cursor-pointer">{{ node.data.id }}</div>
      </template>
    </Column>
    <!-- 名前 -->
    <Column field="name" key="name" header="名前" sortable>
      <template #filter>
        <InputText v-model="filters['name']" placeholder="名前で絞り込み" />
      </template>
    </Column>
  </TreeTable>

Primevue の Table で困ったのが、行クリック機能がなかったことです。おそらくアップデートされて将来的には可能になると思いますが、現段階では代わりにidをクリックすると詳細ページが開くようにしています。

ヘルパー関数

createPlaceTreeNodeList 内で使われている formatTimestampToString は、Firestore の Timestamp型をstringへ変換するヘルパー関数です。
Nuxt3 の utils ディレクトリに追加します。
今回は cdate を使いました。

import { cdate } from "cdate";
import type { Timestamp } from "firebase/firestore";

export const formatTimestampToString = (timestamp: Timestamp) =>
	cdate(timestamp.toDate()).locale("ja").format("YYYY-MM-DD (ddd) HH:mm");

こうすると、2025-03-11 (火) 17:37 のように表示されます。

ここまで終わったら・・・

あとは、name に続いて、必要な情報を追加していくだけです!
例えば次に「郵便番号」という情報を追加したいとき

  1. RawPlaceに、DB用の型 postal_code: string を追加する
  2. 加工する必要はないので、Place には何も追加しない
  3. フォームに postal_code フィールドを追加、inicialValues にも初期値を追加する
  4. バリデーションを追加する
    • postal_code: z.string().regex(/^\d{3}-\d{4}$/, '「000-0000」の形式で入力してください'),
  5. Firestore用に変換する必要はないので addPlaceDoc では何もしない
  6. 受け取ったデータを加工する必要はないので createPlaceTreeNodeList では何もしない
  7. 一覧テーブルのカラムに郵便番号を追加して表示

この手順で進めれば、簡単に情報を追加することができます。
stringなら単純ですが、enumや、別ドキュメントを参照する場合などはもう少しステップが増えていきますので、またの機会にまとめたいと思います。

やることや確認事項が決まっていてレビューしやすいと思うので、社内でのOJTとしても活用できそうですね。

データが蓄積された後のステップとしてRemixで公開用のサイトを作っていきます。すでに定義されたPlace型を使って、爆速で作ることができますよね。

パッション駆動開発はDB設計をしない

スキーマレスなFirestoreとこのフローを使えば、設計をしっかりしなくても、思いつきで情報をすぐに追加できるので、スピード感を持って開発ができます。
パッションが先走ったとしても、開発で追いつくことができます。

そして出来上がったプロトタイプを試験運用して、軌道修正を繰り返したあと、仕様が固まったらSQLのバックエンドへ移行して開発し、本番運用していきます。
そのために、Nuxt3 なら composables に Firestore の処理を書いて、存在感を隠す、というのも大事なポイントですね。

ぜひ、皆さんも「パッション駆動開発」で、社会課題を解決するサービス(のプロトタイプ)を量産してみてください。

また、改善点などがある場合はコメントいただけると嬉しいです。

Discussion