ゴアへの旅行を計画したいと考えているとしましょう。5日間、予算は25,000ルピー、好みはビーチとシーフード。通常、これは10個のブラウザタブを開き、古いフォーラムの投稿を読み、手動で旅程を組み立てることを意味します。その代わりに、たった一度のPOSTリクエストを送信するだけで、食事の提案、アクティビティのリスト、正確な予算配分が含まれた構造化された日ごとのプランが返ってくる様子を想像してみてください。それこそが、このプロジェクトが実現することです。

Spring BootとAzure OpenAIを使用してREST APIを構築します。このAPIは、目的地、予算、期間、興味を受け取ります。そして、フロントエンドやモバイルアプリがすぐにレンダリングできるクリーンなJSONを返します。スクレイピングも、ハードコードされた旅程もありません。ただ、旅行プランナーとして振る舞うようプロンプトを与えられたAIモデルがあるだけです。

APIが返すもの

レスポンスは、正規表現で分解しなければならないMarkdownテキストの塊ではありません。それは、日ごとのアクティビティ、食事の推奨事項、予算の内訳を含む構造化されたJSONオブジェクトです。例えばゴア旅行の場合、ビーチシャックでの朝食に500ルピーを割り当て、午前中はパロレムで過ごし、夜は特定のエリアでシーフードディナーを楽しむといった、1日目のセグメントを受け取ることになります。各日にはタイムスロット、見積もりコスト、「ビーチ」や「フード」といったタグが含まれます。この構造が重要なのは、現代の旅行アプリは段落を解析したいのではなく、RecyclerViewやReactコンポーネントにマッピングできるオブジェクトを求めているからです。

スタックとその選定理由

このプロジェクトでは、Spring AIを使用したSpring Boot 3.5を使用します。Spring AIが極めて重要な要素です。Spring AIは統一されたChatModel抽象化を提供するため、Azure OpenAIに対して生のHTTPクライアントを記述する必要がありません。サービスコードではなく、依存関係とプロパティを入れ替えるだけで済みます。

ビルドファイルには、以下の4つの依存関係が必要です。

  • spring-boot-starter-web(REST層用)
  • spring-ai-starter-model-azure-openai(Spring AIのインターフェースを通じてLLMに接続するため)
  • springdoc-openapi(自動Swaggerドキュメント用)
  • Lombok(リクエストおよびレスポンスのPOJOにおけるボイラープレートを削減するため)

Spring AIは、ビジネスロジックとLLMプロバイダーの間に位置します。この配置は意図的なものです。これにより、@Serviceクラスをクリーンかつプロバイダーに依存しない状態に保つことができます。

PromptTemplatesによるプロンプトエンジニアリング

Javaの文字列内にプロンプトをハードコードすることは、メンテナンス性の低いソフトウェアを作成する近道です。もしプロダクトチームが「AIの口調をもっとカジュアルにすべきだ」とか「一定のしきい値を超える予算見積もりは拒否すべきだ」と判断した場合でも、サービスを再コンパイルする必要はありません。

Spring AIはPromptTemplateを提供しています。プロンプトのスケルトンをリソースファイルや専用のテンプレート文字列に保存し、{destination}{budget}{days}{interests}といった変数のためのプレースホルダーを残しておきます。実行時に、サービスはPromptオブジェクトを作成し、ユーザーの値を注入します。

システムメッセージとユーザーメッセージを分離してください。システムメッセージを使用してペルソナを定義します。例えば、モデルに対して「あなたはインドの目的地に特化した、予算に敏感で、MarkdownのフェンスなしでJSONのみを返すことに厳格な旅行プランナーである」と伝えます。ユーザーメッセージを使用して、具体的な旅行の詳細を渡します。この分離により、APIコントラクトを変更することなく、後でペルソナのA/Bテストを行いたい場合に役立ちます。

サービス層:Azure OpenAIとの対話

@Serviceクラスの仕事は一つです。プロンプトを構築し、モデルを呼び出し、レスポンスをクリーンアップして、結果を解析することです。

Spring AIのChatClientまたはChatModelをインジェクションします。入力されたリクエスト値でPromptTemplateをレンダリングし、チャットメソッドを呼び出します。レスポンスはStringとして届きます。ここが、多くのチュートリアルが終了し、実際のプロダクションコードが始まる場所です。

LLMは時として、丁寧な前置きを追加することがあります。「こちらがあなたの旅程です」で始まり、その後にバッククォート3つで囲まれたJSONが続くようなレスポンスが返ってくるかもしれません。これをJacksonで直接デシリアライズしようとすると、アプリがクラッシュします。生の文字列をスキャンして、最初の開始中括弧と最後の終了中括弧を見つけ、JSONペイロードのみを抽出する小さなヘルパーメソッドを追加してください。その後、抽出したブロックを検証します。オブジェクトをコントローラーに返す前に、必須フィールドが存在すること、および数値が妥当であることを確認してください。

この防御的なパースは、オプション(任意)ではありません。それは、デモと信頼できるAPIの境界線なのです。

成熟したシステムとしてのエラーハンドリング

外部APIは失敗するものです。Azure OpenAIは、レート制限エラー、認証失敗、または一時的な500エラーを返します。これらをスタックトレースとしてユーザーにそのまま伝えてしまうと、信頼を失うことになります。

Use @RestControllerAdvice to intercept exceptions globally. Map Spring AI exceptions, HttpClientErrorException, and generic RuntimeExceptions to consistent error responses. Return a JSON body with a clear message, an HTTP status like 429 for rate limits, and enough detail for the client to retry or log the issue. The user should see something like "Service temporarily busy. Please retry in 30 seconds," not a screen full of Java class names.

Never Hardcode Secrets

Your Azure OpenAI API key does not belong in application.properties checked into Git. Externalize it. Use environment variables referenced in your Spring configuration, such as ${AZURE_OPENAI_KEY} and ${AZURE_OPENAI_ENDPOINT}. Keep a local .env file for development, add it to .gitignore, and load it through Spring Boot’s relaxed binding. If a key leaks, you rotate it in one place rather than rebuilding your artifact.

Testing Through Swagger

The springdoc-openapi dependency exposes a Swagger UI endpoint at runtime. Once your application starts, open /swagger-ui.html in a browser. You can fill in the Goa example directly: destination as "Goa," budget as 25000, days as 5, interests as "beaches, food." Hit execute and watch the JSON itinerary appear. This lets you validate prompt changes, verify serialization, and share a live playground with frontend developers before either side writes a unit test.

Swapping Providers Without Rewriting Code

Startups change providers. Maybe Azure credits expire, or you want to run inference against a local Ollama instance to cut costs. Because Spring AI abstracts the ChatModel interface, the swap is mechanical. Change the Maven dependency from spring-ai-starter-model-azure-openai to another starter, update your properties file with the new endpoint and key, and leave your service class alone. The API contract seen by your mobile app stays identical.

That portability makes this architecture particularly useful for real products. You are not marrying Azure. You are using it as one engine plugged into a clean Spring pipeline.

The Real Takeaway

An AI model is not your application. It is an external service that returns unpredictable text. Treat it with the same rigor you would give a payment gateway or a third-party weather API. Externalize your credentials. Validate every response. Clean the payload before parsing. Handle errors globally so your users never see a stack trace.

Let the AI handle the creative work of building a Goa itinerary on a 25,000-rupee budget. You handle the plumbing. When the two stay separate, you get a system that actually ships.

The original walkthrough that inspired this article can be found here.

Interested in discussing Spring AI and similar projects? Join the GyaanSetu learning community.