コドモン Product Team Blog

株式会社コドモンの開発チームで運営しているブログです。エンジニアやPdMメンバーが、プロダクトや技術やチームについて発信します!

「抽象に依存する」クラス設計の具体例 - 生成AIを組み込んだアプリケーションを題材に

はじめに

こんにちは、プロダクト開発部の中田です。

最近は、生成AIを組み込んだアプリケーションの開発をしています。 アプリケーションの中から、OpenAI等の生成AIのAPIを呼び出し、生成した結果をユーザーに提供するものです。昨今、多くのサービスで取り入れられ始めているパターンですね。

そのため、生成AIのAPI呼び出しまわりのモデルやクラスの設計をしていたのですが、改めて振り返ると、 よく言われる「抽象に依存せよ」や「変わりにくいもの/安定したものに依存せよ」といった設計原則を意識して実装に取り組んだ具体例として、 ちょうどよい題材だと感じました。 そこで、今回の記事として紹介させていただくことにしました。

なお、生成AIを組み込んだアプリケーションを題材として取り上げてはいますが、特に生成AIを使ううえでの工夫や注意点等について詳細を論じているものではない点はご了承ください。

DIP(依存関係逆転の原則)

ソフトウェアの設計原則として有名な"SOLID原則"の一つ(SOLIDの"D")として、依存関係逆転の原則(Dependency Inversion Principle、以下DIP)がありますね。

保守性の高い柔軟なシステムを実現するために、なるべく「(具象ではなく)抽象に依存せよ」という考え方です。

「抽象」であるインターフェイスや抽象クラスと、「具象」である具象クラスとを分離し、変わりやすく不安定な「具象」ではなく、変わりにくく安定した「抽象」に依存することにより、 変わりやすい部分が変えやすくなり、保守性の高いシステムを実現できる、というわけです。

(この説明自体が、割と抽象的で、自分でも何を言ってるのかわからなくなりそうです。。。)

DIPがよく話に出てくるケースとしては、DDDのパターンにおいてリポジトリのインターフェースと実装を分離する理由として、DIPが語られることが多い気がします。 その場合は、レイヤーという少し広めの視点での依存の方向の話になりますが、1つ1つのクラス間の依存関係についても同様の考え方が適用できると思います(もともとSOLID原則はクラス設計レベルの話の認識です)。

生成AIを組み込んだアプリケーションにおける抽象と具象の例

生成AIを組み込んだアプリケーションを開発するということは、仕様として、少なくとも以下のような点を決めるということでした。

生成AIを​組み込んだ​アプリケーションに​おける​抽象と​具象の​例

これらは、上から順に見ていくと、方針から手段へと、徐々に抽象的な内容から具体的な内容になっていきます。

また、下から順に見ていくと、具体的な手段ほど、変更が発生する可能性と頻度が高いことが、想像できるかと思います。

アプリケーションを開発するうえでも、これらの抽象と具象、変わりにくい部分と変わりやすい部分を、適切に設計に反映することが重要になってきます。

抽象と具象を反映したクラス設計の例

では、これらをクラス設計に落とし込んだ例をご紹介します。

概要としては、以下の図のような構成としました。なお、サンプルコードはKotlinで記述したものとなっております。 (ちょっと細かいので、拡大して見ていただければと思います。)

抽象と​具象を​反映した​クラス設計の​例

先ほど整理した抽象と具象に対応するように、interfaceやclassを分けています。 そして、抽象的な方針ほどinterfaceとなっており、具体的な手段ほど具象classとなっていること、 また、依存の方向(is aの線の向き)が上向き(具象→抽象の向き)になっていることが、わかるかと思います。

次に、各クラスのコード例を示しながら、ポイントを説明していきます。

なお、本記事では、「クラス設計」のようにカタカナで「クラス」と記述した場合は、Kotlinのコードにおけるinterface, abstract class, class, objectなどを総称したものの意味で記述しています。

機能: Generator

まずは「子どもに​関する​情報を​入力と​して​文章を​生成する​」の部分からです。

「子どもに​関する​情報を」の部分は、アプリケーションが前提とするコンテキストなので、ここでは割愛します。 となると、残るは「​〜を入力と​して​文章を​生成する​」の部分だけです。

何かを入力とし、何かを生成して出力する、それだけです。なのでコードもこれだけです。

interface Generator<I, O> {
    suspend fun generate(input: I): O
}

「何か」の部分はユースケースごとに異なるので、ジェネリクスで型パラメータI(Input)とO(Output)を定義しています。

入出力: ImageSummaryGenerator

次に「子どもの写真から写真の説明文を生成する」の部分です。

前述のGeneratorを一段具体化し、入力が写真の画像バイナリデータであること、出力が文字列であること、を定義しています。

interface ImageSummaryGenerator : Generator<ImageSummaryGenerator.Input, ImageSummaryGenerator.Output> {

    data class Input(
        val imageData: ByteArray
    )

    interface Output {
        val summaryText: String
    }
}

ユースケースのクラスからは、以下のように利用される想定です。

@Component
class ImageSummaryUseCase(
    private val imageSummaryGenerator: ImageSummaryGenerator,
    private val imageSummaryStore: ImageSummaryStore,
    // ...その他の依存
) {
    suspend fun execute(input: Input) {
        // ...前処理

        val output = imageSummaryGenerator.generate(
            ImageSummaryGenerator.Input(
                imageData = preprocessedImage,
            )
        )

        val summary = ImageSummary(
            imageId = input.imageId,
            summaryText = output.summaryText,
            createdAt = OffsetDateTime.now(),
        )

        imageSummaryStore.save(summary)
    }
}

実現方針: GeneratorByAI

そして、「生成AIを利用して文章を生成する」の部分です。今回の設計の肝になる部分です。

入力データを含めたプロンプトを動的に生成し、そのプロンプトを生成AIに渡して出力を得る、という一連の流れを定義しています。

interface GeneratorByAI<I, O> : Generator<I, O> {
    val promptBuilderFactory: PromptBuilderFactory<I, O>
    val genAIClient: GenAIClient

    override suspend fun generate(input: I): O {
        val promptBuilder = promptBuilderFactory.create()
        val prompt = promptBuilder.build(input)
        val outputJson = genAIClient.generate(prompt)
        val output = promptBuilder.parseOutput(outputJson)
        return output
    }
}

なお、細かい点の補足ですが、生成AIの出力データの構造は、各プロンプト(の中で定義した出力スキーマ)の定義次第で変わるため、 いったん任意の構造のJSON文字列として受け取ったうえで、当該プロンプトの出力スキーマ定義に基づいてパースする、という流れにしています。

GeneratorByAIが利用する部品も、以下のようなinterfaceとして抽象化しています。

interface PromptBuilderFactory<I, O> {
    fun create(): PromptBuilder<I, O>
}

interface PromptBuilder<I, O> {
    fun build(input: I): Prompt
    fun parseOutput(outputJson: String): O
}

interface Prompt {
    val promptId: PromptId
}

@JvmInline
value class PromptId(val value: String)

interface GenAIClient {
    suspend fun generate(prompt: Prompt): String
}

実現手段: OpenAIPromptBuilder

次に、「生成AIとしてOpenAIのAPIを使用する」の部分です。

前述のGeneratorByAIまわりのinterfaceを継承しつつ、OpenAIのAPI仕様に固有の概念を、型として定義しています。

interface OpenAIPromptBuilder<I, O>: PromptBuilder<I, O> {
    override fun build(input: I): OpenAIPrompt
}

interface OpenAIPrompt : Prompt {
    override val promptId: PromptId
    val model: OpenAIModel
    val type: OpenAIInputType
    val input: OpenAIInput
    val outputFormat: OpenAIOutputFormat
}

data class OpenAIInput(
    val contents: List<OpenAIInputContent>,
)

data class OpenAIInputContent(
    val role: OpenAIInputRole,
    val type: OpenAIInputType,
    val content: String,
)

enum class OpenAIInputRole(val value: String) {
    SYSTEM("system"),
    USER("user"),
    ASSISTANT("assistant"),
}

enum class OpenAIInputType {
    TEXT,
    IMAGE,
}

data class OpenAIOutputFormat(
    val name: String,
    val schema: String,
)

実装の詳細: ImageSummaryPromptBuilderV1

最後に、「GPT-5-miniを使用し〇〇というプロンプトで生成する」の部分です。

ここではじめて、具象classを実装します。 前述のinterfaceたちに対し、具体的な情報を埋め込んでいきます。

まず、ImageSummaryGeneratorinterfaceを、生成AIを利用する即ちGeneratorByAIinterfaceを継承して、実装することを示す具象classを作成します。

@Component
class ImageSummaryGeneratorByAI(
    override val promptBuilderFactory: ImageSummaryPromptBuilderFactory,
    override val genAIClient: GenAIClient,
) : ImageSummaryGenerator, GeneratorByAI<ImageSummaryGenerator.Input, ImageSummaryGenerator.Output>

次に、PromptBuilderFactoryinterfaceの実装として、後述するImageSummaryPromptBuilderV1を返す具象classを作成します。 このclassは、上記のImageSummaryGeneratorByAIにコンストラクタでDI(Dependency Injection)しているものです。

@Component
class ImageSummaryPromptBuilderFactory :
    PromptBuilderFactory<ImageSummaryGenerator.Input, ImageSummaryGenerator.Output> {
    override fun create(): PromptBuilder<ImageSummaryGenerator.Input, ImageSummaryGenerator.Output> {
        return ImageSummaryPromptBuilderV1
    }
}

最後に、OpenAIPromptBuilderinterface(OpenAIのAPIを使用するPromptBuilderinterface)の具象classとしてImageSummaryPromptBuilderV1を作成し、具体的に使用するモデル、プロンプト文、出力スキーマなどを定義していきます。

object ImageSummaryPromptBuilderV1 : OpenAIPromptBuilder<ImageSummaryGenerator.Input, ImageSummaryGenerator.Output> {
    override fun build(input: ImageSummaryGenerator.Input): ImageSummaryPromptV1 =
        ImageSummaryPromptV1.from(input)

    override fun parseOutput(outputJson: String): ImageSummaryPromptV1.Output =
        jacksonObjectMapper().readValue(outputJson, ImageSummaryPromptV1.Output::class.java)
}

class ImageSummaryPromptV1 private constructor(
    override val promptId: PromptId,
    override val model: OpenAIModel,
    override val type: OpenAIInputType = OpenAIInputType.IMAGE,
    override val input: OpenAIInput,
    override val outputFormat: OpenAIOutputFormat,
) : OpenAIPrompt {

    companion object {
        val promptId = PromptId("sample-image-summary-v1")

        fun from(input: ImageSummaryGenerator.Input): ImageSummaryPromptV1 {
            return ImageSummaryPromptV1(
                promptId = promptId,
                model = OpenAIModel.GPT_5_MINI,
                input = input.toOpenAIInput(),
                outputFormat = outputFormat,
            )
        }

        private fun ImageSummaryGenerator.Input.toOpenAIInput(): OpenAIInput {
            return OpenAIInput(
                contents = listOf(
                    OpenAIInputContent(
                        role = OpenAIInputRole.USER,
                        type = OpenAIInputType.TEXT,
                        content = """
                            あなたは、画像を見て、その内容を簡潔に表現する役割を担っています。
                            以下の制約条件と入力をもとに、画像の内容を簡潔に表現してください。

                            制約条件:
                            ・出力は日本語で行うこと
                            ・出力は20文字以内で行うこと
                            ・肯定的な表現を用いること
                        """.trimIndent()
                    ),
                    OpenAIInputContent(
                        role = OpenAIInputRole.USER,
                        type = OpenAIInputType.IMAGE,
                        content = "data:image/jpeg;base64,${Base64.getEncoder().encodeToString(imageData)}"
                    ),
                ),
            )
        }

        private val outputFormat = OpenAIOutputFormat(
            name = "ImageSummaryOutput",
            schema = """
                {
                    "type": "object",
                    "properties": {
                        "summaryText": {
                            "type": "string",
                            "description": "画像の内容を簡潔に表現したテキスト"
                        }
                    },
                    "required": ["summaryText"],
                    "additionalProperties": false
                }
            """.trimIndent(),
        )
    }

    data class Output(
        override val summaryText: String,
    ) : ImageSummaryGenerator.Output
}

他にも、図中のOpenAIClientの実装なども必要になりますが、本記事の主旨にはあまり関係ないので、割愛します。

コード例の説明は以上となります。

抽象と具象を反映した設計の効果

では、この設計だと、想定される変更に対して、どのように対応できるかを見ていきましょう。

抽象と​具象を​反映した​設計の​効果

プロンプトの改善

プロンプトを変更したり、使用するモデルを変更したい場合は、ImageSummaryPromptBuilderV2を新規作成し、 ImageSummaryPromptBuilderFactoryがV1の代わりにV2のインスタンスを返すようにすればOKです。

@Component
class ImageSummaryPromptBuilderFactory :
    PromptBuilderFactory<ImageSummaryGenerator.Input, ImageSummaryGenerator.Output> {
    override fun create(): PromptBuilder<ImageSummaryGenerator.Input, ImageSummaryGenerator.Output> {
        // V1をV2に差し替える
        // V1とV2を条件によって使い分けるA/Bテストのようなことがしたい場合は、ここに分岐ロジックを記述すればよい
        return ImageSummaryPromptBuilderV2
    }
}

既存クラスの変更でなく、新規クラスの追加で対応できるため、既存コードへの影響を最小化できます。 また、V1も残しておけば、ユーザーによって使用するプロンプトを切り替えるA/Bテストのような運用も可能です。

生成AIプロバイダーの変更

例えば、GPTからClaudeに変更したい場合は、OpenAIPromptBuilderの代わりに、ClaudePromptBuilderを作成して使用すればOKです。 その場合、もちろんClaudeのAPI仕様に合わせてプロンプトを含む各具象classを別途作成する必要はありますが、より上位の方針に関するクラスは変更する必要がありません。

interface ClaudePromptBuilder<I, O>: PromptBuilder<I, O> {
    override fun build(input: I): ClaudePrompt
}
// 詳細は省略

生成AIを使わないようにする

もし生成AIで十分な品質の出力が得られず、ルールベース等の他の方法で出力を生成するようにしたい場合、 GeneratorByAIの代わりに、GeneratorByRuleのようなinterfaceを別途作成して切り替えれば、Generatorを利用する側であるユースケース等のクラスはほとんど変更する必要がありません。

interface GeneratorByRule<I, O> : Generator<I, O> {
    // 依存するinterfaceを定義

    override suspend fun generate(input: I): O {
        // ルールベースで生成する場合の処理の流れ
    }
}

生成の入出力の変更

例えば、写真の説明文を生成するために子どもの年齢や性別の情報を入力として追加して精度を上げたいとか、写真のタイトルも合わせて出力したい、という場合、ImageSummaryGeneratorInputOutputに新しいフィールドを追加することで対応できます。

interface ImageSummaryGenerator : Generator<ImageSummaryGenerator.Input, ImageSummaryGenerator.Output> {

    data class Input(
        val imageData: ByteArray,
        val child: Child // 子供の情報を追加
    )

    interface Output {
        val title: String // タイトルを追加
        val summaryText: String
    }
}

もちろんプロンプト内容や出力スキーマの変更も合わせて必要にはなりますが、 期待する入出力を明示したうえで、それを実現するプロンプト等を実装する、という関係が明確になります。

他の生成タスクの作成

上記の例は、写真の説明文を生成するようなケースでしたが、例えば他に、長い文章を要約するようなユースケースを追加したい場合、 TextSummaryGeneratorのようなinterfaceを作成して入出力を定義し、各具象classを新規作成すればOKです。

interface TextSummaryGenerator : Generator<TextSummaryGenerator.Input, TextSummaryGenerator.Output> {

    data class Input(
        val originalText: String
    )

    interface Output {
        val summaryText: String
    }
}

その際、「OpenAIのAPIを使用して生成する」という処理の流れ自体は共通なので、既存のGeneratorByAI等のinterfaceを実装したTextSummaryGeneratorByAIを用意すれば、改めて同じようなコードを書く必要はありません。

@Component
class TextSummaryGeneratorByAI(
    override val promptBuilderFactory: TextSummaryPromptBuilderFactory,
    override val genAIClient: GenAIClient,
) : TextSummaryGenerator, GeneratorByAI<TextSummaryGenerator.Input, TextSummaryGenerator.Output>

まとめ

このように、抽象と具象および変わりやすさの順位に合わせて、依存の方向性を整理してクラス設計することで、 変わりやすい部分ほど、影響範囲が小さく、容易に変更できるようになります。

今回の例では、生成AIを使用しプロンプトをこまめに改善しながら出力の品質を上げていく、というユースケースの特性に合った設計を目指した結果、このようなクラス構成となりました。

どこまで抽象化すべきか?

上記の設計例は、なるべく「抽象に依存せよ」という原則を遵守した教科書的な例として紹介しましたが、 実際のプロダクトコードでは、ここまで丁寧に抽象化はしておらず、以下の図のような簡易版となっていたりします。

実際の​プロダクトコードの​設計

ここでは、生成AIを利用する処理の流れこそ抽象化しているものの、以下の点は簡略化しています。

  • 機能要件的に生成AIを利用しない選択肢はほぼなかったりするので、GeneratorGeneratorByAIは分離していません。

  • ImageSummaryGeneratorもinterfaceとclassには分離していません。生成AIを利用する前提なので、はじめから以下のようなクラスとして実装しています。

@Component
class ImageSummaryGenerator(
    override val promptBuilderFactory: ImageSummaryPromptBuilderFactory,
    override val genAIClient: GenAIClient,
) : Generator<ImageSummaryGenerator.Input, ImageSummaryGenerator.Output> {
    data class Input(
        val imageData: ByteArray,
    )

    interface Output {
        val summaryText: String
    }
}

抽象化もやりすぎてしまうと、開発コストが余計にかかったり、むしろ可読性を落とすことにもなりえます。 そのため、ユースケースの特性に応じて、変わりやすい部分を見極め、どこまで抽象化すべきかを判断することが大事だと思います。

とはいえ、変わりそうな部分を推測しだすとキリがなくなり、余計な抽象化をしてしまう可能性もあります。 そこで、例えば今回のように「ここは変えない!」「ここが変わるなら作り直し!」のような前提を関係者と合意したうえで、抽象度を上げすぎないというアプローチが現実的かもしれません。 そのような前提に関する議論の中で、変わりそうな部分が見えてくることも多いと思います。

おわりに

「抽象に依存せよ」とは言われても、「抽象って何よ?」となって、どうすればよいかわからないことも多いかと思います。

開発対象のアプリケーションについて、目的や方針と手段、変更する可能性や頻度の高さを意識しながら、より抽象的な部分は何か、具体的な部分が変わったとしても変わらない部分は何か、を考えながらinterfaceを抽出してみると、クラス設計の一歩目が見えてくるかもしれません。

今回の具体例が、その参考となれば幸いです。

おまけ

気になった方もいるかもしれませんが、実は、上記の「OpenAIのAPIを使用して生成する」という部分は、OpenAIの公式SDKを使えば、自分でクラスを定義しなくても、すでに抽象化された形で容易に利用できたりします。

上記の例を設計した当時は、OpenAIのドキュメントに記載されていた公式のSDKが、TypeScript版とPython版だけだった気がするのですが、 実はJava版もあったようですし、いま改めてドキュメントを見たらJava版も載ってますね。。。 Kotlin版もCommunity Librariesでは紹介されています。

また、プロンプトの管理についても、上記の例ではコード内に直接記述していますが、Langfuseのようなツールを使用すれば、プロンプトを外部サーバに切り出して、アプリケーションとは独立してデプロイやモニタリングができたりします。

詳細説明は割愛しますが、参考までに、OpenAIとLangfuseのSDKを使用し、TypeScriptで記述した、ちょっとしたサンプルコードを載せておきます。

import type { Handler, Context } from "aws-lambda"
import OpenAI from "openai"
import { z } from "zod"
import { Langfuse, observeOpenAI } from "langfuse"

export interface TextSummaryMessage {
  originalText: string
}

// 出力スキーマをZodで定義
const TextSummarySchema = z.object({
  summaryText: z.string().describe("要約されたテキスト。20文字以内で簡潔な表現を用いること。"),
})
type TextSummary = z.infer<typeof TextSummarySchema>

// Langfuseの接続情報
const langfuse = new Langfuse({
  publicKey: process.env.LANGFUSE_PUBLIC_KEY,
  secretKey: process.env.LANGFUSE_SECRET_KEY,
  baseUrl: process.env.LANGFUSE_HOST,
})


// SQSイベントトリガーのLambdaで実行するサンプル
export const handler: Handler<TextSummaryMessage, TextSummary> = async (event: TextSummaryMessage, context: Context): Promise<TextSummary> => {
  // LangFuseを使用して生成AIのAPI呼び出しをトレース
  const trace = langfuse.trace({
    name: "text-summary",
  })

  // プロンプトをLangfuseのサーバーから取得
  const promptObject = await langfuse.getPrompt("text-summary-prompt")

  const openai = observeOpenAI(new OpenAI({
    apiKey: process.env.OPENAI_API_KEY,
  }), {
    parent: trace,
    langfusePrompt: promptObject,
  })

  const inputMessages = [
    {
      role: "user",
      content: promptObject.compile({ originalText: event.originalText }),
    },
  ]
  const outputSchema = z.toJSONSchema(TextSummarySchema)

  trace.update({
    input: inputMessages,
    metadata: {
      structuredOutputSchema: {
        name: "text_summary",
        schema: outputSchema,
      },
    },
  })

  // 生成AIのAPI呼び出し
  const response = await openai.responses.create({
    model: "gpt-5-mini",
    input: inputMessages,
    text: {
      format: {
        type: "json_schema",
        schema: outputSchema,
        name: "text_summary"
      }
    }
  })

  trace.update({
    output: response.output_text,
  })

  try {
    // レスポンスのJSONをパース
    return TextSummarySchema.parse(JSON.parse(response.output_text))
  } finally {
    await langfuse.flushAsync()
  }
}

今回は既存アプリケーションとの統合しやすさを優先して、いったんKotlinで実装していましたが、これらのライブラリやツールの充実度・成熟度をふまえて技術選定を行うことも大事ですね。 特に最近では、AIコーディングエージェント等を使う場合に、世に出ている情報量がAIのアウトプット品質に直結したりするので、その重要度が上がっていると感じます。