ドメイン設計だけじゃない!型を駆使してAI駆動開発の精度を上げよう
こんにちは、株式会社Digeonでエンジニアをしている東(@teast330985)といいます。
その他私の自己紹介はこちらの記事をご覧ください。
今はバックエンドもフロントエンドも全てTypeScriptで書いているのですが、最近プロダクト開発を進める中で「型っていいな」と思うことが強まったのと、技術的な発信もしていきたいなあ記事でも書こうかと思ったタイミングが一致したので書いてみます。
「ENSOUチャットボット」というアプリケーションを作っています。
この記事で伝えたいこと(ざっくり)
- AIコーディングや新規メンバーの参画など、不揃いなコードを出力される事象には、静的解析を駆使しよう。
- ドメイン駆動設計で型を使えるなら、アーキテクチャも型で縛れる。
- 実装は疎結合、アーキテクチャの型は密結合。
型で縛ることのメリット
型はとても便利です。色々なメリットがありますがやはり「しょうもないテストケースの数が減らせる」というのが私にとって一番のメリットです。テストももちろん大事ですが、なんでもかんでもテストで品質を担保するのは大変です。
型がなければ、私達は非常に細かいロジックで関数を書き、能動的にテストをひたすら書いていくコストのかかる方法で品質を保証することが中心になります。つらいです。
一方で、関数をロジックだけでなく型を使って縛ることで、テストを書く書かない以前に静的解析で怒られるようになります。静的解析のエラーを直さないとビルドやデプロイといった次のステップへは進めないので、型で縛った部分の実装ミスが放置されることはありません。
もちろんテストを書かないと保証できないような重要なロジックがあったりするのでテストはとても重要です。大事なのは「しょうもないテスト」を減らせるというところです。
他にも直和型などをうまく使えばアプリケーション内のオブジェクトが取りうる様々な状態を内部のプロパティの値ではなく型で表現することができます。
// ドメイン設計として雑ですが、
// インスト曲
type InstrumentalTrack = {
kind: "Instrumental";
title: string;
composer: string;
featuredInstruments: Instrument[] // InstrumentalTrackにしかないプロパティ
};
// 歌ありの曲
type VocalTrack = {
kind: "Vocal";
title: string;
composer: string;
lyrics: string; // VocalTrackにしかないプロパティ
singer: string; // VocalTrackにしかないプロパティ
};
// 直和型でより広義な`曲`を示す
type Track = InstrumentalTrack | VocalTrack
このように分けることで同じ曲でも、解釈を広くとったり狭く取ったりすることができます。
/**
* 歌付き曲に対して作詞者について説明する関数
* InstrumentalTrackを引数にするとコンパイルエラー
*/
const describeLyrics = (track: VocalTrack) => `${track.lyrics}は偉大です!`
/**
* インスト曲に対してメイン楽器について説明する関数
* VocalTrackを引数にするとコンパイルエラー
*/
const describeInstruments = (track: InstrumentalTrack) => `${track.featuredInstruments.join(", ")}が活躍します。`
/**
* 作曲者について説明する関数
* 両方使える
*/
const describeComposer = (track: Track) => `${track.composer}はいい曲を作ります!`
関数の引数を、その関数が持つ意味に応じて型を狭義に利用したり、広く使ったりすることで無駄なテストを防げます。もし、これがTrack
しか表現方法が無い場合、「インスト曲の作詞者を取得する」というような状態が発生しないことをテストする必要があります。
type Track = {
kind: "Instrumental" | "Vocal";
title: string;
composer: string;
featuredInstruments: Instrument[] // Vocalの場合undefined
lyrics?: string; // Instrumentalの場合undefined
singer?: string; // Instrumentalの場合undefined
};
const describeLyrics = (track: Track) => {
// このケースのテストが必要
if (track.lyrics != null) {
throw new Error("この曲はインスト曲です")
}
return `${track.lyrics}は偉大です!`
}
参考: 関数型ドメインモデリング
https://asciidwango.jp/post/754242099814268928/関数型ドメインモデリング
ドメイン設計は型で縛れる、しかし実装全体の書きぶりはどうしても曖昧になりがち
私の開発しているアプリではクリーンアーキテクチャを意識して設計をしています。
DDDをやる上でコアになるdomain層、アプリケーションの振る舞いを行うusecase層、永続化を操作するrepository層、その他諸々を責務と依存性を意識して切り分けています。
実際にアプリケーションのアーキテクチャは、その構成または類似する構成での開発経験が多い人ほど、過去の成功談・失敗談から生ずる勘のようなものが働いてしまいます。
そのために人は、独自のアーキテクチャの構成図やコーディングルールを書くわけですが、他の開発者やAIがそれを絶対に読んで絶対に守ってくれるとは限りません。人もAIも漏れがあります。コードレビューも間違うことがあります。AGENTS.mdのようなAI用の指示書を用意してもそれが絶対に採用されるという保証はありません。
そうしてソースコードには「バグにはならないけど、書きぶりが若干違うコード」が量産されます。
AIがコードを書いてくれる時代でも、方針が意図なくバラバラなコードで動いていることは保守性も悪いですし、それらAI開発者を統括するエンジニアリーダーとしては見過ごせません。
この曖昧さを解消するためにできることとしては、アーキテクトによる啓蒙が必要になりますがそれは大変です。
各層を型で表現すればルールや啓蒙はぐっと簡単になる。静的解析が代わりに怒ってくれる
一方で、コードの良し悪しを型解析に判断させることでその曖昧さは劇的に減少します。
それなら各層の定義も型でまとめてしまおうというのが今回の本題です。
ドメイン設計を型を使って曖昧さをなくせたのと同様に、アーキテクチャも型で示すことができるはずです。
例えば、UseCase層はどのように表現できるでしょうか。APIハンドラーが呼び出すUseCaseのインタラクターは「依存性注入されたadapter層のinterfaceを抽象的に利用して、受け取った入力を処理し、出力を返す関数」という役割で示せます。
型で縛るとこんな感じでしょうか。
import { Result } from "neverthrow"
import { z } from "zod";
export const UseCaseInput = z.object({
userID: ULID,
});
export type UseCaseInput = z.infer<typeof UseCaseInput>;
/** 認証済みのユーザが利用する前提のユースケースなので引数にuserIDを必須とする */
export type UseCaseInteractor<Input, Output> = (
// UseCaseDepsはrepositoryなどの外側の実装を抽象化したinterfaceをなどを束ねたもの
deps: UseCaseDeps,
) => (input: Input & {userID: string}) => Promise<Result<Output, AppError>>;
/** ログイン機能のような認証前のユーザが利用するユースケース */
export type UseCaseInteractorWithoutAuth<Input, Output> = (
deps: UseCaseDeps,
) => (input: Input & UseCaseInput) => Promise<Result<Output, AppError>>;
実際にユースケースを実装するとするとこのようにかけると思います
export const RegisterHogeInput = z.object({
name: HogeName,
})
export const RegisterHogeOutput = z.object({
hogeID: ULID,
name: HogeName,
hogeType: HogeType
})
type RegisterHogeInput = z.infer<typeof RegisterHogeInput>
type RegisterHogeOutput = z.infer<typeof RegisterHogeOutput>
export const useRegisterUserInteractor: UseCaseInteractor<
RegisterHogeInput,
RegisterHogeOutput
> = (deps) => async (input) => {
const foo = await deps.fooRepo.execFoo({input.userID})
if (foo.isErr()) {
logger.error({error:foo.error}, "失敗!")
// foo.errorはAppError
return err(foo.error)
}
// こんな感じで色々あって、、
const hoge: RegisterHogeOutput = {
// 色々データが入って
}
return ok(hoge)
}
ユースケースを実装する以上はUseCaseを使うことで
- neverthrowのResult型を使う(ResultAsyncではなく)
- エラーはAppError型で返す
みたいなルール含めて静的解析でエディタに怒られないように実装するようになりました。
余談:ZodやHonoとの組み合わせ
型は基本的にZodで定義するようにしています。
例えば上の
export const RegisterHogeInput = z.object({
name: HogeName,
})
export const RegisterHogeOutput = z.object({
hogeID: ULID,
name: HogeName,
hogeType: HogeType
})
をそのままHonoのスキーマに登録しておくことで、APIのエンドポイントとインタラクターが一対一であることを明確にできます。
export const registerHogeRoute = createRoute({
method: "post",
path: "/hoge",
request: {
body: {
content: {
"application/json": {
schema: RegisterHogeInput.openapi("RegisterHogeInput"),
},
},
},
},
responses: {
200: {
content: {
"application/json": {
schema: RegisterHogeOutput.openapi(
"RegisterHogeOutput",
),
},
},
description: "Hogeが成功",
},
400: badRequestResponse,
401: unauthorizedErrorResponse,
403: forbiddenErrorResponse,
500: internalServerErrorResponse,
},
tags: ["hoge"],
});
あとは、これをOrvalと組み合わせることで、API側で定義した直和型などを駆使したドメイン知識がzodのバリデーション機能つきでそのままフロントエンドでも使えるようになります。アプリ全体が型で守られるのが実装していてとても気持ちいいです。
最後に
ここで示した例は、劇的な実装改善というにしては僅かな取り組みですが、この僅かな取り組みが今後システムの規模や開発体制がスケールすればするほど複利として効いてくるなと感じています。
その上でこの「アーキテクチャの型実装」方針の真価を発揮するのは、関連する他の層も同様に型で縛っておくことが重要だと思います。例えば、UseCaseが呼び出すRepository層や、UseCaseを呼び出すAPIハンドラー層の実装などを、アーキテクチャ的には疎結合でも型としてはめちゃくちゃ密結合にしておくことで、一気通貫した実装が可能になるだけでなくそれぞれの層の役割が明確になります。
まだ試せていないですが、Branded Typesとかも利用すれば、しばしばAIエージェントがソースコードをしれっと魔改造して実装するようなことも防げるかもしれません。
人にもAIにも優しいコーディングは型解析という共通のものさしで測れる状態にしておくのが大事だと考えています。
TypeScriptや型についてまだまだ知らないことも多いですが、これからも愛でていきます。
Discussion