電通総研 テックブログ

電通総研が運営する技術ブログ

モノリスエンタープライズアプリから、最初の1機能をマイクロサービスに切り出す判断軸 ~Goで外出しする例とともに~

こんにちは、電通総研のグループ経営ソリューション事業部でエンジニアをしている大浦です。

本記事は 電通総研 Advent Calendar 2025 12日目の記事です。
昨日の記事は、上野さんによる TanStack AI × Amazon Bedrockで作るAIチャットボットでした。 フロントエンドを起点に AI を組み立てるアーキテクチャの考え方が興味深く面白かったです。

今回は、モノリスで作られたアプリから、1つの機能をマイクロサービスとして切り出す判断についてお話します。

1 はじめに

SaaSアプリケーションの構成として、マイクロサービス化を進めているというサービスについて目にする機会が増えました。
あわせて「最初からマイクロサービスで作るのではなく、モノリスで作り始めて必要になったらマイクロサービス化しましょう」という意見を聞くことも多いかと思います。
Martin Fowlerも以下のように述べています。

「成功したマイクロサービスのほとんどは、最初からマイクロサービスだったわけではない。
まずモノリスとして作られ、それが肥大化して“割らざるを得なくなった”結果として生まれている。」

「逆に、最初からマイクロサービスとして作られたシステムで、うまくいった例はほとんど聞かない。
私の知る限り、そうした多くは深刻な問題を抱えることになっている。」

※原文は以下の2点(著者意訳)
1.Almost all the successful microservice stories have started with a monolith that got too big and was broken up
2.Almost all the cases where I've heard of a system that was built as a microservice system from scratch, it has ended up in serious trouble.

ref) https://martinfowler.com/bliki/MonolithFirst.html

マイクロサービスに分割断面を見出し「分割する」という話は書籍やネットで見つけることができます。
ドメイン境界で切りましょう。チーム構成で切りましょう。など)

本記事では、大きな分割断面を見つけるのではなく、モノリスをマイクロサービス化するための第一歩として
「機能を切り出す」ということにフォーカスした話をします。

ビジネスの拡大に合わせて成長してきたモノリスアプリを前提に以下の3点を扱います。

  1. そもそもマイクロサービスを切り出すべきか
  2. 切り出すと判断したら何を切り出すか
  3. どのように切り出すか

1.1 注意点

以降は筆者が今までにモノリスから機能を切り出す or 切り出さない判断をしてきた中で考えてきたことを元に記載しています。
正解を提示するよりは、判断のための問いや観点を共有することを目的としています。
もし、誰かにとって議論のきっかけになればとても嬉しいです。

2 そもそもマイクロサービスを切り出すべきか

「プロジェクト開始早々にマイクロサービスにするな。」という意見が広く納得されている背景として、モノリスならではの Pros/Cons があります。
マイクロサービス化は、「現状のモノリスから得られるProsを失ってでも、Consを解消する価値がある」と合意出来ている場合に実施を検討します。

2.1 モノリスで得られるPros

2.1.1 安定性

エンタープライズアプリでは基本的に、ACID特性を持つトランザクションが重要です。
モノリス構成(=多くの場合単一のDBで構成)である限り、トランザクションの整合性をシンプルに保てます。

もし機能を疎結合にするために複数のDBにまたがったトランザクションを構成する場合には、分散トランザクションや結果整合性など、より複雑な判断が必要です。

2.1.2 デプロイ容易性

モノリスは通常デプロイ単位が1つであり、実行や停止、スケールをまとめて実施できます。ログも集めやすいです。
特にオンプレミス環境での動作が求められる場合には、デプロイ単位が1つであることで、運用における各種手順を容易にできるなどのメリットもあります。

2.1.3 実装容易性

モノリスは基本的に、コンパイル時にアプリ全体が含まれます。
Javaなどの静的型付け言語では、型やインターフェースの不一致を実行前にコンパイルエラーとして検知できます。

2.2 モノリスのCons

2.2.1 認知負荷の増加

製品のコードベースが大きくなるにつれて、1つの機能変更の影響範囲が広がりやすくなります。
これは、コードベースの認知負荷の増加に繋がります。

2.2.2 リードタイムの増加、リリースサイクルの長期化

変更時の影響範囲の広がりは、修正時のビルド時間、検証事項の増加、変更時の回帰テスト範囲の増加に繋がります。
この結果、リードタイムが伸び、変更が慎重に実施されることでリリースサイクルが長くなる、という問題に繋がります。
これは、新機能の知見を持ったチームがPoCを実施する場合の足かせになります。

2.2.3 柔軟なスケールが実施できない

単一ファイルとしてデプロイする以上、特定の機能だけスケールさせる。といったことはできません。
例えば、特定の時期にだけ重い処理がある場合でも、アプリケーション全体をまとめてスケールアップさせる必要があります。
また、単一デプロイ単位で大きくなるとメモリフットプリントが増加し、スケール時に重要な起動時間の増加にもつながります。

2.2.4 ライブラリの衝突

外部ライブラリなどの依存追加により、ライブラリの衝突などがおこるようになる可能性が高まります。

3 切り出すと判断したら何を切り出すか

では、製品が育ってきてモノリスの辛みが顕在化してきた場合に、どの機能を切り出すかを考えてみます。
切り出しは多くの場合、少なくとも以下の2軸を検討する必要があります。

  • 切り出す価値があるか
  • 切り出しやすいか

以下に、切り出す価値が高いと考える機能の性質を記載します。
切り出す価値が、切り出しやすさやマイクロサービス化の辛みを引き受けてでも実現する価値があるかを検討し、切り出しポイントを決めましょう。

3.1 切り出す価値が高い機能

3.1.1 リリースサイクルや設定変更の周期が製品本体とは違う

競争優位性を得るために迅速なフィードバックループを回すことが求められる機能は、切り出して本体と独立してリリースできるとビジネス上の効果が高いです。
「○○機能チーム」など特定のチームが担当している機能の場合には、他チームとのコミュニケーションパスを限定することで生産性を高めることにつながることもあります。

3.1.2 機能固有の依存を持つ

サードパーティーSDKが必要である機能や、外部サービスへの依存が強い機能を切り出すと、製品本体の依存関係を単純化できます。
依存ライブラリが増えると、ライブラリ衝突のような問題に突き当たることがあり、依存が増えるごとにリスクは高まります。
また、もしライブラリの脆弱性などが出た場合にも影響範囲の局所化や修正の反映が迅速になります。
ライブラリ依存を増やさないようにするための手段としての切り出しも価値があると考えています。

3.1.3 機能単位でスケールアウトなどの構成を取りたい

BI処理など、データの増加に伴いリソース使用量や時間の増加が大きい機能は、切り出すと個別のワークロードを見たスケール判断ができます。
また、月末だけ高負荷がかかるなどピーキーな振る舞いを持つ機能は、メインのアプリはそのままでスケールアップ/スケールアウトが実現できます。

3.1.4 特定の環境でのみ利用される機能

アドオンやオプション機能など、利用有無が顧客や環境によって異なる機能を切り出せると、製品本体品質の維持につながります。
本体から分離することで、認知負荷やテスト範囲を抑えられます。
もしモノリスの中で実現する場合、アドオンやオプションの変更が製品本体の再テストやバージョンアップの足かせとなることがあります。

3.2 切り出しやすい機能

切り出しやすさについても記載します。
ただ、切り出しやすさよりも切り出す価値の方が重要です。
切り出す価値が高い機能であっても、以下のような性質がある場合には切り出しコストに見合わないことが多く、切り出しを行わない根拠になると考えます。

  • データの所有権が分離できない
  • 厳密な同期トランザクションが必須
  • 境界仕様が頻繁に変わる

4 どのように切り出すか

ここでは、具体例としてSpring Frameworkで作成されている経費申請アプリケーションの通知機能を切り出します。
Slack, Teams, LINE, メールと、複数の通知先をもつケースを想定しています。

最初に留意点を記載します。
以下の通知は、マイクロサービス分割の中でも比較的易しい題材となります。
= 状態をほぼ持たず、結果整合性を許容しやすく、失敗しても業務停止になりにくい
だからこそ、モノリスからの最初の切り出しを試すうえで有効と考えます。

また、本番運用をするうえで考慮が必要なポイントについては、「4.3 この例についての補足 (本番運用を考えた場合)」として後述しています。

4.1 モノリスで実現した場合

まずは、Spring Bootで作られたモノリスアプリです。
マイクロサービスとして切り出す前の状態として提示しています。
※今後の説明に重要な部分のみ記載しています。

※通常のSpringアプリの範囲なので全ては説明していません。
もし動くコードを見たい場合は、以下のコードと共に生成AIに聞くことで生成してくれると思います。

4.1.1 通知サービスのインターフェース

Javaにおいては以下のようなインターフェースがあります。

public enum NotificationChannel {
    SLACK, TEAMS, LINE, EMAIL
}

public record NotificationRequest(
        NotificationChannel channel,
        String to,        // SlackのチャンネルID / メールアドレス / LINEのuserId など
        String subject,   // 表題
        String message    // 本文
) {}

public interface NotificationService {
    void send(NotificationRequest request);
}

4.1.2 通知サービスの実行クラス

NotificationServiceを実装したクラスNotificationServiceImplは以下です。
※大規模エンタープライズアプリケーションであればStrategyパターンを用いて、各通知先ごとにクラスを作る構成にすることが多いと考えますが、ここでは簡単のため、全ての実処理をNotificationServiceImpl内に記載しています。

// package jp.co.example.notification.infrastructure;

@Service
public class NotificationServiceImpl implements NotificationService {

    @Override
    public void send(NotificationRequest request) {
        switch (request.channel()) {
            case SLACK -> sendSlack(request);
            case TEAMS -> sendTeams(request);
            case LINE  -> sendLine(request);
            case EMAIL -> sendEmail(request);
            default -> throw new IllegalArgumentException("Unsupported channel: " + request.channel());
        }
    }

    private void sendSlack(NotificationRequest request) {
        // Slack用の通知処理
    }

    private void sendTeams(NotificationRequest request) {
        // Teams用の通知処理
    }

    private void sendLine(NotificationRequest request) {
        // Line用の通知処理
    }

    private void sendEmail(NotificationRequest request) {
        // Eメール用の通知処理
    }
}

4.1.3 build.gradleの依存

各種通知処理にSDKを利用する場合、build.gradleやpom.xmlにSlackやLineなどのSDKへの依存が追加されます。
この通知のためにしか使わない多くのSDKが追加され、Jar Hellの危険が増します。

4.2 マイクロサービスによる切り出しを行った場合

ここでは、実際の通知処理をGoで書かれたマイクロサービスに切り出してみます。

4.2.1 (補足) マイクロサービスの実装言語としてGoを選んだ理由

マイクロサービス側の実装言語は、必ずしも Go である必要はありません。
Java、Kotlin、Node.js、Pythonなど、要件を満たす選択肢は複数あります。
チームの技術スタックを統一したい場合や、重厚なドメインロジックを持つ機能を切り出す場合には、Javaを選ぶことも十分に妥当と考えます。

本記事の例でGoを選んでいる理由は、
「通知機能を切り出したマイクロサービス」という文脈において、運用・構成のコストが低い と判断しているためです。
具体例をいくつか挙げます。

4.2.1.1 Goは単一バイナリとして配布できる

Goでビルドしたアプリケーションは、単一の実行バイナリとして配布できます。
JVMやランタイム、依存ライブラリを別途考慮する必要がなく、以下のような運用上のメリットがあります。

  • デプロイ、プロセス常駐のための運用手順が単純
  • 実行環境の差異によるトラブルが起きにくい

4.2.1.2 高並行な I/O 処理を素直に書ける

通知処理は、多くの場合(CPUバウンドではなく) I/O バウンドです。
Webhookや外部API呼び出しを多数並列に処理するケースでは、goroutineによる軽量スレッドが実装と保守の両面で扱いやすいと感じています。
(また主観ですが、async/awaitや.then()などを駆使するよりも個人的にgoroutineの方が読みやすいです)。

4.2.1.3 起動が速く、リソース特性が読みやすい

Goアプリケーションは起動が速く、メモリ使用量も比較的予測しやすいです。
これは、以下のような場面で効いてきます。

  • オートスケール時の立ち上がり
  • 障害復旧時の再起動

4.2.2 モノリス側(Spring)

まず、Java側は通知を呼び出すだけにします。
= 通知処理を切り出したマイクロサービスに委譲する。

@Service
public class NotificationServiceImpl implements NotificationService {

    private final RestTemplate restTemplate; 
    private final String baseUrl = "http://localhost:8081"; // goプロセスのURL, 本来は設定から読み込む

    @Override
    public void send(NotificationRequest request) {
        try {
            String url = baseUrl + "/api/v1/notify";

            restTemplate.postForLocation(url, request);
            log.info("Notification delegated: channel={}, to={}", request.channel(), request.to());

        } catch (RestClientException e) {
            log.error("Notification delegation failed: channel={}, to={}", request.channel(), request.to(), e);
            // 恐らくここは設計判断が必要、ログだけ出して握り潰す?業務エラーにする?
            throw new RuntimeException("Failed to delegate notification", e);
        }
    }
}

通知を依頼するためのHTTPリクエストを投げるだけになっています。
実際に通知をする処理を委譲したことで、通知先毎の各種SDKはアプリ本体側では不要になりました。

実際に送信されるリクエストは以下のような構造になる想定です
例) Slackへの承認依頼Post

{
  "channel": "SLACK",
  "to": "U0456ABCD", 
  "subject": "経費申請の承認依頼",
  "message": "【承認依頼】\n申請者: 山田太郎\n申請番号: EXP-2025-00123\n金額: 12,480円\n申請日: 2025/02/10\n\n以下のリンクから内容を確認し、承認または差戻しをお願いします。\nhttps://expense.example.com/approval/EXP-2025-00123"
}

4.2.3 切り出したマイクロサービス側

Go側では、通知リクエストから適切な通知先に通知を送ります。

package main

import (
    "encoding/json"
    "log"
    "net/http"
)

// 通知のリクエスト構造
type NotificationRequest struct {
    Channel string `json:"channel"`
    To      string `json:"to"`
    Subject string `json:"subject"`
    Message string `json:"message"`
}

func main() {
    http.HandleFunc("/api/v1/notify", NotifyHandler)

    log.Println("Notification Service running on :8081")
    if err := http.ListenAndServe(":8081", nil); err != nil {
        log.Fatal(err)
    }
}

func NotifyHandler(w http.ResponseWriter, r *http.Request) {
    var req NotificationRequest

    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        log.Println("Invalid request:", err)
        http.Error(w, "invalid request", http.StatusBadRequest)
        return
    }

    // 即レスポンスを返す(Spring側を待たせない)
    w.WriteHeader(http.StatusAccepted)

    // 通知を非同期で処理(goroutine利用)
    go func(req NotificationRequest) {
        switch req.Channel {
        case "SLACK":
            sendSlack(req)
        case "TEAMS":
            sendTeams(req)
        case "LINE":
            sendLine(req)
        case "EMAIL":
            sendEmail(req)
        default:
            log.Printf("[goroutine] unsupported channel: %s\n", req.Channel)
            return
        }

        log.Printf("[goroutine] completed notification: channel=%s to=%s\n", req.Channel, req.To)

    }(req)
}

func sendSlack(req NotificationRequest) {
    // Slack SDK or Webhook を呼ぶ処理
    log.Printf("[Slack] sending to %s\nmessage:\n%s\n", req.To, req.Message)
}

func sendTeams(req NotificationRequest) {
    // Teams SDK or Webhook を呼ぶ処理
    log.Printf("[Teams] sending to %s\nmessage:\n%s\n", req.To, req.Message)
}

func sendLine(req NotificationRequest) {
    // Line SDK or Webhook を呼ぶ処理
    log.Printf("[LINE] sending to %s\nmessage:\n%s\n", req.To, req.Message)
}

func sendEmail(req NotificationRequest) {
    // Eメールを送信する処理
    log.Printf("[Email] sending to %s\nsubject=%s\nmessage:\n%s\n", req.To, req.Subject, req.Message)
}

4.3 この例についての補足 (本番運用を考えた場合)

前述のGo実装は説明用の最小構成です。
もしこのまま本番に出してしまうと、以下のような問題が起きます。

  • goroutine実行中にプロセスが落ちた場合、モノリス側では成功したように見えるが実際には通知されない。(アプリ側のみ正常終了など)
  • 二重送信が発生しても止められない。(通知処理完了後にアプリ側の処理が異常終了、マイクロサービス側のみ正常終了など)
  • 障害時に「どの申請の通知が失敗したか」追えない。

上記に対応しようとする場合は、以下のような対応が検討対象になります。

  • 相関IDの追加
    • 申請番号など、モノリス側との一連の処理を紐づける情報を加える。
  • リトライ + バックオフ + デッドレターキュー(DLQ)の追加
    • 通知先サービスが一時的に落ちたときのために再処理を可能にする仕組みを加える。
    • マイクロサービス側のプロセスが落ちても適切に動作するように、マイクロサービス側でDBを持つなど。
  • べき等キーの追加
    • 申請書番号 + イベント種別 + 通知先 などを覚えておき、通知済みの場合は通知しない。
  • メトリクスとアラートの設定
    • 通知の成功率や遅延、DLQ件数 などを監視対象にする。

このように、割と簡単な例でも本番で運用する場合には考慮すべきポイントが数多くあります。
この辺りは、モノリスのConsとマイクロサービスのProsだけを見ている場合に見落とすことがあります。
できる限り、切り出した場合の大変さについても切り出しの検討段階で考慮したいですね。

5 まとめ

今回は、モノリスで作られたアプリから、1つの機能をマイクロサービスとして切り出す判断について書いてきました。

  1. そもそもマイクロサービスを切り出すべきか
    → マイクロサービス化する場合は、モノリスのPros / Consを意識する。
    → 特に、モノリスのProsを考える。

  2. 切り出すと判断したら何を切り出すか
    → 切り出すことで得られる価値が高い機能を切り出す。
    → 価値が高い機能であっても、切り出しやすさを含めトレードオフを見る。

  3. どのように切り出すか
    → 第一歩は、状態をほぼ持たず、結果整合性を許容しやすく、失敗しても業務停止になりにくい機能を対象にする。
    → 本番運用において、マイクロサービス化で問題となる特別な考慮ポイントについて十分に検討する。

最後に、ここまではおおむねアーキテクトの観点でのみ切り出す判断を書いてきています。
実際のプロダクト開発においては、失うProsが運用に直撃します。
そのため、切り出し判断はアーキテクトだけでなく、保守/運用・SRE・QA・プロダクトチームなどを巻き込んで合意することになると思います。 今後も、製品の成功のために出来ることを考え周囲と議論しながら、製品開発を推進していきたいと考えています。

私たちは一緒に働いてくれる仲間を募集しています!

電通総研 キャリア採用サイト 電通総研 新卒採用サイト

執筆:@oura.osamu
レビュー:@nagamatsu.yuji
Shodoで執筆されました