2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AI は集約(Aggregate)を認識していない。Domain Driven Design で AI に正しく指示を出す

2
Last updated at Posted at 2026-06-18

Google AI Studio は簡単な指示で見栄えのいい画面を作ってくれる…が…

現在、AI での開発の練習として Google AI Studio で「映画の予約サイト(スタッフ側)」を作っています。
とりあえず以下のように Google AI Studio に指示をしてみました。

現在、映画予約システムを開発中です。localhost:8080/api/staff/login にアクセスしてログインする画面を作ってください。
認証にはJWTトークンを使用し、ログイン成功後はダッシュボードへ遷移するように実装してください。

出来た画面がこちらです。ログイン画面と空のダッシュボードだけ出来ればいいかなと思ってたのにダミーの映画スケジュールまである!凄い!でも他所様の会社名が入ってるのはだめだと思う!

image.png

image.png

ダッシュボード

image.png

ダッシュボードの下には指示をしてないのに席を予約する機能があります!

image.png

座席をクリックすると管理側で特別に席が取れます!席の色が変わって、WebAPI にリクエストまで出してるよ凄い!
これが「映画の予約サイト」とだけしか打ち込んでない AI が生成した予約リクエストです!!

image.png

PUT (Local simulation) /api/seats/toggle/F-8

…いや確かに席にアクセスはしてる。してるけど…いつのどの部屋の席!?
toggle は画面・画像の操作のことなので名前もなんか違いますね。booking くらいは生成してほしかった…

AI は対象をそのまま保存するのには強いけど関連には弱く集約は知らない

観客が席を取ることができないなら映画を上映するほうはどうでしょう?
AI に「上映予定を管理する画面を作って!」というと部屋に対して時間を指定して「映画名を入力する」画面を作ってくれました。

image.png

惜しい!TODOリストとか会議場・会場予約システムみたいなのだったらそれでもよさそうなんですけどね!

AI によるコード生成の記事はたくさんあります。TODOリストとかは「TODOリストを作って」というだけで作ってくれるそうですね!
ではなぜ映画の予約はできないのでしょうか?AIはTODOリストのように対象のみを操作する・画面に情報がまとまっているコードには強いですが、書いてないことは知らないからですね。

映画の予約と言っても実際には席を予約している…までは AI は認識してますね。実際にコードや画面があるのでしょう。
ですが席と映画がどう関連しているかは知らないし、席が部屋にあることも映画がいつのどの部屋で上映されるかも知りません。
一つの部屋で同時に複数の映画を上映できないことも知らないかもしれません。スタッフ画面に「上映スケジュール機能」なんかを作ったら上映予定が被ったりしそうですね!

集約(aggregation)とは

「集約とは」でググったり AI に聞いたりするとこう返してきます。

「集約 (Aggregate) とは、関連するオブジェクト群を 1 つのユニットとして管理するための手法です」

これだけでは何を言いたいのか分かりませんね?

「集約」とは「お互いに影響を与えたり何らかの制限がありながら存在するオブジェクトのまとまり」です。
例えば、今の映画館(シネマコンプレックス)には上映室が複数ありますが、上映室にはそれぞれ別の名前がついています。別々の映画を上映するのに部屋の名前が同じだとどこで上映するのか分かりませんね!

また、映画の上映をするときは一つの上映室で一つのスクリーンに映画を上映します。やろうと思えば複数のスクリーンを設置することもできるでしょうが複数の映画を同じ部屋で同時に上映してもきっと楽しくはないでしょう。

また、構成するオブジェクトによって全体に変化が出ることもあります。例えば特別な音響機材を導入して爆音で上映することもできます(そのときは部屋の防音を強化しないと音が漏れてしまいますね!)し、機材が故障したら映画の上映自体ができないでしょう。

特に忘れがち…あるいは誰も気にしていないのかもしれませんが、集約は「人はその一部にしか注目していないけど実際には集約自体や集約内の別のオブジェクトの話をしている」ことが多いのに注意が必要です。

映画を見に行くのを「映画館に行く」ということはできますが、「昨日は〇〇の映画を見に行った」ということはあるでしょうが「昨日は映画館に行った」と言われてもそれで何を見てきたんだよって思ってしまいますよね。

また、仮にソクラテスが禿げだったとして「ソクラテスの頭は禿げている」と「頭」という単語を入れても情報量は増えていません。人にとっては、の話ですが。 AI は「頭」を指示しないとわからないことがあるので思わぬところで指示漏れが発生します。(オブジェクトの状態を表すのにソクラテスを使うのはアリストテレスからの伝統であって悪意があるわけじゃないですよ!)

Domain Driven Design (DDD)で「集約」を使って設計する

Domain Driven Design (DDD)というモデリングの考え方があります。
「DDD とは?」でググったり AI に聞いたりするとこう返してきます。

「ソフトウェア開発において「実際の業務やビジネスのルール(ドメイン)」をシステムの中心に据える設計手法」

これも何を言いたいのか分かりませんね!そもそも DDD ではなくてもシステムを開発するときは実際の業務やビジネスのルールをシステムの中心に据えるじゃないですか。

そもそも Domain = 業務 というのは誤訳です。Domain とは Core が主体性を持つ領域、という意味です。
Core …は業務の中心じゃないですよ?ビジネスにおいてならば「実際にお金を出してくれる人」、なんらかのサービスなら「そのサービスで実現したいことやサービスを受ける人」が Core です。

例えば、映画館の Core は映画を上映すること、ではありません。
観客に料金を払ってもらうこと、観客に映画を見てもらうこと、です。
観客なしに映画を上映することもできますがそれはビジネスとは言えませんよね?

ですが一言で映画を見てもらうといってもそのためにやらなければいけないこと、つまり業務がたくさん存在します。この業務をドメインや集約として分割して設計していくのが Domain Driven Design です。

まずドメインをコアサブドメインとサポートサブドメインにわける

ではモデリング…の前に、ドメインを分割します。
ドメインの違い・ドメインの境界とはなんでしょうか?それは「決定権を持つのは誰か」です。
ドメインはよくコアサブドメイン・サポートサブドメイン・一般サブドメインの三つがあると言われます。
ある機能はどのドメインに属するか?を判断することに困ることがありますが決定権者の違いではっきり分けることができます。
私は api も決定権者の違いで分けるのが好みです。

映画のコアドメインは映画を見ることであり、決定権は観客が持ちます。

映画のコアドメインは映画館から見れば映画を見せて料金をもらう(ついでにグッズやフードも買ってもらう)ことです。ですが、映画を無理やり見せて料金を払ってもらうことはできません。

どの映画をいつ見るかは観客が決めることでコアドメインの中心には観客がいます。
映画を見るには予約と…上映予定を知る必要がありますね。

これらをビジネスロジックとして今回はバックエンドに実装し、WebAPI でアクセスできるようにします。
api のルートは…観客と言っても予約のできる会員の方と上映予定を見ることのできる(会員を含めて)一般の方がいますから /api/members/ と /api/public/ の二つあったほうが良いかな?ちょっとオーバーエンジニアリングな気もしますね。

映画のサポートドメインは映画を見せることであり、決定権は映画館のスタッフが持ちます。

映画の上映予定を管理したり、
観客の代わりに予約を入れ…は多分しなさそうですが、
割引用の身分証を忘れたときに通常チケットへ切り替える業務とかはあるかもしれませんね。
新しい映画館の登録とか部屋の追加とかは…たまにしかやらなさそうな気がしますが、機材故障や汚損で部屋や席を使用禁止にするくらいは通常で発生するかもしれません。

api のルートは /api/staffs とします。

staff は映画の上映予定を立てる

映画館の物理的制約

映画を見る・見せるには上映しなければなりません。ここで物理的制約が発生します。

  • (上映には関係ないけど)企業は複数の映画館を持ち、映画館には複数の部屋があり、部屋には複数の椅子がある
  • 部屋に対してある時間帯で上映する
  • 一つの部屋で同時に上映できる映画は一つだけ
  • 上映する映画は買った映画のリストから選択する
  • 席には部屋ごとにユニークな座席番号がついている
  • フィルムの映画は複数の部屋で同時に上映できない(今はこんな制約ないですね何年前の話でしょう?)

なのでバックエンドは、DB上にある部屋と映画を時間を指定して紐づけることになります。

上映計画の前に部屋と映画の CRUD を作っておきましょう。使わない気もしますが AI に上記の情報と「部屋の CRUD と 映画の CRUDを /api/staffs」に作ってというだけで作ってくれます。
ここからはローカルの Intellij IDEA で Spring boot プロジェクトを作って Gemini plugin に作ってもらいます。できた WebApi を Google AI studio に渡せば正しくたたいてくれるでしょう。

部屋の CRUD を作る

まず物理的な部屋の集約を作ります。物理的な制限(例えば基本的に部屋が追加されることはない)や名前的な制限(ユニーク制約)が多いので登録時にチェックが必要になります。
映画館は Theater が一般的ですが、上映室は TOHO では Screen で シネマシティでは Studio ですかね。なお AI に書かせると上映室が Theater になりますがこれは建物自体を表す言葉ですね…。
上映は Screening なので Screen が映画館っぽいでしょうか。Studio だと劇場っぽいかな?

AI に以下のように指示を出すだけでCRUDを作ってくれました。Controller はtheatersしか作ってくれなかったので指示を繰り返す必要がありますが全部指定すれば全部作ってくれるのかな…?

映画予約システムを作ります。次の Entity の集約を作って domain/entity に保存してください。
映画予約対象の映画館グループは複数の映画館(theater)からできています。映画館は複数の部屋(screen)からできています。部屋には複数の席があります。
映画は作品ごとに管理し、それぞれの部屋で特定の時間帯に上映します。多対多の関連があるということです。
それぞれ ID と名前を持ちます。映画は上映時間を持ち、上映時刻は上映時間帯を持ちます。以上が Entity の説明です。
管理用に /staff/theaters に映画館の CRUD ができるAPIを作り、他の Entity の CRUD API も作ってください。

映画の CRUD を作る

AIへの指示は↑にまとまっていますが、設計としては…
映画はたぶん配給会社がタイトルとか上映時間とかの情報を持ってるんでしょうね。きっとそれらに映画館側の情報(ジャンルのタグとか?)を追加してるんでしょう。

今はタイトルと上映時間だけでいいかな。

上映予定の CRUD を作る

上映予定は部屋に対して複数存在します。これは時間をずらすという条件があるのでただの関連ではなく集約として作り制約を実装する必要があります。
上映は映画を一つだけ上映します。買った映画しか上映できませんがそれは「買った映画集約」で、スクリーンに映画を映すのは集約の一部ではありますが特に制約はありません。今は。
昔、フィルムで映画を上映していた時は同じフィルムを複数の部屋で同時に使えないという物理的な制限がありましたのでその時代は集約に制約として実装するべきでした。集約の関係性も時代によって変わるんですね!

また、STOP!映画泥棒や予告編などの上映をしてから本編が始まります。メインとは別コンテンツだから additional かな?
清掃・お客さんの入れ替えなどの時間もありますが…観客には関係ないかな?映画の上映時間は一定ではないのでこれらのインターバルは一定ではないですが0というわけでもなさそうです。今は additional にまとめておくとして、最低限の intervalTime とかを設定できるようにするべきかもしれません。

余談ですが AI に延長の時間があると指示したら additional になりません。extend とかになってしまいました。追加の別映像の上映時間があると指示する必要があります。

WebAPI は基本的に集約に対してアクセスします。
上映予定は部屋に強く依存しているので次のようになるでしょう。theater までモデリングしたのはオーバーエンジニアリングだったかな…?

POST /api/staffs/theaters/{id}/screens/{id}/screening
{"movieId":1, "startTime": "12:00", "additionalTime":5}

ここまでで映画を予約する際のサポートサブドメイン側の CRUD ができました!

コアドメイン側のコード作成

映画を予約、どの映画のどの上映のどの席かを指定するのはユーザなので /api/members/ に作りましょう。
予約にも色々ありますが元々は席と時間の予約を記帳することだったので Booking ですね。
家族の分の席も同時に予約するので席は複数指定できることとします。
映画に家族割ってあるのかな…?まぁ子供は普通に子供料金なので観客 audience も必要ですね。これはたぶん定数です。

ドメインによって集約の見え方は違います

失敗例:これを下のような API にすると不自然になります。

POST /api/members/theaters/{id}/screens/{id}/screenings/{id}
{"seats":[{"seatName":"A-01","audience":"MEMBER"}]}

観客は見る映画を決めるのに映画の上映予定一覧を見るはずです。上映予定一覧で部屋を意識するのはおかしいですね!
一番単純な予約APIはきっとこうなります。

POST /api/members/screenings/{id}
{"seats":[{"seatName":"A-01","audience":"MEMBER"}]}

上映の一覧が欲しいとき...これは誰でも見られるので public ユーザ向けかな。
行動圏内に複数の映画館がある人もいるので映画館は指定したりしなかったりしそうです。

// 全上映予定
GET /api/public/screenings/

// ある映画館の上映予定
GET /api/public/theaters/{id}/screenings/

// ある日のある映画館の上映予定
GET /api/public/theaters/{id}/screenings?date=2026-06-17

バックエンド側コード生成

これらの情報をすべて AI に入力してコードを生成させます!(mermaid のコードは記事を書くときに書いたので日本語で入力しました)
Controller は普通に作ってくれて Service は…
席の存在チェックや重複チェックもしてくれてますね!Audienceが外れてるときは 0 円になったりリクエスト・レスポンスをそのまま入出力したりしていますが次にやるときは先に .md で前提として書いてやればいいでしょう!
バックエンド側は完成です!

package com.example.geminiassistdemo.domain.service

@Service
class BookingService(
    private val bookingRepository: BookingRepository,
    private val screeningRepository: ScreeningRepository,
    private val seatRepository: SeatRepository,
    private val bookedSeatRepository: BookedSeatRepository
) {

    // --- Read ---
    @Transactional(readOnly = true)
    fun findBookingById(bookingId: Long): Booking? {
        return bookingRepository.findById(bookingId).orElse(null)
    }

    @Transactional(readOnly = true)
    fun findBookingsForUser(user: User): List<Booking> {
        return bookingRepository.findByUser(user)
    }

    // --- Create ---
    @Transactional
    fun createBooking(request: BookingRequest, user: User): Result<Booking> {
        val screening = screeningRepository.findById(request.screeningId).orElse(null)
            ?: return Result.failure(IllegalArgumentException("Screening not found"))

        val seatNames = request.seatRequests.map { it.seatName }
        val seats = seatRepository.findByScreenIdAndNames(screening.screen.id, seatNames)
        if (seats.size != seatNames.size) {
            return Result.failure(IllegalArgumentException("Some seats not found in this screen"))
        }

        val alreadyBooked = bookedSeatRepository.findByScreeningIdAndSeatIds(screening.id, seats.map { it.id })
        if (alreadyBooked.isNotEmpty()) {
            val bookedSeatNames = alreadyBooked.joinToString(", ") { it.seat.name }
            return Result.failure(IllegalStateException("Seats already booked: $bookedSeatNames"))
        }

        val totalAmount = calculateTotalAmount(request.seatRequests)
        val booking = Booking(user = user, screening = screening, totalAmount = totalAmount.value)
        val bookedSeats = request.seatRequests.map { seatRequest ->
            val seat = seats.first { it.name == seatRequest.seatName }
            val audience = Audience.valueOf(seatRequest.audience.uppercase())
            BookedSeat(booking = booking, seat = seat, audience = audience)
        }
        booking.bookedSeats.addAll(bookedSeats)
        val savedBooking = bookingRepository.save(booking)
        return Result.success(savedBooking)
    }

    @Transactional
    fun createBookingForUser(request: StaffBookingRequest, targetUser: User): Result<Booking> {
        val memberRequest = BookingRequest(
            screeningId = request.screeningId,
            seatRequests = request.seatRequests
        )
        return createBooking(memberRequest, targetUser)
    }

    // --- Update ---
    @Transactional
    fun updateBooking(bookingId: Long, request: BookingRequest): Result<Booking> {
        val booking = findBookingById(bookingId)
            ?: return Result.failure(IllegalArgumentException("Booking not found"))

        val originalUser = booking.user
        val screeningIdBeforeUpdate = booking.screening.id
        bookingRepository.delete(booking)

        if (request.screeningId != screeningIdBeforeUpdate) {
            return Result.failure(IllegalArgumentException("Changing the screening is not allowed."))
        }

        return createBooking(request, originalUser)
    }

    // --- Delete (Cancel) ---
    @Transactional
    fun cancelBooking(bookingId: Long): Boolean {
        return findBookingById(bookingId)?.let {
            bookingRepository.delete(it)
            true
        } ?: false
    }

    private fun calculateTotalAmount(seatRequests: List<SeatRequest>): Yen {
        return seatRequests.map {
            val audience = Audience.valueOf(it.audience.uppercase())
            audience.basePrice
        }.reduceOrNull { acc, yen -> acc + yen } ?: Yen(0)
    }
}

フロント側コード生成

では席の予約画面も生成させてみましょう。久しぶりの Google AI Studio です。
会員側の画面はまだ作ってないので、スタッフが会員の代わりに予約する形式(そんな業務はないでしょうが)で、と…以下のように AI に指示を出しました。

映画の予約画面を作ってください。映画予約は映画の上映予定(Screening)に対して席(seat)を指定する形で行います。API は POST /api/staffs/members/{id}/screenings/{id}
{"seats":[{"seatName":"席番号","audience":"観客の種類"}]}
上映予定には 映画(Movie)が紐づいており、Movieには名前と上映時間があるので予約時に確認できるようにしてください。
上映予定には部屋が紐づいており、部屋にある席の一覧から複数の席を選択できます。席には観客の種類を選択できます。このとき、すでに予約済みの席は選択できません。
上映予定には開始時刻と追加の上映時間(additionalTime)があり、予約時に確認できるようにしてください。

Google AI Studioは無事画面を修正してくれました!
名前とEmailの欄があるのは会員の確認用かな?

image.png

image.png

送信しているリクエストは、と…

image.png

会員IDの指示が甘かったのでログインしているスタッフのIDが入ってたり上映IDがおかしかったりしていますが大まかには成功ですかね?
きちんと会員側の画面として作って、上映予定に対しての予約画面として作らせれば自然になりそうです。
観客の種類とか料金はたぶんどこかの映画館の料金を参考にしてるんでしょうからそれも API を設計して通信させるようにしないといけないですね。
それっぽいフロントが簡単に作れるのはいいのですが抜け漏れなく指示を出すのは大変だ…

最後に

今回は対話しながら作ったのですが Claude などで作るときはこれらをまとめておいてから渡すことになるのでしょうか。この記事をまとめなおして Claude に食わせてみようかな。

最後になりますが私は映画館に行ったことがないので間違いを教えてもらえたら直します!

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?