Closed12

花束問題のシステムをTypeScriptで作る

tekihei2317tekihei2317

花束問題とは

花束問題は、在庫管理をテーマにしたデータモデリングの練習問題です。花屋さんの問題を解決するためにWebシステムを作るという想定で、データモデリングを行います。

NPO法人 IT勉強宴会 - 花束問題V1.2

花束問題のシステムを作る動機

Domain Modeling Made Functionalという本を読んだことがきっかけです。本の内容は、今まで読んだ同じようなテーマの本の中では一番納得できるものでした。

Amazon.co.jp: Domain Modeling Made Functional: Tackle Software Complexity with Domain-Driven Design and F#

この本はF#で書かれているので、TypeScriptで書くとどうなるのかが気になりました。作るものは業務アプリケーションっぽいものが適しているので、自分が知っている花束問題のシステムを作ってみることにしました。

Domain Modeling Made Functionalでは、サンプルが1つのワークフロー(APIエンドポイント)しか扱っていないため、モデルを作ることのありがたみがいまいち分かりませんでした。花束問題には複数のワークフローがあるため、モデルのありがたみが分かりそうです。

また、Domain Modeling Made Functionalのサンプルコードは外部とのIO(データベースなど)をダミーの実装を使っていたので、Prismaなどを使ってより実践的な例で試してみようと思っています。

tekihei2317tekihei2317

ひとりイベントストーミングをする

イベントストーミングとは、業務をモデリングするために複数人で行うワークショップです。イベントストーミングの目的は、プロジェクトに関わるメンバーでの共通認識を育てたり、コンテキスト(サブドメイン)の境界を見つけることです。

Miroにイベントストーミングのテンプレートがあったので、一人で雰囲気でやってみました。一人の場合でも、システムの全体像が掴めたり、システムの複雑さが分かるので、効果的なモデリング方法なのかなと思いました。

tekihei2317tekihei2317

これからすること

システムの概要が掴めたので、これからすることを整理します。

  • 対話をしながら、各ワークフローを掘り下げて行く
  • ワークフローを自然言語や型を使ってモデリングをする
  • 画面のモックを作成する
  • データベースのモデリングをする
  • 実装していく(認証、注文、仕入れ、加工・出荷、メンテナンス)

まず、お客さん(ドメインエキスパート)と話しながら各ワークフロー(コマンドによって実行される一連の処理)を掘り下げていきます。なぜ話をするかというと、先入観によって現実と異なるモデルを作ることを防いだり、開発者が分からない部分を明らかにするためです。お客さん役はChatGPTにやってもらおうと思っています。

次に、ワークフローを自然言語やTypeScript型を使ってモデリングしていきます。最初に自然言語でモデリングをするのは、Domain Modeling Made Functionalで取り入れられている方法です。

それと同時に、画面のモックを作成したり、データベースのモデリング(論理設計)をします。

最後に実装をします。実装は、依存されていないコンテキストから行います(業務の時系列と同じ順番)。具体的には、認証→メンテナンス→仕入れ→注文→加工・出荷のような順番です。こうすると、前提が間違っていて作り直すということを防ぎやすいからです。

tekihei2317tekihei2317

ワークフローの掘り下げ・モデリング

コンテキストごとにGitHubにディスカッションを立てて、ワークフローの概要を自然言語で記述しました。

仕入れのモデリング · tekihei2317/frere-memoir · Discussion #5

ワークフローを掘り下げていった上で、特に気になったのは花の廃棄と在庫推移の部分です。

  • 品質維持可能日数を超えた花は廃棄するとあるが、過ぎる前や過ぎた後に廃棄することもあるのか
  • 在庫推移は、品質維持可能日数の分だけ列が増える。品質維持可能日数の最大値はどれくらいになるのか。

これらをディスカッションのコメントに質問として残しておきました。これらは、ドメインエキスパートと話すときに質問し、回答を記録していくと良いと思います。今回はドメインエキスパートが不在のため、インターネットで調べた内容などをもとに自分で判断しました。

余談

最近業務でもGitHubのDiscussionを使い始めました。次の点が便利だなと感じています。

  • コメントが主体のため、Wikiと比較してみんなで作り上げる感じがある。そのため、いろいろな人が意見することができ、不確実性が多い内容を文章で共有しやすい。
  • Slackなどのチャットツールと比較して、内容が流れない。
  • Slackなどのチャットツールと比較して急かされない(主観)。そのため、緊急を要していない疑問点なども記録・共有しておける。
tekihei2317tekihei2317

データベースのモデリング

データベースのモデリング(論理設計)をします。ひとまず自分で考えた後、回答例を確認しました。回答例は、渡辺幸三さんのX-TEAを使った図を参照しました。

参考になったのは、同じ日に入荷したある花を1つのロットとして管理しているところです。このロットに対して、入荷・廃棄・出荷などのイベントを紐づけていきます。出荷明細は、ロットと受注明細の交差エンティティとして表現しています。

改良版で、1つ気になるところがありました。ロットに対して、入荷実績ではなく発注が紐づけられていたところです。ここは入荷実績に紐づけた方が自然なのかなと思いました(読み間違っているかもしれない)。

いくつか分からないモデルがありました。

  • 受払予定、受払実績: 在庫の入出庫のことを受払と言います。注文や仕入れから逆算できる気がするので、受払は記録しないことにしました。
  • 支払指示: 要件では考慮しなくてよいとありましたが、仕入先への支払いも考慮しているようでした。
  • 仕入振替、仕入実績: 仕入実績は、発注が完了したことを表していそうです。仕入振替は、画像が見切れていたのもありわかりませんでした。
tekihei2317tekihei2317

バックエンドの実装について

バックエンドの実装方法は今も少し悩んでいるところなのですが、現在は次のように実装しています。

ポイント

  • APIの呼び出しによって実行される一連の処理を、ワークフローと呼んでいる
  • ワークフローの型を定義して、ドキュメントとして読めるようにしている。具体的には、入力・出力・依存の3つの型を定義している。
  • ワークフローで実行されるステップを、入出力の明確な関数として定義している
server/src/context-inventory/dispose-flower.ts
import { adminProcedure, notFoundError } from "../trpc/initialize";
import { prisma } from "../database/prisma";
import { DisposeFlowerInput } from "./inventory-schema";
import { DisposeFlowerWorkflow } from "./inventory-types";
import { TRPCError } from "@trpc/server";

type Workflow = DisposeFlowerWorkflow<DisposeFlowerInput>;
type Deps = Workflow["deps"];

async function disposeFlowerWorkflow(input: Workflow["input"], deps: Workflow["deps"]): Workflow["output"] {
  const validatedInput = await deps.validateDisposeFlowerInput(input);
  const flowerDisposal = await deps.persistFlowerDisposal(validatedInput);
  await deps.updateFlowerInventory(flowerDisposal);

  return flowerDisposal;
}

const validateDisposeFlowerInput: Deps["validateDisposeFlowerInput"] = async (input) => {
  // 在庫IDが正しいこと
  const inventory = await prisma.flowerInventory.findUnique({ where: { id: input.flowerInventoryId } });
  if (inventory === null) throw notFoundError;

  // 在庫数を超えていないこと
  if (input.disposedCount > inventory.currentQuantity) {
    throw new TRPCError({ code: "BAD_REQUEST", message: `破棄数は${inventory.currentQuantity}以下を指定してください` });
  }

  return input;
};

const persistFlowerDisposal: Deps["persistFlowerDisposal"] = async (input) => {
  return prisma.flowerDisposal.create({
    data: {
      flowerInventoryId: input.flowerInventoryId,
      disposedCount: input.disposedCount,
    },
  });
};

const updateFlowerInventory: Deps["updateFlowerInventory"] = async (flowerDisposal) => {
  return prisma.flowerInventory.update({
    where: { id: flowerDisposal.flowerInventoryId },
    data: {
      currentQuantity: { decrement: flowerDisposal.disposedCount },
    },
  });
};

export const disposeFlower = adminProcedure.input(DisposeFlowerInput).mutation(async ({ input }) => {
  return disposeFlowerWorkflow(input, {
    validateDisposeFlowerInput,
    persistFlowerDisposal,
    updateFlowerInventory,
  });
});
server/src/context-inventory/inventory-types.ts
export type FlowerInventory = {
  id: number;
  flowerId: number;
  arrivalDate: Date;
  currentQuantity: number;
};

export type FlowerDisposal = {
  id: number;
  flowerInventoryId: number;
  disposedCount: number;
};

export type DisposeFlowerWorkflow<DisposeFlowerInput> = {
  input: DisposeFlowerInput;
  output: Promise<FlowerDisposal>;
  deps: {
    validateDisposeFlowerInput: (input: DisposeFlowerInput) => Promise<DisposeFlowerInput>;
    persistFlowerDisposal: (input: DisposeFlowerInput) => Promise<FlowerDisposal>;
    updateFlowerInventory: (flowerDisposal: FlowerDisposal) => Promise<FlowerInventory>;
  };
};

悩んでいるところ

  • ワークフローの入力の型を定義するのが面倒なので、ジェネリクスを使ってZodから推論した型を渡すようにしているが、こうするのであればワークフローの入力の型が不要な気がしている
  • ワークフローのステップをどこでどのように定義するのか。tRPCのプロシージャの中で定義する・プロシージャの外で定義する・プロシージャの外でprismaを引数にとる高階関数として定義するの3つの選択肢があり、上記のコードは2番目の方法をとっている
  • 在庫のところをちゃんと実装するにはロックが必要そうだが、使ったことがないので実装をあきらめた
  • テーブルの依存関係がコンテキストの依存関係に反している箇所があり、実装が歪になっている

実装が歪になっている箇所は以下です。業務の時系列を考えると仕入れ登録→在庫更新ですが、順番が逆になっています。仕入れテーブルが在庫テーブルに依存していることが原因です。

このように、テーブルの依存関係がコンテキストの依存関係に反していると、ワークフローの途中で別のコンテキストを呼び出す必要が出てきてしまいます。これは、交差エンティティを用意して依存関係を逆転させると解消できます(後でリファクタリングしたい)。

https://github.com/tekihei2317/frere-memoir/blob/main/server/src/context-purchase/register-arrival-information.ts#L74-L116

tekihei2317tekihei2317

これからすること

実装の方針が固まってきたので、一旦デプロイしようと思います。デプロイ先はRailwayにする予定です。

https://railway.app/

デプロイが終わったら、次は花束の注文ができるようにします。注文の前にユーザー画面と管理画面を分けたいので、認証と認可を実装してから始めます。

tekihei2317tekihei2317

デプロイ

Railwayにデプロイしました。

https://frere-memoir-production.up.railway.app/orders

デプロイ手順は記事にまとめておきました。

https://zenn.dev/gibjapan/articles/881d1e3a1241f1

Herokuと比較していいなと思ったところは、モノレポのデプロイが公式でサポートされているところです。Isolated monorepo用のプロジェクトルートの指定や、Shared monorepo用の起動コマンドの指定ができます。

tekihei2317tekihei2317

認証機能の作成

認証機能を実装しました。ライブラリは、Next.jsのドキュメントで紹介されていたiron-sessionを使いました。iron-sessionは、セッションをクッキーに保存します。

実装はparkgang/trpc-iron-sessionを参考にしました。次のように、tRPCのコンテキストにセッションを渡します。こうすると、プロシージャの中からセッションを変更できます。

https://github.com/tekihei2317/frere-memoir/blob/3ff1fb77cc23b50561cd15d23a280b347226b025/server/src/trpc/initialize.ts#L23-L28

画面ごとの認可は、クライアント側でユーザーを取得して行うことにしました。getServerSidePropsなどを使ってサーバー側でリダイレクトしてもできそうでしたが、慣れている方法で実装しました。

自作のスニペットを使って、次のように実装しています。

画面ごとの認可の実装
// 1. ミドルウェアを定義する
const ensureLoggedIn = createMiddleware(({ ctx, next }) => {
  if (ctx.user.isLoading) return <Loading />;
  if (!ctx.user.value) return <Redirect to="/login" />;

  return next({ user: ctx.user.value });
});

export const AuthMiddleware = MiddlewareComponent.use(ensureLoggedIn);

// 2. ページコンポーネントにミドルウェアを設定する
export default function Flowers() {
  // 省略
}
Flowers.Middleware = AdminMiddleware;

// 3. _app.tsxでミドルウェアを呼び出す
function App<OutputContext>({
  Component,
  pageProps,
}: AppProps & { Component: { Middleware?: MiddlewareComponent<Context, OutputContext> } }) {
  const AvoidSsr = dynamic(() => import("../components/AvoidSsr").then((module) => module.AvoidSsr), { ssr: false });
  const { Middleware = PublicMiddleware } = Component;

  return (
    <Middleware ctx={useMiddlewareContext()}>{(ctx) => <Component ctx={ctx} {...pageProps} />}</Middleware>
  );
}

参考:

https://zenn.dev/gibjapan/articles/ae6c6af9d1b8d0

このスクラップは2023/04/23にクローズされました